Files
nat-as-server/lib/analytics.php
Tom Hempel 6b3cd0da17
Some checks failed
PHPUnit / test (push) Has been cancelled
adding client notes
2026-07-17 17:10:04 +02:00

437 lines
16 KiB
PHP

<?php
/**
* Analytics queries for Insights dashboard (RBAC-scoped).
*/
/**
* Daily upload counts for the last N calendar days (zeros for quiet days).
*
* @return list<array{date: string, count: int}>
*/
function qdb_analytics_submissions_by_day(PDO $pdo, array $tokenRec, int $days = 14): array {
$days = max(1, min(90, $days));
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$startTs = strtotime('today', time()) - ($days - 1) * 86400;
$stmt = $pdo->prepare(
"SELECT qs.submittedAt
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :start"
);
$stmt->execute(array_merge($rbacParams, [':start' => $startTs]));
$byDate = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $submittedAt) {
$key = date('Y-m-d', (int)$submittedAt);
$byDate[$key] = ($byDate[$key] ?? 0) + 1;
}
$out = [];
for ($i = 0; $i < $days; $i++) {
$key = date('Y-m-d', $startTs + $i * 86400);
$out[] = ['date' => $key, 'count' => $byDate[$key] ?? 0];
}
return $out;
}
function qdb_analytics_overview(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM client cl WHERE $rbacClause");
$stmt->execute($rbacParams);
$clientCount = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE $rbacClause"
);
$stmt->execute($rbacParams);
$clientsWithCompletions = (int)$stmt->fetchColumn();
$now = time();
$d7 = $now - 7 * 86400;
$d30 = $now - 30 * 86400;
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :t"
);
$stmt->execute(array_merge($rbacParams, [':t' => $d7]));
$submissions7d = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE $rbacClause AND qs.submittedAt >= :t"
);
$stmt->execute(array_merge($rbacParams, [':t' => $d30]));
$submissions30d = (int)$stmt->fetchColumn();
$qnRows = $pdo->query(
"SELECT questionnaireID, name, orderIndex FROM questionnaire WHERE state = 'active' ORDER BY orderIndex, name"
)->fetchAll(PDO::FETCH_ASSOC);
$perQuestionnaire = [];
foreach ($qnRows as $qn) {
$qnID = $qn['questionnaireID'];
$stmt = $pdo->prepare(
"SELECT COUNT(DISTINCT cq.clientCode) FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE cq.questionnaireID = :qn AND $rbacClause"
);
$stmt->execute(array_merge([':qn' => $qnID], $rbacParams));
$completed = (int)$stmt->fetchColumn();
$ratio = $clientCount > 0 ? round(100 * $completed / $clientCount, 1) : 0;
$perQuestionnaire[] = [
'questionnaireID' => $qnID,
'name' => $qn['name'],
'completedCount' => $completed,
'eligibleCount' => $clientCount,
'completionRatio' => $ratio,
];
}
return [
'clientCount' => $clientCount,
'clientsWithCompletions' => $clientsWithCompletions,
'submissionsLast7d' => $submissions7d,
'submissionsLast30d' => $submissions30d,
'questionnaires' => $perQuestionnaire,
'submissionsByDay' => qdb_analytics_submissions_by_day($pdo, $tokenRec, 14),
'scoringProfiles' => qdb_analytics_scoring_profiles($pdo, $tokenRec),
];
}
/**
* Band distribution and averages per active scoring profile (RBAC-scoped).
*
* @return list<array<string, mixed>>
*/
function qdb_analytics_scoring_profiles(PDO $pdo, array $tokenRec): array {
require_once __DIR__ . '/scoring.php';
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$profiles = $pdo->query(
"SELECT profileID, name, description,
greenMin, greenMax, yellowMin, yellowMax, redMin, isActive
FROM scoring_profile WHERE isActive = 1 ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($profiles as $p) {
$profileID = $p['profileID'];
$stmt = $pdo->prepare(
"SELECT r.band, r.coachBand, r.weightedTotal
FROM client_scoring_profile_result r
INNER JOIN client cl ON cl.clientCode = r.clientCode
WHERE r.profileID = :pid AND ($rbacClause)"
);
$stmt->execute(array_merge([':pid' => $profileID], $rbacParams));
$computedBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$coachBands = ['green' => 0, 'yellow' => 0, 'red' => 0];
$pendingReview = 0;
$totalSum = 0.0;
$count = 0;
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$calcBand = $row['band'] ?? '';
if (isset($computedBands[$calcBand])) {
$computedBands[$calcBand]++;
}
$coachBand = trim((string)($row['coachBand'] ?? ''));
if ($coachBand === '') {
$pendingReview++;
} elseif (isset($coachBands[$coachBand])) {
$coachBands[$coachBand]++;
}
$totalSum += (float)$row['weightedTotal'];
$count++;
}
$members = qdb_scoring_profile_members($pdo, $profileID);
$bandRanges = qdb_normalize_scoring_bands($p);
$out[] = [
'profileID' => $profileID,
'name' => $p['name'],
'description' => $p['description'] ?? '',
'greenMin' => $bandRanges['greenMin'],
'greenMax' => $bandRanges['greenMax'],
'yellowMin' => $bandRanges['yellowMin'],
'yellowMax' => $bandRanges['yellowMax'],
'redMin' => $bandRanges['redMin'],
'questionnaires' => $members,
'clientCount' => $count,
'bands' => $computedBands,
'computedBands' => $computedBands,
'coachBands' => $coachBands,
'pendingReview' => $pendingReview,
'averageTotal' => $count > 0 ? round($totalSum / $count, 2) : null,
];
}
return $out;
}
function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$now = time();
$sql = "
SELECT cl.clientCode,
co.username AS coachUsername,
co.coachID,
lc.lastCompletedAt,
lc.lastQuestionnaireID,
qn.name AS lastQuestionnaireName,
la.lastActivityAt,
n.note AS followupNote
FROM client cl
INNER JOIN (
SELECT cq.clientCode, cq.questionnaireID AS lastQuestionnaireID, cq.completedAt AS lastCompletedAt
FROM completed_questionnaire cq
WHERE cq.completedAt IS NOT NULL
AND cq.rowid = (
SELECT c2.rowid FROM completed_questionnaire c2
WHERE c2.clientCode = cq.clientCode AND c2.completedAt IS NOT NULL
ORDER BY c2.completedAt DESC, c2.questionnaireID DESC
LIMIT 1
)
) lc ON lc.clientCode = cl.clientCode
LEFT JOIN questionnaire qn ON qn.questionnaireID = lc.lastQuestionnaireID
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN (
SELECT qs.clientCode, MAX(qs.submittedAt) AS lastActivityAt
FROM questionnaire_submission qs
GROUP BY qs.clientCode
) la ON la.clientCode = cl.clientCode
LEFT JOIN client_followup_note n ON n.clientCode = cl.clientCode
WHERE $rbacClause
ORDER BY COALESCE(la.lastActivityAt, 0) ASC, cl.clientCode ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($rbacParams);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$completedTs = $r['lastCompletedAt'] ? (int)$r['lastCompletedAt'] : null;
$uploadTs = $r['lastActivityAt'] !== null ? (int)$r['lastActivityAt'] : null;
$lastAt = null;
if ($completedTs !== null && $uploadTs !== null) {
$lastAt = max($completedTs, $uploadTs);
} elseif ($completedTs !== null) {
$lastAt = $completedTs;
} else {
$lastAt = $uploadTs;
}
$daysSince = $lastAt !== null
? (int)floor(($now - $lastAt) / 86400)
: null;
$out[] = [
'clientCode' => $r['clientCode'],
'coachUsername' => $r['coachUsername'] ?? $r['coachID'],
'lastQuestionnaireID' => $r['lastQuestionnaireID'],
'lastQuestionnaireName' => $r['lastQuestionnaireName'] ?? $r['lastQuestionnaireID'],
'lastCompletedAt' => $completedTs ? date('Y-m-d H:i', $completedTs) : '',
'daysSinceLastActivity' => $daysSince,
'note' => $r['followupNote'] ?? '',
];
}
return $out;
}
/** Max length for counselor/admin client follow-up notes (characters). */
const QDB_CLIENT_NOTE_MAX_LENGTH = 200;
/**
* Sanitize a client follow-up note. Empty string clears the note.
* Rejects input that sanitizes to empty while the raw value was non-empty.
*/
function qdb_normalize_client_followup_note(mixed $note): string {
require_once __DIR__ . '/text_sanitize.php';
$raw = is_string($note) || is_numeric($note) ? trim((string)$note) : '';
if ($raw === '') {
return '';
}
if (mb_strlen($raw) > QDB_CLIENT_NOTE_MAX_LENGTH) {
json_error(
'INVALID_FIELD',
'note must be at most ' . QDB_CLIENT_NOTE_MAX_LENGTH . ' characters',
400
);
}
$clean = sanitize_free_text($raw, QDB_CLIENT_NOTE_MAX_LENGTH);
if ($clean === '') {
json_error('INVALID_FIELD', 'note contains disallowed content', 400);
}
return $clean;
}
/**
* Upsert a follow-up note for a client visible under the caller's RBAC.
* Pass an already-normalized note (see qdb_normalize_client_followup_note).
*/
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
$clientCode = trim($clientCode);
if ($clientCode === '') {
json_error('INVALID_FIELD', 'clientCode is required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$stmt = $pdo->prepare("SELECT 1 FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)");
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$stmt->fetchColumn()) {
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$pdo->prepare(
'INSERT INTO client_followup_note (clientCode, note, updatedByUserID, updatedAt)
VALUES (:cc, :n, :uid, :ts) '
. qdb_upsert_update(
['clientCode'],
[
'note' => 'excluded.note',
'updatedByUserID' => 'excluded.updatedByUserID',
'updatedAt' => 'excluded.updatedAt',
]
)
)->execute([
':cc' => $clientCode,
':n' => $note,
':uid' => $tokenRec['userID'] ?? '',
':ts' => time(),
]);
}
function qdb_coach_activity_list(PDO $pdo, array $tokenRec): array {
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
if ($role === 'supervisor') {
$coachWhere = 'co.supervisorID = :eid';
$coachParams = [':eid' => $entityID];
} else {
$coachWhere = '1=1';
$coachParams = [];
}
$now = time();
$d7 = $now - 7 * 86400;
$d30 = $now - 30 * 86400;
$sql = "
SELECT co.coachID, co.username,
sv.username AS supervisorUsername,
(SELECT COUNT(*) FROM client c
WHERE c.coachID = co.coachID AND COALESCE(c.archived, 0) = 0) AS clientCount,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS totalSubmissions,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d7) AS submissions7d,
(SELECT COUNT(*) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID AND qs.submittedAt >= :d30) AS submissions30d,
(SELECT MAX(qs.submittedAt) FROM questionnaire_submission qs
INNER JOIN client c ON c.clientCode = qs.clientCode
WHERE c.coachID = co.coachID) AS lastSubmissionAt
FROM coach co
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE $coachWhere
ORDER BY co.username
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':d7' => $d7, ':d30' => $d30], $coachParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$out = [];
foreach ($rows as $r) {
$last = $r['lastSubmissionAt'] !== null ? (int)$r['lastSubmissionAt'] : null;
$out[] = [
'coachID' => $r['coachID'],
'username' => $r['username'],
'supervisorUsername'=> $r['supervisorUsername'] ?? '',
'clientCount' => (int)$r['clientCount'],
'totalSubmissions' => (int)$r['totalSubmissions'],
'submissionsLast7d' => (int)$r['submissions7d'],
'submissionsLast30d'=> (int)$r['submissions30d'],
'lastSubmissionAt' => $last ? date('Y-m-d H:i', $last) : '',
];
}
return $out;
}
function qdb_coach_recent_submissions(
PDO $pdo,
array $tokenRec,
string $coachID,
int $limit = 100,
int $offset = 0
): array {
$coachID = trim($coachID);
if ($coachID === '') {
json_error('INVALID_FIELD', 'coachID is required', 400);
}
$role = $tokenRec['role'] ?? '';
$entityID = $tokenRec['entityID'] ?? '';
$chk = $pdo->prepare('SELECT coachID, supervisorID FROM coach WHERE coachID = :id');
$chk->execute([':id' => $coachID]);
$coach = $chk->fetch(PDO::FETCH_ASSOC);
if (!$coach) {
json_error('NOT_FOUND', 'Counselor not found', 404);
}
if ($role === 'supervisor' && ($coach['supervisorID'] ?? '') !== $entityID) {
json_error('FORBIDDEN', 'Not authorized for this coach', 403);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$limit = max(1, min($limit, 200));
$offset = max(0, $offset);
$countSql = "
SELECT COUNT(*)
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE cl.coachID = :cid AND ($rbacClause)
";
$countStmt = $pdo->prepare($countSql);
$countStmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$total = (int)$countStmt->fetchColumn();
$sql = "
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByRole,
qs.clientCode, qs.questionnaireID, qn.name AS questionnaireName
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
LEFT JOIN questionnaire qn ON qn.questionnaireID = qs.questionnaireID
WHERE cl.coachID = :cid AND ($rbacClause)
ORDER BY qs.submittedAt DESC
LIMIT $limit OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute(array_merge([':cid' => $coachID], $rbacParams));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$submissions = array_map(static function ($r) {
return [
'submissionID' => $r['submissionID'],
'version' => (int)$r['version'],
'submittedAt' => $r['submittedAt'] ? date('Y-m-d H:i', (int)$r['submittedAt']) : '',
'submittedByRole' => $r['submittedByRole'] ?? '',
'clientCode' => $r['clientCode'],
'questionnaireID' => $r['questionnaireID'],
'questionnaireName'=> $r['questionnaireName'] ?? $r['questionnaireID'],
];
}, $rows);
return [
'submissions' => $submissions,
'total' => $total,
'limit' => $limit,
'offset' => $offset,
];
}