initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
401
lib/analytics.php
Normal file
401
lib/analytics.php
Normal file
@ -0,0 +1,401 @@
|
||||
<?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;
|
||||
}
|
||||
|
||||
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)
|
||||
ON CONFLICT(clientCode) DO UPDATE SET
|
||||
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,
|
||||
];
|
||||
}
|
||||
998
lib/api_log.php
Normal file
998
lib/api_log.php
Normal file
@ -0,0 +1,998 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Activity API logs (JSON Lines). One file per calendar day; rotates when a file
|
||||
* exceeds QDB_API_LOG_MAX_BYTES (default 10 MiB) to api-YYYY-MM-DD-002.log, etc.
|
||||
*
|
||||
* Recorded activity (not routine website page loads):
|
||||
* - app_sync: GET /app_questionnaires (mobile sync)
|
||||
* - app_change: POST/PUT/PATCH/DELETE from the Android app
|
||||
* - web_change: POST/PUT/PATCH/DELETE from the management website (incl. translation labels)
|
||||
* - web_export: Website downloads (CSV/ZIP/JSON exports, translation bundle)
|
||||
*
|
||||
* Set QDB_API_LOG=0 to disable. Set QDB_API_LOG_DIR for a custom path.
|
||||
* Default directory is uploads/logs/api (writable by PHP alongside the database).
|
||||
*/
|
||||
|
||||
/** @var list<string> */
|
||||
const QDB_API_LOG_ACTIVITIES = ['app_sync', 'app_change', 'web_change', 'web_export', 'api_error'];
|
||||
|
||||
function qdb_api_log_enabled(): bool
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG');
|
||||
if ($v === null || $v === '') {
|
||||
return true;
|
||||
}
|
||||
$v = strtolower(trim($v));
|
||||
return !in_array($v, ['0', 'false', 'no', 'off'], true);
|
||||
}
|
||||
|
||||
function qdb_api_log_dir(): string
|
||||
{
|
||||
static $resolved = null;
|
||||
if ($resolved !== null) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$custom = qdb_env_get('QDB_API_LOG_DIR');
|
||||
if ($custom !== null && $custom !== '') {
|
||||
$resolved = rtrim($custom, '/\\');
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
foreach ([$root . '/uploads/logs/api', $root . '/logs/api'] as $candidate) {
|
||||
if (qdb_api_log_dir_is_usable($candidate)) {
|
||||
$resolved = $candidate;
|
||||
return $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
$resolved = $root . '/uploads/logs/api';
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
function qdb_api_log_dir_is_usable(string $dir): bool
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
return is_writable($dir);
|
||||
}
|
||||
$parent = dirname($dir);
|
||||
if (is_dir($parent) && is_writable($parent)) {
|
||||
return true;
|
||||
}
|
||||
if (@mkdir($dir, 0775, true)) {
|
||||
return is_writable($dir);
|
||||
}
|
||||
return is_dir($dir) && is_writable($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics for admin UI (permissions, path, enabled flag).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_api_log_status(): array
|
||||
{
|
||||
$dir = qdb_api_log_dir();
|
||||
$enabled = qdb_api_log_enabled();
|
||||
$exists = is_dir($dir);
|
||||
$writable = false;
|
||||
$probeError = null;
|
||||
|
||||
if ($enabled) {
|
||||
if (!$exists) {
|
||||
if (!@mkdir($dir, 0775, true)) {
|
||||
$probeError = 'Cannot create directory: ' . $dir;
|
||||
}
|
||||
$exists = is_dir($dir);
|
||||
}
|
||||
if ($exists && !is_writable($dir)) {
|
||||
$probeError = 'Directory exists but is not writable by PHP: ' . $dir;
|
||||
} elseif ($exists) {
|
||||
$probe = $dir . '/.write_probe';
|
||||
if (@file_put_contents($probe, (string)time() . "\n", LOCK_EX) === false) {
|
||||
$probeError = 'Cannot write files in: ' . $dir;
|
||||
} else {
|
||||
$writable = true;
|
||||
@unlink($probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$phpUser = null;
|
||||
if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) {
|
||||
$info = posix_getpwuid(posix_geteuid());
|
||||
if (is_array($info) && !empty($info['name'])) {
|
||||
$phpUser = (string)$info['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'dir' => $dir,
|
||||
'exists' => $exists,
|
||||
'writable' => $writable,
|
||||
'error' => $probeError,
|
||||
'php_user' => $phpUser,
|
||||
'fix_hint' => $writable || !$enabled
|
||||
? null
|
||||
: 'On the server run: mkdir -p ' . $dir . ' && chown -R www-data:www-data '
|
||||
. dirname($dir) . ' && chmod -R 775 ' . dirname($dir)
|
||||
. ' (adjust www-data to your PHP user). Or set QDB_API_LOG_DIR in .env to a writable path.',
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_api_log_max_bytes(): int
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG_MAX_BYTES');
|
||||
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||
return max(1024 * 1024, (int)$v);
|
||||
}
|
||||
return 10 * 1024 * 1024;
|
||||
}
|
||||
|
||||
function qdb_api_log_body_max_chars(): int
|
||||
{
|
||||
$v = qdb_env_get('QDB_API_LOG_BODY_MAX');
|
||||
if ($v !== null && $v !== '' && ctype_digit($v)) {
|
||||
return max(256, (int)$v);
|
||||
}
|
||||
return 4096;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request should be written to the activity log.
|
||||
*/
|
||||
function qdb_api_log_classify_activity(string $method, string $client, string $route): ?string
|
||||
{
|
||||
$method = strtoupper($method);
|
||||
$client = strtolower(trim($client));
|
||||
$route = strtolower(trim($route));
|
||||
|
||||
if ($method === 'OPTIONS' || in_array($route, ['activity-log', 'session'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
|
||||
return $client === 'web' ? 'web_change' : 'app_change';
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
if ($route === 'app_questionnaires' && $client !== 'web') {
|
||||
return 'app_sync';
|
||||
}
|
||||
if (qdb_api_log_is_website_download($route)) {
|
||||
return 'web_export';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Website GET that downloads or exports data (not routine list/load). */
|
||||
function qdb_api_log_is_website_download(string $route): bool
|
||||
{
|
||||
if (in_array($route, ['export', 'backup'], true)) {
|
||||
return true;
|
||||
}
|
||||
if ($route === 'translations' && !empty($_GET['exportBundle'])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
function qdb_api_log_begin(string $method, string $route, array $context = []): void
|
||||
{
|
||||
if (!qdb_api_log_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$client = trim((string)($_SERVER['HTTP_X_QDB_CLIENT'] ?? ''));
|
||||
$activity = qdb_api_log_classify_activity($method, $client, $route);
|
||||
|
||||
$GLOBALS['qdb_api_log_ctx'] = array_merge([
|
||||
'started_at' => microtime(true),
|
||||
'method' => strtoupper($method),
|
||||
'route' => $route,
|
||||
'uri' => (string)($_SERVER['REQUEST_URI'] ?? ''),
|
||||
'ip' => qdb_api_log_client_ip(),
|
||||
'client' => $client,
|
||||
'user_agent' => trim((string)($_SERVER['HTTP_USER_AGENT'] ?? '')),
|
||||
], $context);
|
||||
|
||||
if ($activity !== null) {
|
||||
$GLOBALS['qdb_api_log_ctx']['activity'] = $activity;
|
||||
} else {
|
||||
$GLOBALS['qdb_api_log_ctx']['skip_success'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_note_exception(Throwable $e): void
|
||||
{
|
||||
if (empty($GLOBALS['qdb_api_log_ctx'])) {
|
||||
return;
|
||||
}
|
||||
$GLOBALS['qdb_api_log_ctx']['exception'] = $e->getMessage();
|
||||
$GLOBALS['qdb_api_log_ctx']['exception_class'] = $e::class;
|
||||
}
|
||||
|
||||
function qdb_api_log_note_api_error(string $code, string $message, int $status): void
|
||||
{
|
||||
if (empty($GLOBALS['qdb_api_log_ctx'])) {
|
||||
return;
|
||||
}
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_code'] = $code;
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_message'] = $message;
|
||||
$GLOBALS['qdb_api_log_ctx']['api_error_status'] = $status;
|
||||
}
|
||||
|
||||
function qdb_api_log_finish(): void
|
||||
{
|
||||
$ctx = $GLOBALS['qdb_api_log_ctx'] ?? null;
|
||||
unset($GLOBALS['qdb_api_log_ctx']);
|
||||
if (!is_array($ctx) || !qdb_api_log_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$started = (float)($ctx['started_at'] ?? microtime(true));
|
||||
$status = http_response_code();
|
||||
if ($status === false || $status === 0) {
|
||||
$status = 200;
|
||||
}
|
||||
|
||||
$isError = $status >= 400
|
||||
|| !empty($ctx['exception'])
|
||||
|| !empty($ctx['api_error_code']);
|
||||
|
||||
if (!empty($ctx['skip_success']) && !$isError) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = get_bearer_token();
|
||||
$auth = ['role' => null, 'userID' => null, 'token_hint' => null];
|
||||
if ($token !== null && $token !== '') {
|
||||
$auth['token_hint'] = qdb_api_log_token_hint($token);
|
||||
try {
|
||||
$rec = token_get_record($token, false);
|
||||
if (is_array($rec)) {
|
||||
$auth['role'] = $rec['role'] ?? null;
|
||||
$auth['userID'] = $rec['userID'] ?? null;
|
||||
if (!empty($rec['temp'])) {
|
||||
$auth['temp_token'] = true;
|
||||
}
|
||||
}
|
||||
} catch (Throwable) {
|
||||
$auth['token_lookup'] = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
$rawBody = function_exists('qdb_raw_request_body') ? qdb_raw_request_body() : '';
|
||||
$bodyLog = qdb_api_log_format_body($rawBody);
|
||||
$queryLog = qdb_api_log_redact_query_string((string)($_SERVER['QUERY_STRING'] ?? ''));
|
||||
|
||||
$actorUsername = qdb_api_log_lookup_username($auth['userID'] ?? null);
|
||||
$targetUsername = qdb_api_log_extract_target_username($bodyLog, $queryLog);
|
||||
|
||||
$activity = $ctx['activity'] ?? null;
|
||||
if ($isError) {
|
||||
$activity = 'api_error';
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'ts' => gmdate('c'),
|
||||
'activity' => $activity,
|
||||
'method' => $ctx['method'] ?? '',
|
||||
'route' => $ctx['route'] ?? '',
|
||||
'status' => $status,
|
||||
'duration_ms' => (int)round((microtime(true) - $started) * 1000),
|
||||
'ip' => $ctx['ip'] ?? '',
|
||||
'client' => $ctx['client'] ?? '',
|
||||
'uri' => $ctx['uri'] ?? '',
|
||||
'query' => $queryLog,
|
||||
'role' => $auth['role'],
|
||||
'userID' => $auth['userID'],
|
||||
'actor_username' => $actorUsername,
|
||||
'target_username' => $targetUsername,
|
||||
'token_hint' => $auth['token_hint'],
|
||||
'temp_token' => $auth['temp_token'] ?? null,
|
||||
'changes' => qdb_api_log_build_changes(
|
||||
(string)($ctx['method'] ?? ''),
|
||||
(string)($ctx['route'] ?? ''),
|
||||
$bodyLog,
|
||||
$queryLog
|
||||
),
|
||||
];
|
||||
|
||||
if (!empty($ctx['api_error_code'])) {
|
||||
$entry['error_code'] = (string)$ctx['api_error_code'];
|
||||
$entry['error_message'] = (string)($ctx['api_error_message'] ?? '');
|
||||
$entry['error'] = $entry['error_code'] . ': ' . $entry['error_message'];
|
||||
}
|
||||
if (!empty($ctx['exception'])) {
|
||||
$entry['error_internal'] = (string)$ctx['exception'];
|
||||
if (!empty($ctx['exception_class'])) {
|
||||
$entry['error_class'] = (string)$ctx['exception_class'];
|
||||
}
|
||||
if (empty($entry['error'])) {
|
||||
$entry['error'] = 'SERVER_ERROR: ' . $entry['error_internal'];
|
||||
}
|
||||
}
|
||||
|
||||
if (($ctx['user_agent'] ?? '') !== '') {
|
||||
$entry['user_agent'] = $ctx['user_agent'];
|
||||
}
|
||||
|
||||
try {
|
||||
$line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($line === false) {
|
||||
return;
|
||||
}
|
||||
qdb_api_log_append_line($line . "\n");
|
||||
} catch (Throwable $e) {
|
||||
error_log('api_log write failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_client_ip(): string
|
||||
{
|
||||
foreach ([
|
||||
'HTTP_CF_CONNECTING_IP',
|
||||
'HTTP_X_FORWARDED_FOR',
|
||||
'HTTP_X_REAL_IP',
|
||||
'REMOTE_ADDR',
|
||||
] as $key) {
|
||||
$v = trim((string)($_SERVER[$key] ?? ''));
|
||||
if ($v === '') {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'HTTP_X_FORWARDED_FOR') {
|
||||
$v = trim(explode(',', $v)[0]);
|
||||
}
|
||||
if (filter_var($v, FILTER_VALIDATE_IP)) {
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function qdb_api_log_token_hint(string $token): string
|
||||
{
|
||||
$len = strlen($token);
|
||||
if ($len <= 8) {
|
||||
return '[token]';
|
||||
}
|
||||
return substr($token, 0, 4) . '…' . substr($token, -4) . " ($len)";
|
||||
}
|
||||
|
||||
function qdb_api_log_redact_query_string(string $qs): ?string
|
||||
{
|
||||
if ($qs === '') {
|
||||
return null;
|
||||
}
|
||||
parse_str($qs, $params);
|
||||
if (!is_array($params) || $params === []) {
|
||||
return $qs;
|
||||
}
|
||||
foreach ($params as $k => $_) {
|
||||
if (in_array(strtolower((string)$k), ['token', 'password', 'authorization'], true)) {
|
||||
$params[$k] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
return http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|string|null
|
||||
*/
|
||||
function qdb_api_log_format_body(string $raw): array|string|null
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = qdb_api_log_body_max_chars();
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded)) {
|
||||
$redacted = qdb_api_log_redact_value($decoded);
|
||||
$json = json_encode($redacted, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return '[unencodable body]';
|
||||
}
|
||||
if (strlen($json) > $max) {
|
||||
return substr($json, 0, $max) . '…[truncated]';
|
||||
}
|
||||
return $redacted;
|
||||
}
|
||||
|
||||
if (strlen($raw) > $max) {
|
||||
return substr($raw, 0, $max) . '…[truncated]';
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** @param mixed $value @return mixed */
|
||||
function qdb_api_log_redact_value(mixed $value): mixed
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$isList = array_keys($value) === range(0, count($value) - 1);
|
||||
$out = [];
|
||||
foreach ($value as $k => $v) {
|
||||
$lk = strtolower((string)$k);
|
||||
if (in_array($lk, [
|
||||
'password', 'old_password', 'new_password', 'passwordhash',
|
||||
'token', 'authorization', 'secret',
|
||||
], true)) {
|
||||
$out[$k] = '[REDACTED]';
|
||||
} elseif ($lk === 'encrypted' && is_string($v)) {
|
||||
$out[$k] = '[ENCRYPTED:' . strlen($v) . ' chars]';
|
||||
} elseif (is_array($v)) {
|
||||
$out[$k] = qdb_api_log_redact_value($v);
|
||||
} else {
|
||||
$out[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $isList ? array_values($out) : $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-level change list for the activity log UI (passwords stay redacted).
|
||||
*
|
||||
* @param mixed $body Redacted decoded body or null
|
||||
* @return list<array{field: string, value: string|int|float|bool|null}>
|
||||
*/
|
||||
function qdb_api_log_build_changes(string $method, string $route, mixed $body, ?string $queryString): array
|
||||
{
|
||||
$changes = [];
|
||||
$max = 100;
|
||||
|
||||
if ($queryString !== null && $queryString !== '') {
|
||||
parse_str($queryString, $params);
|
||||
if (is_array($params)) {
|
||||
foreach ($params as $k => $v) {
|
||||
if (count($changes) >= $max) {
|
||||
break;
|
||||
}
|
||||
if (!is_scalar($v)) {
|
||||
continue;
|
||||
}
|
||||
$changes[] = [
|
||||
'field' => 'query.' . (string)$k,
|
||||
'value' => qdb_api_log_change_scalar($v),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($body)) {
|
||||
qdb_api_log_flatten_for_changes($body, '', $changes, 0, 4, $max);
|
||||
} elseif (is_string($body) && $body !== '') {
|
||||
$changes[] = ['field' => 'body', 'value' => qdb_api_log_change_scalar($body)];
|
||||
}
|
||||
|
||||
if ($changes === [] && strtoupper($method) === 'DELETE') {
|
||||
$changes[] = ['field' => '_action', 'value' => 'delete'];
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field: string, value: string|int|float|bool|null}> $out
|
||||
*/
|
||||
function qdb_api_log_flatten_for_changes(
|
||||
array $data,
|
||||
string $prefix,
|
||||
array &$out,
|
||||
int $depth,
|
||||
int $maxDepth,
|
||||
int $maxFields
|
||||
): void {
|
||||
if ($depth >= $maxDepth || count($out) >= $maxFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data as $k => $v) {
|
||||
if (count($out) >= $maxFields) {
|
||||
return;
|
||||
}
|
||||
$key = (string)$k;
|
||||
$field = $prefix === '' ? $key : $prefix . '.' . $key;
|
||||
|
||||
if (is_array($v)) {
|
||||
$childList = array_keys($v) === range(0, count($v) - 1);
|
||||
$n = count($v);
|
||||
if ($n === 0) {
|
||||
$out[] = ['field' => $field, 'value' => '[]'];
|
||||
continue;
|
||||
}
|
||||
if ($childList && $n > 8) {
|
||||
$out[] = ['field' => $field, 'value' => '[list: ' . $n . ' items]'];
|
||||
continue;
|
||||
}
|
||||
if (!$childList && $depth + 1 >= $maxDepth) {
|
||||
$out[] = ['field' => $field, 'value' => '[object: ' . $n . ' keys]'];
|
||||
continue;
|
||||
}
|
||||
qdb_api_log_flatten_for_changes($v, $field, $out, $depth + 1, $maxDepth, $maxFields);
|
||||
} else {
|
||||
$out[] = ['field' => $field, 'value' => qdb_api_log_change_scalar($v)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string|int|float|bool|null */
|
||||
function qdb_api_log_change_scalar(mixed $v): string|int|float|bool|null
|
||||
{
|
||||
if ($v === null) {
|
||||
return null;
|
||||
}
|
||||
if (is_bool($v) || is_int($v) || is_float($v)) {
|
||||
return $v;
|
||||
}
|
||||
$s = is_string($v) ? $v : json_encode($v, JSON_UNESCAPED_UNICODE);
|
||||
if ($s === false) {
|
||||
return '';
|
||||
}
|
||||
if (strlen($s) > 800) {
|
||||
return substr($s, 0, 800) . '…';
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
function qdb_api_log_append_line(string $line): void
|
||||
{
|
||||
$dir = qdb_api_log_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
$err = error_get_last();
|
||||
throw new RuntimeException(
|
||||
'Cannot create log directory: ' . $dir
|
||||
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
|
||||
);
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
throw new RuntimeException('Log directory not writable: ' . $dir);
|
||||
}
|
||||
|
||||
$path = qdb_api_log_resolve_write_path($dir);
|
||||
if (@file_put_contents($path, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||
$err = error_get_last();
|
||||
throw new RuntimeException(
|
||||
'Cannot write API log: ' . $path
|
||||
. ($err ? ' (' . ($err['message'] ?? '') . ')' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_resolve_write_path(string $dir): string
|
||||
{
|
||||
$date = date('Y-m-d');
|
||||
$base = $dir . '/api-' . $date;
|
||||
$max = qdb_api_log_max_bytes();
|
||||
|
||||
$primary = $base . '.log';
|
||||
if (!is_file($primary) || filesize($primary) < $max) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
for ($part = 2; $part <= 999; $part++) {
|
||||
$path = sprintf('%s-%03d.log', $base, $part);
|
||||
if (!is_file($path) || filesize($path) < $max) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return sprintf('%s-%03d.log', $base, 999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read activity log entries for admin UI (newest first).
|
||||
*
|
||||
* @return array{date: string, activity: string, entries: list<array<string, mixed>>, files: list<string>, truncated: bool}
|
||||
*/
|
||||
function qdb_api_log_read_entries(string $date, string $activityFilter = '', int $limit = 200, bool $errorsOnly = false): array
|
||||
{
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$date = date('Y-m-d');
|
||||
}
|
||||
$limit = max(1, min(1000, $limit));
|
||||
$activityFilter = strtolower(trim($activityFilter));
|
||||
if ($activityFilter === 'errors') {
|
||||
$errorsOnly = true;
|
||||
$activityFilter = '';
|
||||
}
|
||||
if ($activityFilter !== '' && !in_array($activityFilter, QDB_API_LOG_ACTIVITIES, true)) {
|
||||
$activityFilter = '';
|
||||
}
|
||||
|
||||
$dir = qdb_api_log_dir();
|
||||
$files = qdb_api_log_files_for_date($dir, $date);
|
||||
$entries = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($lines as $line) {
|
||||
$row = json_decode($line, true);
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ($activityFilter !== '' && ($row['activity'] ?? '') !== $activityFilter) {
|
||||
continue;
|
||||
}
|
||||
if ($errorsOnly) {
|
||||
$rowActivity = $row['activity'] ?? '';
|
||||
$rowStatus = (int)($row['status'] ?? 0);
|
||||
if ($rowActivity !== 'api_error' && $rowStatus < 400 && empty($row['error'])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$entries[] = qdb_api_log_entry_for_ui($row);
|
||||
}
|
||||
}
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
return strcmp($b['ts'] ?? '', $a['ts'] ?? '');
|
||||
});
|
||||
|
||||
$truncated = count($entries) > $limit;
|
||||
if ($truncated) {
|
||||
$entries = array_slice($entries, 0, $limit);
|
||||
}
|
||||
|
||||
$entries = qdb_api_log_enrich_entries_usernames($entries);
|
||||
|
||||
return [
|
||||
'date' => $date,
|
||||
'activity' => $activityFilter,
|
||||
'errorsOnly' => $errorsOnly,
|
||||
'entries' => $entries,
|
||||
'files' => array_map('basename', $files),
|
||||
'truncated' => $truncated,
|
||||
'kinds' => QDB_API_LOG_ACTIVITIES,
|
||||
'logStatus' => qdb_api_log_status(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
function qdb_api_log_files_for_date(string $dir, string $date): array
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
$pattern = $dir . '/api-' . $date . '*.log';
|
||||
$files = glob($pattern) ?: [];
|
||||
sort($files);
|
||||
return $files;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row @return array<string, mixed> */
|
||||
function qdb_api_log_entry_for_ui(array $row): array
|
||||
{
|
||||
$changes = $row['changes'] ?? null;
|
||||
if (!is_array($changes) || $changes === []) {
|
||||
$changes = qdb_api_log_build_changes(
|
||||
(string)($row['method'] ?? ''),
|
||||
(string)($row['route'] ?? ''),
|
||||
$row['body'] ?? null,
|
||||
isset($row['query']) ? (string)$row['query'] : null
|
||||
);
|
||||
}
|
||||
$changes = qdb_api_log_sort_changes_for_display($changes);
|
||||
|
||||
$actorUsername = $row['actor_username'] ?? null;
|
||||
$targetUsername = $row['target_username'] ?? null;
|
||||
|
||||
return [
|
||||
'ts' => $row['ts'] ?? '',
|
||||
'activity' => $row['activity'] ?? '',
|
||||
'method' => $row['method'] ?? '',
|
||||
'route' => $row['route'] ?? '',
|
||||
'status' => $row['status'] ?? null,
|
||||
'duration_ms' => $row['duration_ms'] ?? null,
|
||||
'role' => $row['role'] ?? null,
|
||||
'userID' => $row['userID'] ?? null,
|
||||
'actor_username' => is_string($actorUsername) ? $actorUsername : null,
|
||||
'target_username' => is_string($targetUsername) ? $targetUsername : null,
|
||||
'client' => $row['client'] ?? '',
|
||||
'ip' => $row['ip'] ?? '',
|
||||
'query' => $row['query'] ?? null,
|
||||
'changes' => $changes,
|
||||
'summary' => qdb_api_log_summarize_changes($changes, $targetUsername),
|
||||
'error' => $row['error'] ?? null,
|
||||
'error_code' => $row['error_code'] ?? null,
|
||||
'error_message' => $row['error_message'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_api_log_lookup_username(?string $userID): ?string
|
||||
{
|
||||
if ($userID === null || $userID === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
|
||||
$stmt->execute([':uid' => $userID]);
|
||||
$name = $stmt->fetchColumn();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $name !== false ? (string)$name : null;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_api_log_extract_target_username(mixed $body, ?string $queryString): ?string
|
||||
{
|
||||
if (is_array($body) && !empty($body['username']) && is_scalar($body['username'])) {
|
||||
return trim((string)$body['username']);
|
||||
}
|
||||
if ($queryString !== null && $queryString !== '') {
|
||||
parse_str($queryString, $params);
|
||||
if (!empty($params['username']) && is_scalar($params['username'])) {
|
||||
return trim((string)$params['username']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve userID / supervisorID in log rows to usernames for retraceability.
|
||||
*
|
||||
* @param list<array<string, mixed>> $entries
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_api_log_enrich_entries_usernames(array $entries): array
|
||||
{
|
||||
if ($entries === []) {
|
||||
return $entries;
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$supervisorIds = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (!empty($entry['userID']) && is_string($entry['userID'])) {
|
||||
$userIds[$entry['userID']] = true;
|
||||
}
|
||||
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||
$userIds[$entry['userID']] = true;
|
||||
}
|
||||
foreach ($entry['changes'] ?? [] as $change) {
|
||||
if (!is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
$field = strtolower((string)($change['field'] ?? ''));
|
||||
$value = $change['value'] ?? null;
|
||||
if (!is_string($value) || $value === '' || str_starts_with($value, '[')) {
|
||||
continue;
|
||||
}
|
||||
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||
$userIds[$value] = true;
|
||||
}
|
||||
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||
$supervisorIds[$value] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$userMap = qdb_api_log_load_user_map(array_keys($userIds));
|
||||
$svMap = qdb_api_log_load_supervisor_map(array_keys($supervisorIds));
|
||||
|
||||
foreach ($entries as $i => $entry) {
|
||||
if (empty($entry['actor_username']) && !empty($entry['userID'])) {
|
||||
$entry['actor_username'] = $userMap[$entry['userID']] ?? null;
|
||||
}
|
||||
if (empty($entry['target_username'])) {
|
||||
$entry['target_username'] = qdb_api_log_target_from_changes($entry['changes'] ?? []);
|
||||
}
|
||||
|
||||
$enriched = [];
|
||||
foreach ($entry['changes'] ?? [] as $change) {
|
||||
if (!is_array($change)) {
|
||||
continue;
|
||||
}
|
||||
$change['display'] = qdb_api_log_change_display_label($change, $userMap, $svMap);
|
||||
$enriched[] = $change;
|
||||
}
|
||||
$entry['changes'] = qdb_api_log_sort_changes_for_display($enriched);
|
||||
$entry['summary'] = qdb_api_log_summarize_changes(
|
||||
$entry['changes'],
|
||||
$entry['target_username'] ?? null
|
||||
);
|
||||
$entries[$i] = $entry;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** @param list<string> $userIds @return array<string, string> */
|
||||
function qdb_api_log_load_user_map(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_filter($userIds, static fn($id) => $id !== ''));
|
||||
if ($userIds === []) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT userID, username FROM users WHERE userID IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($userIds);
|
||||
$map = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$map[(string)$row['userID']] = (string)$row['username'];
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $map;
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $supervisorIds @return array<string, string> */
|
||||
function qdb_api_log_load_supervisor_map(array $supervisorIds): array
|
||||
{
|
||||
$supervisorIds = array_values(array_filter($supervisorIds, static fn($id) => $id !== ''));
|
||||
if ($supervisorIds === []) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$placeholders = implode(',', array_fill(0, count($supervisorIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT supervisorID, username FROM supervisor WHERE supervisorID IN ($placeholders)"
|
||||
);
|
||||
$stmt->execute($supervisorIds);
|
||||
$map = [];
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$map[(string)$row['supervisorID']] = (string)$row['username'];
|
||||
}
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
return $map;
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array{field?: string, value?: mixed}> $changes */
|
||||
function qdb_api_log_target_from_changes(array $changes): ?string
|
||||
{
|
||||
foreach ($changes as $c) {
|
||||
$field = strtolower((string)($c['field'] ?? ''));
|
||||
if ($field === 'username' && isset($c['value']) && is_scalar($c['value'])) {
|
||||
return trim((string)$c['value']);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $userMap
|
||||
* @param array<string, string> $svMap
|
||||
*/
|
||||
function qdb_api_log_change_display_label(array $change, array $userMap, array $svMap): string
|
||||
{
|
||||
$field = strtolower((string)($change['field'] ?? ''));
|
||||
$value = $change['value'] ?? null;
|
||||
if ($value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (!is_scalar($value)) {
|
||||
return '';
|
||||
}
|
||||
$str = (string)$value;
|
||||
|
||||
if ($field === 'username') {
|
||||
return $str;
|
||||
}
|
||||
if ($field === 'userid' || str_ends_with($field, '.userid')) {
|
||||
return $userMap[$str] ?? $str;
|
||||
}
|
||||
if ($field === 'supervisorid' || str_ends_with($field, '.supervisorid')) {
|
||||
return $svMap[$str] ?? $str;
|
||||
}
|
||||
if ($field === 'clientcode' || str_ends_with($field, 'clientcode')) {
|
||||
return $str;
|
||||
}
|
||||
if ($field === 'mustchangepassword') {
|
||||
return ((int)$value === 1) ? 'yes (temporary password)' : 'no (permanent password)';
|
||||
}
|
||||
if ($field === 'password' || str_contains($field, 'password')) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{field?: string, value?: mixed}> $changes
|
||||
* @return list<array{field?: string, value?: mixed}>
|
||||
*/
|
||||
function qdb_api_log_sort_changes_for_display(array $changes): array
|
||||
{
|
||||
$priority = [
|
||||
'username' => 0,
|
||||
'type' => 1,
|
||||
'text' => 2,
|
||||
'languagecode' => 3,
|
||||
'id' => 4,
|
||||
'role' => 5,
|
||||
'clientcode' => 6,
|
||||
'action' => 7,
|
||||
'query.id' => 10,
|
||||
'query.clients' => 11,
|
||||
'query.clientcode' => 12,
|
||||
'query.answers' => 13,
|
||||
'query.translations' => 14,
|
||||
'query.bundle' => 15,
|
||||
'query.exportall' => 16,
|
||||
'query.exportbundle' => 17,
|
||||
'supervisorid' => 18,
|
||||
'userid' => 19,
|
||||
'mustchangepassword' => 20,
|
||||
'password' => 21,
|
||||
'location' => 22,
|
||||
];
|
||||
|
||||
usort($changes, static function (array $a, array $b) use ($priority): int {
|
||||
$fa = strtolower((string)($a['field'] ?? ''));
|
||||
$fb = strtolower((string)($b['field'] ?? ''));
|
||||
$pa = $priority[$fa] ?? 50;
|
||||
$pb = $priority[$fb] ?? 50;
|
||||
if ($pa !== $pb) {
|
||||
return $pa <=> $pb;
|
||||
}
|
||||
return strcmp($fa, $fb);
|
||||
});
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/** @param list<array{field: string, value: mixed, display?: string}> $changes */
|
||||
function qdb_api_log_summarize_changes(array $changes, ?string $targetUsername = null): string
|
||||
{
|
||||
if ($targetUsername !== null && $targetUsername !== '') {
|
||||
return 'user=' . $targetUsername;
|
||||
}
|
||||
if ($changes === []) {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
foreach (array_slice($changes, 0, 8) as $c) {
|
||||
$field = (string)($c['field'] ?? '');
|
||||
if ($field === '') {
|
||||
continue;
|
||||
}
|
||||
$label = (string)($c['display'] ?? '');
|
||||
$value = $c['value'] ?? '';
|
||||
if ($label !== '' && $label !== (string)$value) {
|
||||
$parts[] = $field . '=' . $label;
|
||||
} elseif ($value === null) {
|
||||
$parts[] = $field . '=null';
|
||||
} elseif (is_bool($value)) {
|
||||
$parts[] = $field . '=' . ($value ? 'true' : 'false');
|
||||
} else {
|
||||
$parts[] = $field . '=' . (string)$value;
|
||||
}
|
||||
}
|
||||
$summary = implode(', ', $parts);
|
||||
if (count($changes) > 8) {
|
||||
$summary .= ', …';
|
||||
}
|
||||
if (strlen($summary) > 240) {
|
||||
$summary = substr($summary, 0, 240) . '…';
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
306
lib/app_answers.php
Normal file
306
lib/app_answers.php
Normal file
@ -0,0 +1,306 @@
|
||||
<?php
|
||||
/**
|
||||
* Mobile app answer ingest/export helpers (submit payload ↔ client_answer rows).
|
||||
*/
|
||||
|
||||
/** Encode multi-select keys the same way the Android app stores them locally. */
|
||||
function qdb_encode_multi_check_values(array $keys): string {
|
||||
$clean = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = trim((string)$k);
|
||||
if ($k !== '') {
|
||||
$clean[$k] = true;
|
||||
}
|
||||
}
|
||||
return json_encode(array_keys($clean), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
function qdb_decode_multi_check_values(?string $raw): array {
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
$trimmed = trim($raw);
|
||||
if ($trimmed[0] === '[') {
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$out = [];
|
||||
foreach ($decoded as $v) {
|
||||
$s = trim((string)$v);
|
||||
if ($s !== '') {
|
||||
$out[] = $s;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
$sep = str_contains($trimmed, ',') ? ',' : (str_contains($trimmed, ';') ? ';' : null);
|
||||
if ($sep !== null) {
|
||||
$parts = [];
|
||||
foreach (explode($sep, $trimmed) as $p) {
|
||||
$s = trim($p, " \t\n\r\0\x0B\"'");
|
||||
if ($s !== '') {
|
||||
$parts[] = $s;
|
||||
}
|
||||
}
|
||||
return $parts;
|
||||
}
|
||||
return [$trimmed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build glass-scale symptom JSON from the current submit only (no merge with prior rows).
|
||||
*
|
||||
* @param array<string, string> $symptomLabels symptom key => selected label
|
||||
*/
|
||||
function qdb_build_glass_symptom_json(array $symptomLabels): ?string {
|
||||
$data = [];
|
||||
foreach ($symptomLabels as $key => $label) {
|
||||
$k = trim((string)$key);
|
||||
$v = trim((string)$label);
|
||||
if ($k !== '' && $v !== '') {
|
||||
$data[$k] = $v;
|
||||
}
|
||||
}
|
||||
if ($data === []) {
|
||||
return null;
|
||||
}
|
||||
return json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw POST answer rows by question short id; merge multi_check option keys.
|
||||
*
|
||||
* @param array<string, string> $shortIdToType question localId => layout type
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_group_app_submit_answers(array $answers, array $shortIdToType, array $symptomParentMap): array {
|
||||
$grouped = [];
|
||||
$order = [];
|
||||
|
||||
foreach ($answers as $a) {
|
||||
if (!is_array($a)) {
|
||||
continue;
|
||||
}
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($symptomParentMap[$shortId])) {
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = $a;
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $shortIdToType[$shortId] ?? '';
|
||||
if ($type === 'multi_check_box_question') {
|
||||
if (!isset($grouped[$shortId])) {
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = [
|
||||
'questionID' => $shortId,
|
||||
'multiOptionKeys' => [],
|
||||
'answeredAt' => $a['answeredAt'] ?? null,
|
||||
];
|
||||
}
|
||||
$key = trim((string)($a['answerOptionKey'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$grouped[$shortId]['multiOptionKeys'][$key] = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$order[] = $shortId;
|
||||
$grouped[$shortId] = $a;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($order as $shortId) {
|
||||
$row = $grouped[$shortId];
|
||||
if (!empty($row['multiOptionKeys'])) {
|
||||
$keys = array_keys($row['multiOptionKeys']);
|
||||
$out[] = [
|
||||
'questionID' => $shortId,
|
||||
'freeTextValue' => qdb_encode_multi_check_values($keys),
|
||||
'answeredAt' => $row['answeredAt'] ?? null,
|
||||
];
|
||||
} else {
|
||||
unset($row['multiOptionKeys']);
|
||||
$out[] = $row;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export one client's answers for one questionnaire into app submit shape.
|
||||
*
|
||||
* @return array{answers: list<array<string, mixed>>, sumPoints: int, completedAt: ?int}
|
||||
*/
|
||||
function qdb_export_app_questionnaire_answers(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $qnID
|
||||
): array {
|
||||
$qStmt = $pdo->prepare(
|
||||
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
$shortToFull = [];
|
||||
$fullToShort = [];
|
||||
$fullToType = [];
|
||||
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
||||
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$full = $row['questionID'];
|
||||
$parts = explode('__', $full);
|
||||
$short = end($parts);
|
||||
$shortToFull[$short] = $full;
|
||||
$fullToShort[$full] = $short;
|
||||
$fullToType[$full] = $row['type'] ?? '';
|
||||
}
|
||||
|
||||
$aoStmt = $pdo->prepare("
|
||||
SELECT ao.answerOptionID, ao.questionID, ao.defaultText
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn
|
||||
");
|
||||
$aoStmt->execute([':qn' => $qnID]);
|
||||
$optionIdToKey = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionIdToKey[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
|
||||
$caStmt = $pdo->prepare("
|
||||
SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue, ca.answeredAt
|
||||
FROM client_answer ca
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn
|
||||
");
|
||||
$caStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$rows = $caStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$export = [];
|
||||
foreach ($rows as $row) {
|
||||
$fullQid = $row['questionID'];
|
||||
$shortId = $fullToShort[$fullQid] ?? '';
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
$type = $fullToType[$fullQid] ?? '';
|
||||
|
||||
if ($type === 'glass_scale_question' && ($row['freeTextValue'] ?? '') !== '') {
|
||||
$decoded = json_decode($row['freeTextValue'], true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $symptomKey => $label) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
$lab = trim((string)$label);
|
||||
if ($sk !== '' && $lab !== '') {
|
||||
$export[] = [
|
||||
'questionID' => $sk,
|
||||
'answerOptionKey' => $lab,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'multi_check_box_question') {
|
||||
foreach (qdb_decode_multi_check_values($row['freeTextValue'] ?? null) as $key) {
|
||||
$export[] = [
|
||||
'questionID' => $shortId,
|
||||
'answerOptionKey' => $key,
|
||||
];
|
||||
}
|
||||
if ($export === [] && !empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
|
||||
$export[] = [
|
||||
'questionID' => $shortId,
|
||||
'answerOptionKey' => $optionIdToKey[$row['answerOptionID']],
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['questionID' => $shortId];
|
||||
if (!empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
|
||||
$entry['answerOptionKey'] = $optionIdToKey[$row['answerOptionID']];
|
||||
}
|
||||
if (isset($row['freeTextValue']) && $row['freeTextValue'] !== null && $row['freeTextValue'] !== '') {
|
||||
$entry['freeTextValue'] = $row['freeTextValue'];
|
||||
}
|
||||
if (isset($row['numericValue']) && $row['numericValue'] !== null && $row['numericValue'] !== '') {
|
||||
$entry['numericValue'] = (float)$row['numericValue'];
|
||||
}
|
||||
if (!empty($row['answeredAt'])) {
|
||||
$entry['answeredAt'] = (int)$row['answeredAt'];
|
||||
}
|
||||
$export[] = $entry;
|
||||
}
|
||||
|
||||
$cq = $pdo->prepare(
|
||||
"SELECT sumPoints, completedAt FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn"
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$meta = $cq->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
return [
|
||||
'answers' => $export,
|
||||
'sumPoints' => isset($meta['sumPoints']) ? (int)$meta['sumPoints'] : 0,
|
||||
'completedAt' => isset($meta['completedAt']) ? (int)$meta['completedAt'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all completed questionnaires for a client (coach RBAC must be applied by caller).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc"
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode]);
|
||||
$items = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
|
||||
$block = qdb_export_app_questionnaire_answers($pdo, $clientCode, $qnID);
|
||||
$items[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'completedAt' => $block['completedAt'],
|
||||
'sumPoints' => $block['sumPoints'],
|
||||
'answers' => $block['answers'],
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
|
||||
*
|
||||
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array {
|
||||
if (($tokenRec['role'] ?? '') !== 'coach') {
|
||||
return [];
|
||||
}
|
||||
$coachID = trim((string)($tokenRec['entityID'] ?? ''));
|
||||
if ($coachID === '') {
|
||||
return [];
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT clientCode FROM client
|
||||
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
|
||||
ORDER BY clientCode'
|
||||
);
|
||||
$stmt->execute([':cid' => $coachID]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) {
|
||||
$out[] = [
|
||||
'clientCode' => $clientCode,
|
||||
'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
221
lib/app_submit_validate.php
Normal file
221
lib/app_submit_validate.php
Normal file
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/questionnaire_structure.php';
|
||||
|
||||
/**
|
||||
* Validate Android app questionnaire submit payloads before persisting answers.
|
||||
*
|
||||
* @param array<string, mixed>|null $manifest Structure manifest for legacy revision submits
|
||||
* @return list<array{questionID: string, code: string, message: string}>
|
||||
*/
|
||||
function qdb_validate_app_submit_payload(
|
||||
PDO $pdo,
|
||||
string $qnID,
|
||||
array $rawAnswers,
|
||||
array $shortIdMap,
|
||||
array $shortIdToType,
|
||||
array $symptomParentMap,
|
||||
array $optionMap,
|
||||
?array $manifest = null
|
||||
): array {
|
||||
$errors = [];
|
||||
|
||||
if ($rawAnswers === []) {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'ANSWERS_REQUIRED',
|
||||
'message' => 'At least one answer is required',
|
||||
];
|
||||
return $errors;
|
||||
}
|
||||
|
||||
foreach ($rawAnswers as $idx => $a) {
|
||||
if (!is_array($a)) {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'INVALID_ANSWER',
|
||||
'message' => 'Answer at index ' . $idx . ' must be an object',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
$errors[] = [
|
||||
'questionID' => '',
|
||||
'code' => 'MISSING_QUESTION_ID',
|
||||
'message' => 'Each answer must include questionID',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($symptomParentMap[$shortId])) {
|
||||
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
|
||||
if ($label === '') {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Symptom selection is required',
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($shortIdMap[$shortId])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'UNKNOWN_QUESTION',
|
||||
'message' => 'Unknown question: ' . $shortId,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullQID = $shortIdMap[$shortId];
|
||||
$type = $shortIdToType[$shortId] ?? '';
|
||||
$optKey = $a['answerOptionKey'] ?? null;
|
||||
$free = isset($a['freeTextValue']) ? trim((string)$a['freeTextValue']) : '';
|
||||
$numeric = $a['numericValue'] ?? null;
|
||||
$hasNumeric = $numeric !== null && $numeric !== '';
|
||||
|
||||
if ($type === 'multi_check_box_question') {
|
||||
if ($optKey !== null && trim((string)$optKey) !== '') {
|
||||
continue;
|
||||
}
|
||||
if ($free !== '') {
|
||||
continue;
|
||||
}
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Select at least one option',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($optKey !== null && trim((string)$optKey) !== '') {
|
||||
$key = (string)$optKey;
|
||||
if (!isset($optionMap[$fullQID][$key])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'INVALID_OPTION',
|
||||
'message' => 'Unknown option key: ' . $key,
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($free !== '' || $hasNumeric) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'EMPTY_ANSWER',
|
||||
'message' => 'Answer is empty',
|
||||
];
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return $errors;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
$grouped = qdb_group_app_submit_answers($rawAnswers, $shortIdToType, $symptomParentMap);
|
||||
$answeredShort = [];
|
||||
foreach ($grouped as $row) {
|
||||
$sid = trim($row['questionID'] ?? '');
|
||||
if ($sid !== '') {
|
||||
$answeredShort[$sid] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($manifest !== null) {
|
||||
return array_merge($errors, qdb_validate_required_answers_from_manifest($manifest, $answeredShort));
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, type, isRequired, configJson FROM question
|
||||
WHERE questionnaireID = :qn AND ' . qdb_active_questions_clause('question')
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
if ((int)($qRow['isRequired'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$fullId = $qRow['questionID'];
|
||||
$parts = explode('__', $fullId);
|
||||
$short = end($parts);
|
||||
$type = $qRow['type'] ?? '';
|
||||
|
||||
if ($type === 'glass_scale_question') {
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$symptoms = $cfg['symptoms'] ?? [];
|
||||
if (is_array($symptoms) && $symptoms !== []) {
|
||||
foreach ($symptoms as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$sk])) {
|
||||
$errors[] = [
|
||||
'questionID' => $sk,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required symptom is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($answeredShort[$short])) {
|
||||
$errors[] = [
|
||||
'questionID' => $short,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required question is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionID: string, code: string, message: string}>
|
||||
*/
|
||||
function qdb_validate_required_answers_from_manifest(array $manifest, array $answeredShort): array {
|
||||
$errors = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry) || empty($qEntry['isRequired'])) {
|
||||
continue;
|
||||
}
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$sk])) {
|
||||
$errors[] = [
|
||||
'questionID' => $sk,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required symptom is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (empty($answeredShort[$shortId])) {
|
||||
$errors[] = [
|
||||
'questionID' => $shortId,
|
||||
'code' => 'MISSING_ANSWER',
|
||||
'message' => 'Required question is not answered',
|
||||
];
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
911
lib/dev_fixture.php
Normal file
911
lib/dev_fixture.php
Normal file
@ -0,0 +1,911 @@
|
||||
<?php
|
||||
/**
|
||||
* Dev-only fixture import: users, clients, completed questionnaires.
|
||||
* All entity usernames / client codes must use the fixture prefix (default dev_).
|
||||
*/
|
||||
|
||||
const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||
|
||||
const QDB_DEV_GLASS_POINTS = [
|
||||
'never_glass' => 0,
|
||||
'little_glass' => 1,
|
||||
'moderate_glass' => 2,
|
||||
'much_glass' => 3,
|
||||
'extreme_glass' => 4,
|
||||
];
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
require_once __DIR__ . '/submissions.php';
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
|
||||
/**
|
||||
* @return array{imported: array, skipped: array, errors: string[]}
|
||||
*/
|
||||
function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
|
||||
{
|
||||
$prefix = qdb_dev_validate_fixture($fixture);
|
||||
$password = (string)($fixture['defaultPassword'] ?? 'socialvrlab');
|
||||
if (strlen($password) < 6) {
|
||||
json_error('INVALID_FIELD', 'defaultPassword must be at least 6 characters', 400);
|
||||
}
|
||||
|
||||
$imported = [
|
||||
'admins' => 0,
|
||||
'supervisors' => 0,
|
||||
'coaches' => 0,
|
||||
'clients' => 0,
|
||||
'completions' => 0,
|
||||
'submissions' => 0,
|
||||
'answers' => 0,
|
||||
'scoringRecomputed' => 0,
|
||||
];
|
||||
$skipped = [
|
||||
'admins' => 0,
|
||||
'supervisors' => 0,
|
||||
'coaches' => 0,
|
||||
'clients' => 0,
|
||||
'completions' => 0,
|
||||
];
|
||||
$errors = [];
|
||||
|
||||
$coachIdByUsername = [];
|
||||
$supervisorIdByUsername = [];
|
||||
$affectedClients = [];
|
||||
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$now = time();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($fixture['admins'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['admins']++;
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('admin', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$location = trim($row['location'] ?? 'Dev');
|
||||
$pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'admin', $entityID, $now);
|
||||
$imported['admins']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['supervisors'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['supervisors']++;
|
||||
$supervisorIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('supervisor', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$location = trim($row['location'] ?? 'Dev');
|
||||
$pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)')
|
||||
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'supervisor', $entityID, $now);
|
||||
$supervisorIdByUsername[$username] = $entityID;
|
||||
$imported['supervisors']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['coaches'] ?? [] as $row) {
|
||||
$username = qdb_dev_norm_username($row, $prefix);
|
||||
$svUser = trim($row['supervisor'] ?? $row['supervisorUsername'] ?? '');
|
||||
if ($svUser === '' || !qdb_dev_has_prefix($svUser, $prefix)) {
|
||||
$errors[] = "Counselor $username: missing or invalid supervisor";
|
||||
continue;
|
||||
}
|
||||
$supervisorID = $supervisorIdByUsername[$svUser]
|
||||
?? qdb_dev_lookup_entity_id($pdo, $svUser);
|
||||
if ($supervisorID === '') {
|
||||
$errors[] = "Counselor $username: supervisor $svUser not found";
|
||||
continue;
|
||||
}
|
||||
if (qdb_dev_user_exists($pdo, $username)) {
|
||||
$skipped['coaches']++;
|
||||
$coachIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username);
|
||||
continue;
|
||||
}
|
||||
$entityID = qdb_dev_entity_id('coach', $username);
|
||||
$userID = qdb_dev_user_id($username);
|
||||
$pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)')
|
||||
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
|
||||
qdb_dev_insert_user($pdo, $userID, $username, $hash, 'coach', $entityID, $now);
|
||||
$coachIdByUsername[$username] = $entityID;
|
||||
$imported['coaches']++;
|
||||
}
|
||||
|
||||
foreach ($fixture['clients'] ?? [] as $row) {
|
||||
$clientCode = qdb_dev_norm_client_code($row, $prefix);
|
||||
$coachUser = trim($row['coach'] ?? $row['coachUsername'] ?? '');
|
||||
if ($coachUser === '' || !qdb_dev_has_prefix($coachUser, $prefix)) {
|
||||
$errors[] = "Client $clientCode: missing or invalid coach";
|
||||
continue;
|
||||
}
|
||||
$coachID = $coachIdByUsername[$coachUser]
|
||||
?? qdb_dev_lookup_entity_id($pdo, $coachUser);
|
||||
if ($coachID === '') {
|
||||
$errors[] = "Client $clientCode: coach $coachUser not found";
|
||||
continue;
|
||||
}
|
||||
$chk = $pdo->prepare('SELECT 1 FROM client WHERE clientCode = :cc');
|
||||
$chk->execute([':cc' => $clientCode]);
|
||||
if ($chk->fetch()) {
|
||||
$skipped['clients']++;
|
||||
continue;
|
||||
}
|
||||
$pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)')
|
||||
->execute([':cc' => $clientCode, ':cid' => $coachID]);
|
||||
$imported['clients']++;
|
||||
}
|
||||
|
||||
$variant = 0;
|
||||
foreach ($fixture['completions'] ?? [] as $row) {
|
||||
$clientCode = qdb_dev_norm_client_code($row, $prefix);
|
||||
$qnID = trim($row['questionnaireID'] ?? '');
|
||||
if ($qnID === '') {
|
||||
$errors[] = 'Completion missing questionnaireID';
|
||||
continue;
|
||||
}
|
||||
$qnChk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||
$qnChk->execute([':id' => $qnID]);
|
||||
if (!$qnChk->fetch()) {
|
||||
$errors[] = "Questionnaire not in DB: $qnID";
|
||||
continue;
|
||||
}
|
||||
$clChk = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc');
|
||||
$clChk->execute([':cc' => $clientCode]);
|
||||
$coachID = $clChk->fetchColumn();
|
||||
if ($coachID === false) {
|
||||
$errors[] = "Client not found: $clientCode";
|
||||
continue;
|
||||
}
|
||||
$doneChk = $pdo->prepare(
|
||||
'SELECT 1 FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$doneChk->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
if ($doneChk->fetch()) {
|
||||
$skipped['completions']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$coachUserStmt = $pdo->prepare(
|
||||
"SELECT u.userID FROM users u
|
||||
WHERE u.role = 'coach' AND u.entityID = :cid LIMIT 1"
|
||||
);
|
||||
$coachUserStmt->execute([':cid' => $coachID]);
|
||||
$coachUserID = (string)($coachUserStmt->fetchColumn() ?: '');
|
||||
$tokenRec = ['userID' => $coachUserID, 'role' => 'coach'];
|
||||
|
||||
$uploads = qdb_dev_normalize_uploads($row, $variant, $now);
|
||||
$archiveSubmissions = qdb_table_exists($pdo, 'questionnaire_submission');
|
||||
|
||||
foreach ($uploads as $uploadIndex => $upload) {
|
||||
$variantSeed = (int)($upload['variant'] ?? ($variant + $uploadIndex));
|
||||
$completedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||
$startedAt = (int)($upload['startedAt'] ?? ($completedAt - 600));
|
||||
|
||||
$answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variantSeed);
|
||||
$answerCount = qdb_dev_persist_submission(
|
||||
$pdo,
|
||||
$qnID,
|
||||
$clientCode,
|
||||
(string)$coachID,
|
||||
$answers,
|
||||
$startedAt,
|
||||
$completedAt
|
||||
);
|
||||
$imported['answers'] += $answerCount;
|
||||
|
||||
if ($archiveSubmissions) {
|
||||
$spStmt = $pdo->prepare(
|
||||
'SELECT sumPoints FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$spStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$sumPoints = (int)($spStmt->fetchColumn() ?: 0);
|
||||
qdb_record_submission_after_submit(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$qnID,
|
||||
$tokenRec,
|
||||
$startedAt,
|
||||
$completedAt,
|
||||
$sumPoints,
|
||||
(string)$coachID,
|
||||
$completedAt
|
||||
);
|
||||
$imported['submissions']++;
|
||||
}
|
||||
}
|
||||
|
||||
$imported['completions']++;
|
||||
$affectedClients[$clientCode] = true;
|
||||
$variant++;
|
||||
}
|
||||
|
||||
$upperPrefix = strtoupper(rtrim($prefix, '_'));
|
||||
$devClientStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
|
||||
$devClientStmt->execute([':pfx' => $upperPrefix . '%']);
|
||||
foreach ($devClientStmt->fetchAll(PDO::FETCH_COLUMN) as $cc) {
|
||||
$affectedClients[(string)$cc] = true;
|
||||
}
|
||||
if ($affectedClients !== []) {
|
||||
$imported['scoringRecomputed'] = qdb_recompute_scores_for_clients(
|
||||
$pdo,
|
||||
array_keys($affectedClients)
|
||||
);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors];
|
||||
}
|
||||
|
||||
function qdb_dev_validate_fixture(array $fixture): string
|
||||
{
|
||||
$prefix = trim((string)($fixture['prefix'] ?? 'dev_'));
|
||||
if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) {
|
||||
json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400);
|
||||
}
|
||||
$fixtureVersion = (int)($fixture['fixtureVersion'] ?? 0);
|
||||
if ($fixtureVersion !== 1 && $fixtureVersion !== 2) {
|
||||
json_error('INVALID_FIELD', 'fixtureVersion must be 1 or 2', 400);
|
||||
}
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{submittedAt: int, startedAt: int, variant: int}>
|
||||
*/
|
||||
function qdb_dev_normalize_uploads(array $row, int $fallbackVariant, int $now): array
|
||||
{
|
||||
if (!empty($row['uploads']) && is_array($row['uploads'])) {
|
||||
$out = [];
|
||||
foreach ($row['uploads'] as $i => $upload) {
|
||||
if (!is_array($upload)) {
|
||||
continue;
|
||||
}
|
||||
$submittedAt = (int)($upload['submittedAt'] ?? $upload['completedAt'] ?? $now);
|
||||
$startedAt = (int)($upload['startedAt'] ?? ($submittedAt - 600));
|
||||
$out[] = [
|
||||
'submittedAt' => $submittedAt,
|
||||
'startedAt' => $startedAt,
|
||||
'variant' => (int)($upload['variant'] ?? ($fallbackVariant + $i)),
|
||||
];
|
||||
}
|
||||
if ($out !== []) {
|
||||
usort($out, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
$versionCount = max(1, (int)($row['versions'] ?? $row['uploadCount'] ?? 1));
|
||||
$finalCompleted = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($fallbackVariant % 90) * 86400);
|
||||
$uploads = [];
|
||||
for ($v = 0; $v < $versionCount; $v++) {
|
||||
$gapDays = ($versionCount - 1 - $v) * 14 + ($fallbackVariant % 5);
|
||||
$completedAt = $finalCompleted - ($gapDays * 86400);
|
||||
$uploads[] = [
|
||||
'submittedAt' => $completedAt,
|
||||
'startedAt' => isset($row['startedAt']) && $v === $versionCount - 1
|
||||
? (int)$row['startedAt']
|
||||
: ($completedAt - 600 - ($v * 120)),
|
||||
'variant' => $fallbackVariant + $v,
|
||||
];
|
||||
}
|
||||
usort($uploads, static fn($a, $b) => $a['submittedAt'] <=> $b['submittedAt']);
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
function qdb_dev_has_prefix(string $value, string $prefix): bool
|
||||
{
|
||||
return str_starts_with(strtolower($value), strtolower($prefix))
|
||||
|| str_starts_with(strtoupper($value), strtoupper(rtrim($prefix, '_')));
|
||||
}
|
||||
|
||||
function qdb_dev_norm_username(array $row, string $prefix): string
|
||||
{
|
||||
$username = trim($row['username'] ?? '');
|
||||
if ($username === '' || !qdb_dev_has_prefix($username, $prefix)) {
|
||||
json_error('INVALID_FIELD', "Username must use prefix $prefix", 400);
|
||||
}
|
||||
return $username;
|
||||
}
|
||||
|
||||
function qdb_dev_norm_client_code(array $row, string $prefix): string
|
||||
{
|
||||
$code = trim($row['clientCode'] ?? '');
|
||||
$upperPrefix = strtoupper(rtrim($prefix, '_'));
|
||||
if ($code === '' || !str_starts_with($code, $upperPrefix)) {
|
||||
json_error('INVALID_FIELD', "clientCode must start with $upperPrefix", 400);
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
|
||||
function qdb_dev_entity_id(string $role, string $username): string
|
||||
{
|
||||
return 'dev_ent_' . $role . '_' . substr(hash('sha256', $username), 0, 24);
|
||||
}
|
||||
|
||||
function qdb_dev_user_id(string $username): string
|
||||
{
|
||||
return 'dev_uid_' . substr(hash('sha256', 'user:' . $username), 0, 28);
|
||||
}
|
||||
|
||||
function qdb_dev_user_exists(PDO $pdo, string $username): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u');
|
||||
$stmt->execute([':u' => $username]);
|
||||
return (bool)$stmt->fetch();
|
||||
}
|
||||
|
||||
function qdb_dev_lookup_entity_id(PDO $pdo, string $username): string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT entityID FROM users WHERE username = :u');
|
||||
$stmt->execute([':u' => $username]);
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id !== false ? (string)$id : '';
|
||||
}
|
||||
|
||||
function qdb_dev_insert_user(
|
||||
PDO $pdo,
|
||||
string $userID,
|
||||
string $username,
|
||||
string $hash,
|
||||
string $role,
|
||||
string $entityID,
|
||||
int $now
|
||||
): void {
|
||||
$mcp = 0;
|
||||
$pdo->prepare(
|
||||
'INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt)
|
||||
VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)'
|
||||
)->execute([
|
||||
':uid' => $userID,
|
||||
':u' => $username,
|
||||
':hash' => $hash,
|
||||
':role' => $role,
|
||||
':eid' => $entityID,
|
||||
':mcp' => $mcp,
|
||||
':now' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}>
|
||||
*/
|
||||
function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $variant): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT questionID, type, configJson, orderIndex
|
||||
FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
|
||||
);
|
||||
$stmt->execute([':qn' => $qnID]);
|
||||
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points, ao.orderIndex
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn
|
||||
ORDER BY ao.orderIndex'
|
||||
);
|
||||
$aoStmt->execute([':qn' => $qnID]);
|
||||
$optionsByQuestion = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionsByQuestion[$ao['questionID']][] = $ao;
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
$freeTextSamples = ['Testantwort', 'Dev-Eingabe', 'Beispieltext', 'N/A'];
|
||||
|
||||
foreach ($questions as $q) {
|
||||
$type = $q['type'] ?? '';
|
||||
$localId = qdb_question_local_id($q['questionID'], $qnID);
|
||||
$config = json_decode($q['configJson'] ?? '{}', true) ?: [];
|
||||
$v = $variant + (int)($q['orderIndex'] ?? 0);
|
||||
|
||||
switch ($type) {
|
||||
case 'last_page':
|
||||
break;
|
||||
|
||||
case 'glass_scale_question':
|
||||
$symData = [];
|
||||
foreach ($config['symptoms'] ?? [] as $si => $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$symData[$sk] = QDB_DEV_GLASS_LABELS[($v + $si) % count(QDB_DEV_GLASS_LABELS)];
|
||||
}
|
||||
if ($symData !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => json_encode($symData, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'radio_question':
|
||||
$opts = $optionsByQuestion[$q['questionID']] ?? [];
|
||||
if ($opts) {
|
||||
$pick = $opts[$v % count($opts)];
|
||||
$key = qdb_option_key($pick) ?: $pick['defaultText'];
|
||||
$answers[] = ['questionID' => $localId, 'answerOptionKey' => $key];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'multi_check_box_question':
|
||||
$opts = $optionsByQuestion[$q['questionID']] ?? [];
|
||||
if ($opts) {
|
||||
$pickCount = min(5, max(2, count($opts) > 20 ? 4 : 2), count($opts));
|
||||
$keys = [];
|
||||
for ($i = 0; $i < $pickCount; $i++) {
|
||||
$pick = $opts[($v + $i) % count($opts)];
|
||||
$key = qdb_option_key($pick) ?: $pick['defaultText'];
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
if ($keys !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => qdb_encode_multi_check_values($keys),
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'string_spinner':
|
||||
$opts = $config['options'] ?? [];
|
||||
if (is_array($opts) && $opts !== []) {
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => (string)$opts[$v % count($opts)],
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'value_spinner':
|
||||
case 'slider_question':
|
||||
$range = $config['range'] ?? ['min' => 0, 'max' => 10];
|
||||
$min = (int)($range['min'] ?? 0);
|
||||
$max = (int)($range['max'] ?? 10);
|
||||
if ($max < $min) {
|
||||
$max = $min;
|
||||
}
|
||||
$span = max(1, $max - $min + 1);
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'numericValue' => (float)($min + ($v % $span)),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'date_spinner':
|
||||
$year = 2018 + ($v % 6);
|
||||
$month = 1 + ($v % 12);
|
||||
$day = 1 + ($v % 28);
|
||||
$precision = $config['precision'] ?? 'full';
|
||||
$dateValue = match ($precision) {
|
||||
'year' => sprintf('%04d', $year),
|
||||
'year_month' => sprintf('%04d-%02d', $year, $month),
|
||||
default => sprintf('%04d-%02d-%02d', $year, $month, $day),
|
||||
};
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => $dateValue,
|
||||
];
|
||||
break;
|
||||
|
||||
case 'free_text':
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => $freeTextSamples[$v % count($freeTextSamples)],
|
||||
];
|
||||
break;
|
||||
|
||||
case 'client_coach_code_question':
|
||||
$answers[] = [
|
||||
'questionID' => $localId,
|
||||
'freeTextValue' => 'DEV-IMPORT',
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}> $answers
|
||||
*/
|
||||
function qdb_dev_persist_submission(
|
||||
PDO $pdo,
|
||||
string $qnID,
|
||||
string $clientCode,
|
||||
string $assignedByCoach,
|
||||
array $answers,
|
||||
int $startedAt,
|
||||
int $completedAt
|
||||
): int {
|
||||
$qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn');
|
||||
$qRows->execute([':qn' => $qnID]);
|
||||
$shortIdMap = [];
|
||||
$typeByFullId = [];
|
||||
$freeTextMaxLen = [];
|
||||
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
$fullId = $qRow['questionID'];
|
||||
$parts = explode('__', $fullId);
|
||||
$shortIdMap[end($parts)] = $fullId;
|
||||
$typeByFullId[$fullId] = $qRow['type'] ?? '';
|
||||
if (($qRow['type'] ?? '') === 'free_text') {
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
||||
}
|
||||
}
|
||||
|
||||
$aoRows = $pdo->prepare(
|
||||
'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn'
|
||||
);
|
||||
$aoRows->execute([':qn' => $qnID]);
|
||||
$optionMap = [];
|
||||
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionMap[$ao['questionID']][$ao['defaultText']] = [
|
||||
'answerOptionID' => $ao['answerOptionID'],
|
||||
'points' => (int)$ao['points'],
|
||||
];
|
||||
}
|
||||
|
||||
$sumPoints = 0;
|
||||
$written = 0;
|
||||
$answeredAt = $completedAt;
|
||||
$answerInsert = $pdo->prepare(
|
||||
'INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
|
||||
ON CONFLICT(clientCode, questionID) DO UPDATE SET
|
||||
answerOptionID = excluded.answerOptionID,
|
||||
freeTextValue = excluded.freeTextValue,
|
||||
numericValue = excluded.numericValue,
|
||||
answeredAt = excluded.answeredAt'
|
||||
);
|
||||
|
||||
foreach ($answers as $a) {
|
||||
$shortId = trim($a['questionID'] ?? '');
|
||||
if ($shortId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullQID = $shortIdMap[$shortId] ?? null;
|
||||
if ($fullQID === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
|
||||
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
||||
if ($freeTextValue !== null && $freeTextValue !== '') {
|
||||
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
|
||||
$freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
|
||||
if ($freeTextValue === '') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$answerOptionID = null;
|
||||
$qType = $typeByFullId[$fullQID] ?? '';
|
||||
|
||||
if ($qType === 'glass_scale_question' && $freeTextValue !== null && $freeTextValue !== '') {
|
||||
$decoded = json_decode($freeTextValue, true);
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $label) {
|
||||
$label = trim((string)$label);
|
||||
if ($label !== '') {
|
||||
$sumPoints += QDB_DEV_GLASS_POINTS[$label] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($qType === 'multi_check_box_question' && $freeTextValue !== null && $freeTextValue !== '') {
|
||||
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
|
||||
if (isset($optionMap[$fullQID][$mk])) {
|
||||
$sumPoints += $optionMap[$fullQID][$mk]['points'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$optionKey = $a['answerOptionKey'] ?? null;
|
||||
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
||||
$opt = $optionMap[$fullQID][(string)$optionKey];
|
||||
$answerOptionID = $opt['answerOptionID'];
|
||||
$sumPoints += $opt['points'];
|
||||
}
|
||||
}
|
||||
|
||||
$answerInsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':qid' => $fullQID,
|
||||
':aoid' => $answerOptionID,
|
||||
':ftv' => $freeTextValue,
|
||||
':nv' => $numericValue,
|
||||
':at' => $answeredAt,
|
||||
]);
|
||||
$written++;
|
||||
}
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||
VALUES (:cc, :qn, :abc, \'completed\', :sa, :ca, :sp)
|
||||
ON CONFLICT(clientCode, questionnaireID) DO UPDATE SET
|
||||
assignedByCoach = excluded.assignedByCoach,
|
||||
status = \'completed\',
|
||||
startedAt = COALESCE(excluded.startedAt, startedAt),
|
||||
completedAt = excluded.completedAt,
|
||||
sumPoints = excluded.sumPoints'
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||
':sa' => $startedAt,
|
||||
':ca' => $completedAt,
|
||||
':sp' => $sumPoints,
|
||||
]);
|
||||
|
||||
return $written;
|
||||
}
|
||||
|
||||
/** @param list<string> $ids */
|
||||
function qdb_dev_delete_by_ids(PDO $pdo, string $table, string $column, array $ids): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids, static fn($id) => $id !== '' && $id !== null)));
|
||||
if ($ids === []) {
|
||||
return 0;
|
||||
}
|
||||
$total = 0;
|
||||
foreach (array_chunk($ids, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$stmt = $pdo->prepare("DELETE FROM $table WHERE $column IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$total += $stmt->rowCount();
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dev-prefixed test users, clients, and their answers (does not touch other accounts).
|
||||
*
|
||||
* @return array{deleted: array<string, int>}
|
||||
*/
|
||||
function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array
|
||||
{
|
||||
$usernamePrefix = trim((string)($options['usernamePrefix'] ?? 'dev_'));
|
||||
$clientPrefix = trim((string)($options['clientCodePrefix'] ?? 'DEV-CL-'));
|
||||
if ($usernamePrefix === '' || stripos($usernamePrefix, 'dev') !== 0) {
|
||||
json_error('INVALID_FIELD', 'usernamePrefix must start with "dev"', 400);
|
||||
}
|
||||
if ($clientPrefix === '' || stripos($clientPrefix, 'DEV') !== 0) {
|
||||
json_error('INVALID_FIELD', 'clientCodePrefix must start with "DEV"', 400);
|
||||
}
|
||||
|
||||
$userLike = $usernamePrefix . '%';
|
||||
$clientLike = $clientPrefix . '%';
|
||||
|
||||
$deleted = [
|
||||
'submissions' => 0,
|
||||
'followup_notes' => 0,
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
'clients' => 0,
|
||||
'sessions' => 0,
|
||||
'users' => 0,
|
||||
'coaches' => 0,
|
||||
'supervisors' => 0,
|
||||
'admins' => 0,
|
||||
];
|
||||
|
||||
$coachIds = [];
|
||||
$stmt = $pdo->prepare('SELECT coachID FROM coach WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$coachIds[$id] = true;
|
||||
}
|
||||
|
||||
$supervisorIds = [];
|
||||
$stmt = $pdo->prepare('SELECT supervisorID FROM supervisor WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$supervisorIds[$id] = true;
|
||||
}
|
||||
|
||||
$adminIds = [];
|
||||
$stmt = $pdo->prepare('SELECT adminID FROM admin WHERE username LIKE :pfx');
|
||||
$stmt->execute([':pfx' => $userLike]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) {
|
||||
$adminIds[$id] = true;
|
||||
}
|
||||
|
||||
$userIds = [];
|
||||
$userRows = $pdo->prepare('SELECT userID, role, entityID FROM users WHERE username LIKE :pfx');
|
||||
$userRows->execute([':pfx' => $userLike]);
|
||||
foreach ($userRows->fetchAll(PDO::FETCH_ASSOC) as $u) {
|
||||
$userIds[$u['userID']] = true;
|
||||
switch ($u['role']) {
|
||||
case 'coach':
|
||||
$coachIds[$u['entityID']] = true;
|
||||
break;
|
||||
case 'supervisor':
|
||||
$supervisorIds[$u['entityID']] = true;
|
||||
break;
|
||||
case 'admin':
|
||||
$adminIds[$u['entityID']] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$coachIdList = array_keys($coachIds);
|
||||
$clientCodes = [];
|
||||
$ccStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx');
|
||||
$ccStmt->execute([':pfx' => $clientLike]);
|
||||
foreach ($ccStmt->fetchAll(PDO::FETCH_COLUMN) as $code) {
|
||||
$clientCodes[$code] = true;
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
foreach (array_chunk($coachIdList, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$byCoach = $pdo->prepare("SELECT clientCode FROM client WHERE coachID IN ($ph)");
|
||||
$byCoach->execute($chunk);
|
||||
foreach ($byCoach->fetchAll(PDO::FETCH_COLUMN) as $code) {
|
||||
$clientCodes[$code] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$clientCodeList = array_keys($clientCodes);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($clientCodeList !== []) {
|
||||
$responseDeleted = qdb_delete_client_response_data($pdo, $clientCodeList);
|
||||
$deleted['submissions'] += $responseDeleted['submissions'];
|
||||
$deleted['followup_notes'] += $responseDeleted['followup_notes'];
|
||||
$deleted['client_answers'] += $responseDeleted['client_answers'];
|
||||
$deleted['completed_questionnaires'] += $responseDeleted['completed_questionnaires'];
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
$deleted['submissions'] += qdb_delete_submissions_for_coaches($pdo, $coachIdList);
|
||||
$deleted['completed_questionnaires'] += qdb_dev_delete_by_ids(
|
||||
$pdo,
|
||||
'completed_questionnaire',
|
||||
'assignedByCoach',
|
||||
$coachIdList
|
||||
);
|
||||
}
|
||||
|
||||
if ($clientCodeList !== []) {
|
||||
$deleted['clients'] += qdb_dev_delete_by_ids($pdo, 'client', 'clientCode', $clientCodeList);
|
||||
}
|
||||
if ($coachIdList !== []) {
|
||||
foreach (array_chunk($coachIdList, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$delCl = $pdo->prepare("DELETE FROM client WHERE coachID IN ($ph)");
|
||||
$delCl->execute($chunk);
|
||||
$deleted['clients'] += $delCl->rowCount();
|
||||
}
|
||||
}
|
||||
$delClPrefix = $pdo->prepare('DELETE FROM client WHERE clientCode LIKE :pfx');
|
||||
$delClPrefix->execute([':pfx' => $clientLike]);
|
||||
$deleted['clients'] += $delClPrefix->rowCount();
|
||||
|
||||
$deleted['sessions'] = qdb_dev_delete_by_ids($pdo, 'session', 'userID', array_keys($userIds));
|
||||
|
||||
$deleted['users'] = qdb_dev_delete_by_ids($pdo, 'users', 'userID', array_keys($userIds));
|
||||
$delUsersLike = $pdo->prepare('DELETE FROM users WHERE username LIKE :pfx');
|
||||
$delUsersLike->execute([':pfx' => $userLike]);
|
||||
$deleted['users'] += $delUsersLike->rowCount();
|
||||
|
||||
$deleted['coaches'] = qdb_dev_delete_by_ids($pdo, 'coach', 'coachID', $coachIdList);
|
||||
$delCoachLike = $pdo->prepare('DELETE FROM coach WHERE username LIKE :pfx');
|
||||
$delCoachLike->execute([':pfx' => $userLike]);
|
||||
$deleted['coaches'] += $delCoachLike->rowCount();
|
||||
|
||||
$deleted['supervisors'] = qdb_dev_delete_by_ids($pdo, 'supervisor', 'supervisorID', array_keys($supervisorIds));
|
||||
$delSvLike = $pdo->prepare('DELETE FROM supervisor WHERE username LIKE :pfx');
|
||||
$delSvLike->execute([':pfx' => $userLike]);
|
||||
$deleted['supervisors'] += $delSvLike->rowCount();
|
||||
|
||||
$deleted['admins'] = qdb_dev_delete_by_ids($pdo, 'admin', 'adminID', array_keys($adminIds));
|
||||
$delAdLike = $pdo->prepare('DELETE FROM admin WHERE username LIKE :pfx');
|
||||
$delAdLike->execute([':pfx' => $userLike]);
|
||||
$deleted['admins'] += $delAdLike->rowCount();
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['deleted' => $deleted];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all clients, completions, coaches, and supervisors. Keeps admin accounts
|
||||
* and questionnaire definitions (questions, translations, etc.).
|
||||
*
|
||||
* @return array{deleted: array<string, int>, kept: array<string, int>}
|
||||
*/
|
||||
function qdb_wipe_all_data_except_admins(PDO $pdo): array
|
||||
{
|
||||
$deleted = [
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
'clients' => 0,
|
||||
'sessions' => 0,
|
||||
'users' => 0,
|
||||
'coaches' => 0,
|
||||
'supervisors' => 0,
|
||||
];
|
||||
|
||||
$keptStmt = $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'admin'");
|
||||
$keptAdmins = (int)$keptStmt->fetchColumn();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// Interview data references clients/coaches; coach references supervisor.
|
||||
// Disable FK checks for this bulk wipe (same pattern as questionnaire delete).
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF');
|
||||
|
||||
$deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer');
|
||||
if (qdb_table_exists($pdo, 'client_answer_submission')) {
|
||||
$pdo->exec('DELETE FROM client_answer_submission');
|
||||
}
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$pdo->exec('DELETE FROM questionnaire_submission');
|
||||
}
|
||||
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||
$pdo->exec('DELETE FROM client_followup_note');
|
||||
}
|
||||
$deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire');
|
||||
$deleted['clients'] = (int)$pdo->exec('DELETE FROM client');
|
||||
|
||||
$deleted['coaches'] = (int)$pdo->exec('DELETE FROM coach');
|
||||
$deleted['supervisors'] = (int)$pdo->exec('DELETE FROM supervisor');
|
||||
|
||||
$sess = $pdo->prepare(
|
||||
"DELETE FROM session WHERE userID IN (SELECT userID FROM users WHERE role != 'admin')"
|
||||
);
|
||||
$sess->execute();
|
||||
$deleted['sessions'] = $sess->rowCount();
|
||||
|
||||
$users = $pdo->prepare("DELETE FROM users WHERE role != 'admin'");
|
||||
$users->execute();
|
||||
$deleted['users'] = $users->rowCount();
|
||||
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
try {
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
} catch (Throwable $ignored) {
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return [
|
||||
'deleted' => $deleted,
|
||||
'kept' => ['admins' => $keptAdmins],
|
||||
];
|
||||
}
|
||||
60
lib/encrypted_payload.php
Normal file
60
lib/encrypted_payload.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
/**
|
||||
* Application-layer encryption for sensitive mobile API payloads.
|
||||
* AES-256-GCM (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
|
||||
* Legacy CBC envelopes are accepted for compatibility with older app builds.
|
||||
*/
|
||||
|
||||
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
|
||||
$key = hkdf_session_key_from_token($tokenHex);
|
||||
return [
|
||||
'encrypted' => true,
|
||||
'alg' => 'A256GCM',
|
||||
'payload' => base64_encode(qdb_aes256_gcm_encrypt_bytes($plainJson, $key)),
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_decrypt_sensitive_envelope(array $envelope, string $tokenHex): string {
|
||||
if (empty($envelope['encrypted']) || !isset($envelope['payload']) || !is_string($envelope['payload'])) {
|
||||
throw new InvalidArgumentException('Invalid encrypted envelope');
|
||||
}
|
||||
$raw = base64_decode($envelope['payload'], true);
|
||||
if ($raw === false) {
|
||||
throw new InvalidArgumentException('Invalid base64 payload');
|
||||
}
|
||||
$key = hkdf_session_key_from_token($tokenHex);
|
||||
$alg = isset($envelope['alg']) && is_string($envelope['alg']) ? $envelope['alg'] : 'A256CBC';
|
||||
return match ($alg) {
|
||||
'A256GCM' => qdb_aes256_gcm_decrypt_bytes($raw, $key),
|
||||
'A256CBC' => aes256_cbc_decrypt_bytes($raw, $key),
|
||||
default => throw new InvalidArgumentException('Unsupported encrypted envelope algorithm'),
|
||||
};
|
||||
}
|
||||
|
||||
function qdb_aes256_gcm_encrypt_bytes(string $plain, string $key): string {
|
||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||
$iv = random_bytes(12);
|
||||
$tag = '';
|
||||
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||
if ($cipher === false || strlen($tag) !== 16) {
|
||||
throw new RuntimeException('openssl_encrypt failed');
|
||||
}
|
||||
return $iv . $cipher . $tag;
|
||||
}
|
||||
|
||||
function qdb_aes256_gcm_decrypt_bytes(string $data, string $key): string {
|
||||
$ivLen = 12;
|
||||
$tagLen = 16;
|
||||
if (strlen($data) <= $ivLen + $tagLen) {
|
||||
throw new InvalidArgumentException('cipher too short');
|
||||
}
|
||||
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||
$iv = substr($data, 0, $ivLen);
|
||||
$tag = substr($data, -$tagLen);
|
||||
$ct = substr($data, $ivLen, -$tagLen);
|
||||
$plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||
if ($plain === false) {
|
||||
throw new InvalidArgumentException('openssl_decrypt failed');
|
||||
}
|
||||
return $plain;
|
||||
}
|
||||
89
lib/errors.php
Normal file
89
lib/errors.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Shared API error helpers for handlers and db_init.
|
||||
*/
|
||||
|
||||
function qdb_is_lock_exception(Throwable $e): bool
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
return str_contains($msg, 'Could not acquire lock')
|
||||
|| str_contains($msg, 'Could not open lock file');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map internal exceptions to safe, identifiable API error messages (details go to error_log).
|
||||
*/
|
||||
function qdb_public_error_message(Throwable $e, string $contextLabel = 'Operation'): string
|
||||
{
|
||||
$msg = $e->getMessage();
|
||||
|
||||
if (qdb_is_lock_exception($e)) {
|
||||
return 'Database is busy because another update is in progress. Please try again in a moment.';
|
||||
}
|
||||
if (str_contains($msg, 'decrypt') || str_contains($msg, 'QDB_MASTER_KEY')) {
|
||||
return 'Database configuration error. Contact the server administrator.';
|
||||
}
|
||||
if (str_contains($msg, 'Could not read') || str_contains($msg, 'Could not write')
|
||||
|| str_contains($msg, 'Could not save')) {
|
||||
return 'Database storage error. Contact the server administrator.';
|
||||
}
|
||||
if ($e instanceof PDOException) {
|
||||
return $contextLabel . ' failed due to a database error.';
|
||||
}
|
||||
|
||||
return $contextLabel . ' failed. If this persists, contact support.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string, 2: int} code, message, http status
|
||||
*/
|
||||
function qdb_api_error_for_exception(Throwable $e, string $contextLabel = 'Operation'): array
|
||||
{
|
||||
if (qdb_is_lock_exception($e)) {
|
||||
return ['LOCKED', qdb_public_error_message($e, $contextLabel), 503];
|
||||
}
|
||||
return ['SERVER_ERROR', qdb_public_error_message($e, $contextLabel), 500];
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard handler catch: rollback, discard DB temp file, log, JSON error (never returns).
|
||||
*/
|
||||
function qdb_handler_fail(
|
||||
Throwable $e,
|
||||
string $contextLabel,
|
||||
?PDO $pdo = null,
|
||||
?string $tmpDb = null,
|
||||
$lockFp = null
|
||||
): never {
|
||||
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
|
||||
throw $e;
|
||||
}
|
||||
if ($pdo !== null && $pdo->inTransaction()) {
|
||||
try {
|
||||
$pdo->rollBack();
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
if ($tmpDb !== null && $lockFp !== null) {
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
qdb_emit_api_error($e, $contextLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log exception and exit with unified JSON error (never returns).
|
||||
*/
|
||||
function qdb_emit_api_error(Throwable $e, string $contextLabel): never
|
||||
{
|
||||
if (defined('QDB_TESTING') && qdb_test_is_control_flow_exception($e)) {
|
||||
throw $e;
|
||||
}
|
||||
if (function_exists('qdb_api_log_note_exception')) {
|
||||
qdb_api_log_note_exception($e);
|
||||
}
|
||||
error_log($contextLabel . ': ' . $e->getMessage());
|
||||
[$code, $message, $status] = qdb_api_error_for_exception($e, $contextLabel);
|
||||
json_error($code, $message, $status);
|
||||
}
|
||||
221
lib/keycloak_auth.php
Normal file
221
lib/keycloak_auth.php
Normal file
@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Minimal OpenID Connect support for Keycloak-backed website login.
|
||||
*
|
||||
* Required .env values:
|
||||
* - QDB_KEYCLOAK_ISSUER, e.g. https://.../realms/...
|
||||
* - QDB_KEYCLOAK_CLIENT_ID
|
||||
* - QDB_KEYCLOAK_CLIENT_SECRET
|
||||
*
|
||||
* Optional:
|
||||
* - QDB_KEYCLOAK_REDIRECT_URI (defaults to current /api/auth/keycloak-callback)
|
||||
* - QDB_KEYCLOAK_USERNAME_CLAIM (defaults to preferred_username)
|
||||
* - QDB_KEYCLOAK_DISPLAY_NAME (defaults to University Konstanz)
|
||||
*/
|
||||
|
||||
function qdb_keycloak_config(): array
|
||||
{
|
||||
$issuer = rtrim((string)(qdb_env_get('QDB_KEYCLOAK_ISSUER') ?? ''), '/');
|
||||
$clientId = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_ID') ?? '');
|
||||
$clientSecret = (string)(qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET') ?? '');
|
||||
return [
|
||||
'issuer' => $issuer,
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'redirect_uri' => (string)(qdb_env_get('QDB_KEYCLOAK_REDIRECT_URI') ?? qdb_keycloak_default_redirect_uri()),
|
||||
'username_claim' => (string)(qdb_env_get('QDB_KEYCLOAK_USERNAME_CLAIM') ?? 'preferred_username'),
|
||||
'display_name' => (string)(qdb_env_get('QDB_KEYCLOAK_DISPLAY_NAME') ?? 'University Konstanz'),
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_keycloak_is_configured(?array $config = null): bool
|
||||
{
|
||||
$config ??= qdb_keycloak_config();
|
||||
return $config['issuer'] !== '' && $config['client_id'] !== '' && $config['client_secret'] !== '';
|
||||
}
|
||||
|
||||
function qdb_keycloak_default_redirect_uri(): string
|
||||
{
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
|
||||
$base = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||
return $scheme . '://' . $host . $base . '/auth/keycloak-callback';
|
||||
}
|
||||
|
||||
function qdb_keycloak_frontend_url(): string
|
||||
{
|
||||
$configured = (string)(qdb_env_get('QDB_KEYCLOAK_FRONTEND_URL') ?? '');
|
||||
if ($configured !== '') {
|
||||
return $configured;
|
||||
}
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||
$scheme = $https ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
|
||||
$apiBase = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||
$root = preg_replace('#/api$#', '', $apiBase) ?: '';
|
||||
return $scheme . '://' . $host . $root . '/website/index.html';
|
||||
}
|
||||
|
||||
function qdb_keycloak_state_signing_key(): string
|
||||
{
|
||||
$secret = qdb_env_get('QDB_KEYCLOAK_STATE_SECRET')
|
||||
?? qdb_env_get('QDB_MASTER_KEY')
|
||||
?? qdb_env_get('QDB_KEYCLOAK_CLIENT_SECRET')
|
||||
?? '';
|
||||
if ($secret === '') {
|
||||
throw new RuntimeException('No signing secret available for Keycloak state');
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
function qdb_keycloak_base64url(string $bytes): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function qdb_keycloak_unbase64url(string $value): string
|
||||
{
|
||||
$padded = strtr($value, '-_', '+/');
|
||||
$padded .= str_repeat('=', (4 - strlen($padded) % 4) % 4);
|
||||
$decoded = base64_decode($padded, true);
|
||||
if ($decoded === false) {
|
||||
throw new InvalidArgumentException('Invalid base64url value');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
function qdb_keycloak_create_state(string $nonce): string
|
||||
{
|
||||
$payload = json_encode([
|
||||
'nonce' => $nonce,
|
||||
'exp' => time() + 600,
|
||||
], JSON_THROW_ON_ERROR);
|
||||
$body = qdb_keycloak_base64url($payload);
|
||||
$sig = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
|
||||
return $body . '.' . $sig;
|
||||
}
|
||||
|
||||
function qdb_keycloak_verify_state(string $state): array
|
||||
{
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
|
||||
}
|
||||
[$body, $sig] = $parts;
|
||||
$expected = qdb_keycloak_base64url(hash_hmac('sha256', $body, qdb_keycloak_state_signing_key(), true));
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
json_error('INVALID_STATE', 'Invalid Keycloak state', 400);
|
||||
}
|
||||
$payload = json_decode(qdb_keycloak_unbase64url($body), true);
|
||||
if (!is_array($payload) || (int)($payload['exp'] ?? 0) < time()) {
|
||||
json_error('INVALID_STATE', 'Expired Keycloak state', 400);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function qdb_keycloak_discovery(array $config): array
|
||||
{
|
||||
$url = $config['issuer'] . '/.well-known/openid-configuration';
|
||||
$json = qdb_keycloak_http_json($url);
|
||||
foreach (['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] as $key) {
|
||||
if (empty($json[$key])) {
|
||||
throw new RuntimeException("Keycloak discovery missing $key");
|
||||
}
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function qdb_keycloak_http_json(string $url, array $options = []): array
|
||||
{
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => $options['method'] ?? 'GET',
|
||||
'header' => $options['headers'] ?? '',
|
||||
'content' => $options['content'] ?? '',
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
]]);
|
||||
$raw = file_get_contents($url, false, $context);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Keycloak request failed');
|
||||
}
|
||||
$status = 200;
|
||||
foreach (($http_response_header ?? []) as $header) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d+)#', $header, $m)) {
|
||||
$status = (int)$m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$json = json_decode($raw, true);
|
||||
if ($status < 200 || $status >= 300 || !is_array($json)) {
|
||||
throw new RuntimeException('Keycloak returned an invalid response');
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function qdb_keycloak_exchange_code(array $config, array $discovery, string $code): array
|
||||
{
|
||||
$body = http_build_query([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $config['redirect_uri'],
|
||||
'client_id' => $config['client_id'],
|
||||
'client_secret' => $config['client_secret'],
|
||||
]);
|
||||
return qdb_keycloak_http_json($discovery['token_endpoint'], [
|
||||
'method' => 'POST',
|
||||
'headers' => "Content-Type: application/x-www-form-urlencoded\r\n",
|
||||
'content' => $body,
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_keycloak_userinfo(array $discovery, string $accessToken): array
|
||||
{
|
||||
return qdb_keycloak_http_json($discovery['userinfo_endpoint'], [
|
||||
'headers' => "Authorization: Bearer $accessToken\r\n",
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_keycloak_claim_username(array $config, array $claims): string
|
||||
{
|
||||
$claim = $config['username_claim'] ?: 'preferred_username';
|
||||
$username = trim((string)($claims[$claim] ?? ''));
|
||||
if ($username === '' && $claim !== 'email') {
|
||||
$username = trim((string)($claims['email'] ?? ''));
|
||||
}
|
||||
if ($username === '') {
|
||||
json_error('KEYCLOAK_NO_USERNAME', 'University login did not provide a usable username', 403);
|
||||
}
|
||||
return $username;
|
||||
}
|
||||
|
||||
function qdb_keycloak_redirect(string $url, int $status = 302): never
|
||||
{
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\RawHttpResponse('', ['Location' => $url], $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Location: ' . $url, true, $status);
|
||||
exit;
|
||||
}
|
||||
|
||||
function qdb_keycloak_finish_html(string $token, string $user, string $role): never
|
||||
{
|
||||
$target = qdb_keycloak_frontend_url() . '#/';
|
||||
$payload = json_encode([
|
||||
'token' => $token,
|
||||
'user' => $user,
|
||||
'role' => $role,
|
||||
'target' => $target,
|
||||
], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
|
||||
qdb_http_download(
|
||||
"<!doctype html><meta charset=\"utf-8\"><script>const d=$payload;localStorage.setItem('qdb_token',d.token);localStorage.setItem('qdb_user',d.user);localStorage.setItem('qdb_role',d.role);location.replace(d.target);</script>",
|
||||
['Content-Type' => 'text/html; charset=UTF-8']
|
||||
);
|
||||
}
|
||||
|
||||
176
lib/login_rate_limit.php
Normal file
176
lib/login_rate_limit.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
function qdb_login_rate_limit_path(): string
|
||||
{
|
||||
return dirname(QDB_PATH) . '/.login_rate_limit.json';
|
||||
}
|
||||
|
||||
function qdb_client_ip(): string
|
||||
{
|
||||
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if ($raw === '') {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
if (str_contains($raw, ',')) {
|
||||
$raw = trim(explode(',', $raw)[0]);
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_key(string $username): string
|
||||
{
|
||||
$ip = qdb_client_ip();
|
||||
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool, 1: int} allowed, retry_after_seconds
|
||||
*/
|
||||
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return [true, 0];
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return [true, 0];
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$lockedUntil = (int)($entry['locked_until'] ?? 0);
|
||||
if ($lockedUntil > $now) {
|
||||
return [false, $lockedUntil - $now];
|
||||
}
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return [false, $lockout];
|
||||
}
|
||||
return [true, 0];
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return 0;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
$attempts[] = $now;
|
||||
$retry = 0;
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$retry = $lockout;
|
||||
}
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return $retry;
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_clear(string $username): void
|
||||
{
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
if (!is_file($path)) {
|
||||
return;
|
||||
}
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data) || !isset($data[$key])) {
|
||||
return;
|
||||
}
|
||||
unset($data[$key]);
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_deny(int $retryAfterSeconds): never
|
||||
{
|
||||
if ($retryAfterSeconds > 0) {
|
||||
header('Retry-After: ' . (string)$retryAfterSeconds);
|
||||
}
|
||||
json_error(
|
||||
'RATE_LIMITED',
|
||||
'Too many login attempts. Please wait before trying again.',
|
||||
429
|
||||
);
|
||||
}
|
||||
414
lib/questionnaire_structure.php
Normal file
414
lib/questionnaire_structure.php
Normal file
@ -0,0 +1,414 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire structure revisions: bump, snapshot manifests, retire questions.
|
||||
*/
|
||||
|
||||
function qdb_active_questions_clause(string $alias = 'question'): string {
|
||||
return "COALESCE($alias.retiredAt, 0) = 0";
|
||||
}
|
||||
|
||||
function qdb_active_options_clause(string $alias = 'answer_option'): string {
|
||||
return "COALESCE($alias.retiredAt, 0) = 0";
|
||||
}
|
||||
|
||||
function qdb_questionnaire_structure_revision(PDO $pdo, string $qnID): int {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(structureRevision, 1) FROM questionnaire WHERE questionnaireID = :id'
|
||||
);
|
||||
$stmt->execute([':id' => $qnID]);
|
||||
$rev = $stmt->fetchColumn();
|
||||
return $rev !== false ? max(1, (int)$rev) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_build_structure_manifest(PDO $pdo, string $qnID, bool $includeRetired = false): array {
|
||||
$where = 'questionnaireID = :qn';
|
||||
if (!$includeRetired) {
|
||||
$where .= ' AND ' . qdb_active_questions_clause('question');
|
||||
}
|
||||
$qStmt = $pdo->prepare(
|
||||
"SELECT questionID, type, isRequired, configJson, defaultText, retiredAt
|
||||
FROM question WHERE $where ORDER BY orderIndex"
|
||||
);
|
||||
$qStmt->execute([':qn' => $qnID]);
|
||||
$questions = [];
|
||||
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
||||
$fullId = $qRow['questionID'];
|
||||
$shortId = qdb_question_local_id($fullId, $qnID);
|
||||
$config = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$qKey = qdb_question_key($config, $qRow['defaultText']);
|
||||
|
||||
$optWhere = 'questionID = :qid';
|
||||
if (!$includeRetired) {
|
||||
$optWhere .= ' AND ' . qdb_active_options_clause('answer_option');
|
||||
}
|
||||
$aoStmt = $pdo->prepare(
|
||||
"SELECT defaultText, points FROM answer_option WHERE $optWhere ORDER BY orderIndex"
|
||||
);
|
||||
$aoStmt->execute([':qid' => $fullId]);
|
||||
$options = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$options[] = [
|
||||
'key' => $ao['defaultText'],
|
||||
'points' => (int)$ao['points'],
|
||||
];
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'questionID' => $fullId,
|
||||
'shortId' => $shortId,
|
||||
'questionKey'=> $qKey,
|
||||
'type' => $qRow['type'] ?? '',
|
||||
'isRequired' => (int)($qRow['isRequired'] ?? 0) === 1,
|
||||
'options' => $options,
|
||||
];
|
||||
if ($includeRetired && (int)($qRow['retiredAt'] ?? 0) > 0) {
|
||||
$entry['retired'] = true;
|
||||
}
|
||||
if (($qRow['type'] ?? '') === 'glass_scale_question' && !empty($config['symptoms'])) {
|
||||
$entry['symptoms'] = array_values(array_map('strval', (array)$config['symptoms']));
|
||||
}
|
||||
$questions[] = $entry;
|
||||
}
|
||||
|
||||
return [
|
||||
'questionnaireID' => $qnID,
|
||||
'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID),
|
||||
'questions' => $questions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
function qdb_load_structure_manifest(PDO $pdo, string $qnID, int $revision): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT manifestJson FROM questionnaire_structure_snapshot
|
||||
WHERE questionnaireID = :qn AND structureRevision = :rev'
|
||||
);
|
||||
$stmt->execute([':qn' => $qnID, ':rev' => $revision]);
|
||||
$json = $stmt->fetchColumn();
|
||||
if ($json === false || $json === '') {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode((string)$json, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
function qdb_save_structure_snapshot(PDO $pdo, string $qnID, int $revision, array $manifest): void {
|
||||
$json = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
$json = '{}';
|
||||
}
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire_structure_snapshot (questionnaireID, structureRevision, createdAt, manifestJson)
|
||||
VALUES (:qn, :rev, :ts, :mj)
|
||||
ON CONFLICT(questionnaireID, structureRevision) DO UPDATE SET
|
||||
manifestJson = excluded.manifestJson,
|
||||
createdAt = excluded.createdAt'
|
||||
)->execute([
|
||||
':qn' => $qnID,
|
||||
':rev' => $revision,
|
||||
':ts' => time(),
|
||||
':mj' => $json,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill revision-1 snapshots for questionnaires missing any snapshot row.
|
||||
*/
|
||||
function qdb_backfill_structure_snapshots(PDO $pdo): bool {
|
||||
$ids = $pdo->query('SELECT questionnaireID FROM questionnaire')->fetchAll(PDO::FETCH_COLUMN);
|
||||
$changed = false;
|
||||
foreach ($ids as $qnID) {
|
||||
$qnID = (string)$qnID;
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT 1 FROM questionnaire_structure_snapshot
|
||||
WHERE questionnaireID = :qn LIMIT 1'
|
||||
);
|
||||
$chk->execute([':qn' => $qnID]);
|
||||
if ($chk->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
$rev = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, true);
|
||||
$manifest['structureRevision'] = $rev;
|
||||
qdb_save_structure_snapshot($pdo, $qnID, $rev, $manifest);
|
||||
$changed = true;
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
function qdb_bump_structure_revision(PDO $pdo, string $qnID, string $reason = ''): int {
|
||||
$current = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||
$manifest['structureRevision'] = $current;
|
||||
qdb_save_structure_snapshot($pdo, $qnID, $current, $manifest);
|
||||
|
||||
$newRev = $current + 1;
|
||||
$pdo->prepare(
|
||||
'UPDATE questionnaire SET structureRevision = :rev, structureChangedAt = :ts WHERE questionnaireID = :qn'
|
||||
)->execute([':rev' => $newRev, ':ts' => time(), ':qn' => $qnID]);
|
||||
|
||||
return $newRev;
|
||||
}
|
||||
|
||||
function qdb_questionnaire_id_for_question(PDO $pdo, string $questionID): ?string {
|
||||
$stmt = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :qid');
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
$id = $stmt->fetchColumn();
|
||||
return $id !== false ? (string)$id : null;
|
||||
}
|
||||
|
||||
function qdb_option_has_client_data(PDO $pdo, string $answerOptionID): bool {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE answerOptionID = :id LIMIT 1');
|
||||
$stmt->execute([':id' => $answerOptionID]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return true;
|
||||
}
|
||||
$chk = $pdo->query(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
|
||||
);
|
||||
if (!$chk || !$chk->fetchColumn()) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM client_answer_submission WHERE answerOptionID = :id LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':id' => $answerOptionID]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_question_has_client_data(PDO $pdo, string $questionID): bool {
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM client_answer WHERE questionID = :qid LIMIT 1');
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
if ($stmt->fetchColumn()) {
|
||||
return true;
|
||||
}
|
||||
$chk = $pdo->query(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'client_answer_submission' LIMIT 1"
|
||||
);
|
||||
if (!$chk || !$chk->fetchColumn()) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT 1 FROM client_answer_submission WHERE questionID = :qid LIMIT 1'
|
||||
);
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function qdb_retire_question(PDO $pdo, string $questionID, int $revision): void {
|
||||
$pdo->prepare(
|
||||
'UPDATE question SET retiredAt = :ts, retiredInRevision = :rev
|
||||
WHERE questionID = :qid AND COALESCE(retiredAt, 0) = 0'
|
||||
)->execute([':ts' => time(), ':rev' => $revision, ':qid' => $questionID]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build submit maps from a structure manifest (current or legacy).
|
||||
*
|
||||
* @return array{
|
||||
* shortIdMap: array<string, string>,
|
||||
* shortIdToType: array<string, string>,
|
||||
* optionMap: array<string, array<string, array{answerOptionID: string, points: int}>>,
|
||||
* symptomParentMap: array<string, string>,
|
||||
* freeTextMaxLen: array<string, int>,
|
||||
* manifestQuestionIds: list<string>
|
||||
* }
|
||||
*/
|
||||
function qdb_submit_maps_from_manifest(PDO $pdo, string $qnID, array $manifest): array {
|
||||
$shortIdMap = [];
|
||||
$shortIdToType = [];
|
||||
$optionMap = [];
|
||||
$symptomParentMap = [];
|
||||
$freeTextMaxLen = [];
|
||||
$manifestQuestionIds = [];
|
||||
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
if ($fullId === '' || $shortId === '') {
|
||||
continue;
|
||||
}
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
$shortIdMap[$shortId] = $fullId;
|
||||
$shortIdToType[$shortId] = $type;
|
||||
$manifestQuestionIds[] = $fullId;
|
||||
|
||||
if ($type === 'free_text') {
|
||||
$cfgStmt = $pdo->prepare('SELECT configJson FROM question WHERE questionID = :qid');
|
||||
$cfgStmt->execute([':qid' => $fullId]);
|
||||
$cfgJson = $cfgStmt->fetchColumn();
|
||||
$cfg = json_decode($cfgJson ?: '{}', true) ?: [];
|
||||
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
||||
}
|
||||
|
||||
$optionMap[$fullId] = [];
|
||||
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||
if (!is_array($opt)) {
|
||||
continue;
|
||||
}
|
||||
$key = (string)($opt['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT answerOptionID, points FROM answer_option
|
||||
WHERE questionID = :qid AND defaultText = :k LIMIT 1'
|
||||
);
|
||||
$aoStmt->execute([':qid' => $fullId, ':k' => $key]);
|
||||
$aoRow = $aoStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$optionMap[$fullId][$key] = [
|
||||
'answerOptionID' => $aoRow ? (string)$aoRow['answerOptionID'] : '',
|
||||
'points' => $aoRow ? (int)$aoRow['points'] : (int)($opt['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk !== '') {
|
||||
$symptomParentMap[$sk] = $fullId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($symptomParentMap === []) {
|
||||
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
||||
}
|
||||
|
||||
return [
|
||||
'shortIdMap' => $shortIdMap,
|
||||
'shortIdToType' => $shortIdToType,
|
||||
'optionMap' => $optionMap,
|
||||
'symptomParentMap' => $symptomParentMap,
|
||||
'freeTextMaxLen' => $freeTextMaxLen,
|
||||
'manifestQuestionIds' => $manifestQuestionIds,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build submit maps from active DB questions (current revision).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_submit_maps_from_active_questions(PDO $pdo, string $qnID): array {
|
||||
$manifest = qdb_build_structure_manifest($pdo, $qnID, false);
|
||||
$manifest['structureRevision'] = qdb_questionnaire_structure_revision($pdo, $qnID);
|
||||
return qdb_submit_maps_from_manifest($pdo, $qnID, $manifest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{field: string, header: string, questionID: string, type: string}>
|
||||
*/
|
||||
function qdb_result_columns_from_manifest(array $manifest): array {
|
||||
$cols = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$shortId = (string)($qEntry['shortId'] ?? '');
|
||||
$qKey = (string)($qEntry['questionKey'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
if ($fullId === '') {
|
||||
continue;
|
||||
}
|
||||
$header = $qKey !== '' ? $qKey : ($shortId !== '' ? $shortId : $fullId);
|
||||
$cols[] = [
|
||||
'field' => $header,
|
||||
'header' => $header,
|
||||
'questionID' => $fullId,
|
||||
'type' => $type,
|
||||
];
|
||||
if ($type === 'glass_scale_question') {
|
||||
foreach ($qEntry['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$cols[] = [
|
||||
'field' => $sk,
|
||||
'header' => $sk,
|
||||
'questionID' => $fullId,
|
||||
'type' => 'glass_symptom',
|
||||
'symptomKey' => $sk,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $cols;
|
||||
}
|
||||
|
||||
function qdb_compute_questionnaire_score_from_manifest(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
array $manifest
|
||||
): int {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
$qnID = (string)($manifest['questionnaireID'] ?? '');
|
||||
if ($qnID === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$questionIDs = [];
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (is_array($qEntry) && !empty($qEntry['questionID'])) {
|
||||
$questionIDs[] = (string)$qEntry['questionID'];
|
||||
}
|
||||
}
|
||||
if ($questionIDs === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
$aStmt = $pdo->prepare(
|
||||
"SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
|
||||
ao.defaultText AS optionKey
|
||||
FROM client_answer ca
|
||||
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
|
||||
WHERE ca.clientCode = ? AND ca.questionID IN ($ph)"
|
||||
);
|
||||
$aStmt->execute(array_merge([$clientCode], $questionIDs));
|
||||
$answersByQuestion = [];
|
||||
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answersByQuestion[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
$type = (string)($qEntry['type'] ?? '');
|
||||
$optionPoints = [];
|
||||
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||
if (is_array($opt) && isset($opt['key'])) {
|
||||
$optionPoints[(string)$opt['key']] = (int)($opt['points'] ?? 0);
|
||||
}
|
||||
}
|
||||
$qRow = [
|
||||
'questionID' => $fullId,
|
||||
'type' => $type,
|
||||
'configJson' => json_encode(
|
||||
['symptoms' => $qEntry['symptoms'] ?? []],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
),
|
||||
];
|
||||
$total += qdb_score_points_for_question(
|
||||
$pdo,
|
||||
$qRow,
|
||||
$answersByQuestion[$fullId] ?? null,
|
||||
$optionPoints
|
||||
);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
299
lib/read_db_cache.php
Normal file
299
lib/read_db_cache.php
Normal file
@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* Per-worker read-only DB snapshot in RAM (memfd on Linux) — no plaintext on durable disk.
|
||||
*/
|
||||
|
||||
/** Passed as $tmpDb when the PDO comes from the worker cache (qdb_discard is a no-op). */
|
||||
define('QDB_READONLY_CACHE_HANDLE', "\0qdb_cached_read");
|
||||
|
||||
define('QDB_READ_GENERATION_FILE', QDB_UPLOADS_DIR . '/.qdb_read_generation');
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: null}
|
||||
*/
|
||||
function qdb_read_db_open(): array {
|
||||
$signature = qdb_read_cache_signature();
|
||||
$cached = qdb_read_cache_get($signature);
|
||||
if ($cached !== null) {
|
||||
return [$cached, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
[$pdo, $memfdPath, $memfdFd] = qdb_read_materialize_decrypted_db();
|
||||
// Materialize may persist a migration (updates QDB_PATH mtime); refresh signature before caching.
|
||||
$signature = qdb_read_cache_signature();
|
||||
qdb_read_cache_store($signature, $pdo, $memfdPath, $memfdFd);
|
||||
return [$pdo, QDB_READONLY_CACHE_HANDLE, null];
|
||||
}
|
||||
|
||||
function qdb_read_cache_invalidate(): void {
|
||||
qdb_read_cache_reset();
|
||||
qdb_read_generation_bump();
|
||||
}
|
||||
|
||||
function qdb_read_cache_signature(): string {
|
||||
$gen = qdb_read_generation_current();
|
||||
if (!is_file(QDB_PATH)) {
|
||||
return $gen . ':missing';
|
||||
}
|
||||
$mtime = @filemtime(QDB_PATH);
|
||||
$size = @filesize(QDB_PATH);
|
||||
return $gen . ':' . (int)$mtime . ':' . (int)$size;
|
||||
}
|
||||
|
||||
function qdb_read_generation_current(): string {
|
||||
if (!is_file(QDB_READ_GENERATION_FILE)) {
|
||||
return '0';
|
||||
}
|
||||
$raw = @file_get_contents(QDB_READ_GENERATION_FILE);
|
||||
return $raw === false || $raw === '' ? '0' : trim($raw);
|
||||
}
|
||||
|
||||
function qdb_read_generation_bump(): void {
|
||||
$dir = dirname(QDB_READ_GENERATION_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
$next = (string)((int)qdb_read_generation_current() + 1);
|
||||
@file_put_contents(QDB_READ_GENERATION_FILE, $next, LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: PDO, 1: string, 2: int} memfdPath empty when using disk fallback
|
||||
*/
|
||||
function qdb_read_materialize_decrypted_db(): array {
|
||||
$dbPath = QDB_PATH;
|
||||
$masterKey = get_master_key_bytes();
|
||||
$sql = file_get_contents(QDB_SCHEMA);
|
||||
if ($sql === false) {
|
||||
throw new Exception('Could not read schema.sql');
|
||||
}
|
||||
|
||||
if (!file_exists($dbPath) || !is_file($dbPath)) {
|
||||
$mem = qdb_read_open_sqlite_on_path(':memory:', $sql, true);
|
||||
return [$mem, '', -1];
|
||||
}
|
||||
|
||||
$storedEnc = @file_get_contents($dbPath);
|
||||
if ($storedEnc === false) {
|
||||
throw new Exception('Could not read stored DB');
|
||||
}
|
||||
$decrypted = aes256_cbc_decrypt_bytes($storedEnc, $masterKey);
|
||||
|
||||
$memfd = qdb_memfd_write($decrypted);
|
||||
if ($memfd !== null) {
|
||||
[$fd, $path] = $memfd;
|
||||
try {
|
||||
$pdo = qdb_read_open_sqlite_on_path($path, $sql, false);
|
||||
return [$pdo, $path, $fd];
|
||||
} catch (Throwable $e) {
|
||||
// memfd is filled but SQLite cannot open /proc/self/fd on this host.
|
||||
qdb_close_fd($fd);
|
||||
}
|
||||
}
|
||||
|
||||
$snapshotPath = qdb_read_ram_snapshot_path();
|
||||
if (file_put_contents($snapshotPath, $decrypted) === false) {
|
||||
throw new Exception('Could not write read snapshot');
|
||||
}
|
||||
@chmod($snapshotPath, 0600);
|
||||
$pdo = qdb_read_open_sqlite_on_path($snapshotPath, $sql, false);
|
||||
return [$pdo, $snapshotPath, -1];
|
||||
}
|
||||
|
||||
/**
|
||||
* RAM-backed snapshot path (tmpfs). One file per worker; overwritten on refresh.
|
||||
*/
|
||||
function qdb_read_ram_snapshot_path(): string {
|
||||
$dir = is_writable('/dev/shm') ? '/dev/shm' : sys_get_temp_dir();
|
||||
return $dir . '/qdb_read_' . getmypid() . '.sqlite';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FFI|null libc handle with memfd_create + close (Linux only)
|
||||
*/
|
||||
function qdb_linux_libc_ffi() {
|
||||
static $ffi = null;
|
||||
if ($ffi !== null) {
|
||||
return $ffi;
|
||||
}
|
||||
if (PHP_OS_FAMILY !== 'Linux' || !extension_loaded('ffi')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$ffi = FFI::cdef(
|
||||
'int memfd_create(const char *name, unsigned int flags);
|
||||
long write(int fd, const void *buf, unsigned long count);
|
||||
int close(int fd);',
|
||||
'libc.so.6'
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log('qdb_linux_libc_ffi: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
return $ffi;
|
||||
}
|
||||
|
||||
function qdb_close_fd(int $fd): void {
|
||||
if ($fd < 0) {
|
||||
return;
|
||||
}
|
||||
if (function_exists('posix_close')) {
|
||||
@posix_close($fd);
|
||||
return;
|
||||
}
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi !== null) {
|
||||
$ffi->close($fd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a raw fd (e.g. memfd). /proc/self/fd/N writes are blocked on some hosts;
|
||||
* fall back to libc write() via FFI.
|
||||
*/
|
||||
function qdb_memfd_fill_fd(int $fd, string $bytes): bool {
|
||||
$len = strlen($bytes);
|
||||
if ($len === 0) {
|
||||
return true;
|
||||
}
|
||||
$written = @file_put_contents('/proc/self/fd/' . $fd, $bytes);
|
||||
if ($written === $len) {
|
||||
return true;
|
||||
}
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi === null) {
|
||||
return false;
|
||||
}
|
||||
$offset = 0;
|
||||
while ($offset < $len) {
|
||||
$chunkLen = min(1024 * 1024, $len - $offset);
|
||||
$chunk = substr($bytes, $offset, $chunkLen);
|
||||
$buf = FFI::new('char[' . $chunkLen . ']');
|
||||
FFI::memcpy($buf, $chunk, $chunkLen);
|
||||
$n = (int)$ffi->write($fd, $buf, $chunkLen);
|
||||
if ($n <= 0) {
|
||||
return false;
|
||||
}
|
||||
$offset += $n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}|null [fd, /proc/self/fd/N]
|
||||
*/
|
||||
function qdb_memfd_write(string $bytes): ?array {
|
||||
$ffi = qdb_linux_libc_ffi();
|
||||
if ($ffi === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$MFD_CLOEXEC = 1;
|
||||
$fd = (int)$ffi->memfd_create('qdb_read', $MFD_CLOEXEC);
|
||||
if ($fd < 0) {
|
||||
return null;
|
||||
}
|
||||
if (!qdb_memfd_fill_fd($fd, $bytes)) {
|
||||
qdb_close_fd($fd);
|
||||
return null;
|
||||
}
|
||||
return [$fd, '/proc/self/fd/' . $fd];
|
||||
} catch (Throwable $e) {
|
||||
error_log('qdb_memfd_write: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_read_open_sqlite_on_path(string $sqlitePath, string $schemaSql, bool $fresh): PDO {
|
||||
if ($fresh) {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec($schemaSql);
|
||||
$pdo->exec('PRAGMA user_version = ' . QDB_VERSION . ';');
|
||||
} else {
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
// DELETE (not WAL) so a migration persist via file_get_contents + qdb_save is complete.
|
||||
$pdo->exec('PRAGMA journal_mode = DELETE;');
|
||||
|
||||
$schemaChanged = qdb_apply_migrations($pdo, $schemaSql);
|
||||
if ($schemaChanged) {
|
||||
$migrateLock = qdb_acquire_lock();
|
||||
try {
|
||||
if ($sqlitePath === ':memory:') {
|
||||
throw new Exception('Cannot persist migration from in-memory read snapshot');
|
||||
}
|
||||
$savePath = $sqlitePath;
|
||||
if (str_starts_with($sqlitePath, '/proc/')) {
|
||||
$savePath = tempnam(sys_get_temp_dir(), 'qdb_migrate_');
|
||||
$bytes = file_get_contents($sqlitePath);
|
||||
if ($bytes === false || file_put_contents($savePath, $bytes) === false) {
|
||||
throw new Exception('Could not copy memfd DB for migration save');
|
||||
}
|
||||
} elseif (!is_file($savePath)) {
|
||||
throw new Exception('Cannot persist migration from missing read snapshot');
|
||||
}
|
||||
$pdo->exec('PRAGMA wal_checkpoint(FULL);');
|
||||
qdb_save($savePath, $migrateLock);
|
||||
if ($savePath !== $sqlitePath && is_file($savePath)) {
|
||||
@unlink($savePath);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
flock($migrateLock, LOCK_UN);
|
||||
fclose($migrateLock);
|
||||
throw $e;
|
||||
}
|
||||
return qdb_read_db_open()[0];
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PDO|null
|
||||
*/
|
||||
function qdb_read_cache_get(string $signature) {
|
||||
$state = &qdb_read_cache_state();
|
||||
if ($state['signature'] === $signature && $state['pdo'] instanceof PDO) {
|
||||
return $state['pdo'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function qdb_read_cache_store(string $signature, PDO $pdo, string $memfdPath, int $memfdFd): void {
|
||||
qdb_read_cache_reset();
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = $signature;
|
||||
$state['pdo'] = $pdo;
|
||||
$state['memfdPath'] = $memfdPath;
|
||||
$state['memfdFd'] = $memfdFd;
|
||||
}
|
||||
|
||||
function qdb_read_cache_reset(): void {
|
||||
$state = &qdb_read_cache_state();
|
||||
$state['signature'] = '';
|
||||
$state['pdo'] = null;
|
||||
if ($state['memfdFd'] >= 0) {
|
||||
qdb_close_fd($state['memfdFd']);
|
||||
}
|
||||
$state['memfdFd'] = -1;
|
||||
if ($state['memfdPath'] !== '' && is_file($state['memfdPath'])) {
|
||||
@unlink($state['memfdPath']);
|
||||
}
|
||||
$state['memfdPath'] = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{signature: string, pdo: ?PDO, memfdPath: string, memfdFd: int}
|
||||
*/
|
||||
function &qdb_read_cache_state(): array {
|
||||
static $state = [
|
||||
'signature' => '',
|
||||
'pdo' => null,
|
||||
'memfdPath' => '',
|
||||
'memfdFd' => -1,
|
||||
];
|
||||
return $state;
|
||||
}
|
||||
122
lib/response.php
Normal file
122
lib/response.php
Normal file
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/encrypted_payload.php';
|
||||
|
||||
/** @internal PHPUnit: inject request body (cleared by qdb_test_reset_http_state). */
|
||||
function qdb_test_set_request_body(string $body): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
throw new RuntimeException('qdb_test_set_request_body is only available in tests');
|
||||
}
|
||||
$GLOBALS['qdb_test_request_body'] = $body;
|
||||
}
|
||||
|
||||
/** @internal PHPUnit: reset cached body between API calls. */
|
||||
function qdb_test_reset_http_state(): void
|
||||
{
|
||||
if (!defined('QDB_TESTING')) {
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['qdb_test_request_body']);
|
||||
}
|
||||
|
||||
/** @internal PHPUnit: rethrow response control-flow exceptions. */
|
||||
function qdb_test_is_control_flow_exception(\Throwable $e): bool
|
||||
{
|
||||
return $e instanceof \Tests\Support\JsonSuccessResponse
|
||||
|| $e instanceof \Tests\Support\JsonErrorResponse
|
||||
|| $e instanceof \Tests\Support\RawHttpResponse;
|
||||
}
|
||||
|
||||
function json_success(mixed $data, int $status = 200): never {
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\JsonSuccessResponse($data, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
echo json_encode(["ok" => true, "data" => $data]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $details Optional structured payload (e.g. validation error list).
|
||||
*/
|
||||
/**
|
||||
* Send a non-JSON download response (CSV, ZIP, etc.).
|
||||
*
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
function qdb_http_download(string $body, array $headers, int $status = 200): never
|
||||
{
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\RawHttpResponse($body, $headers, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value);
|
||||
}
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_error(string $code, string $message, int $status = 400, mixed $details = null): never {
|
||||
if (defined('QDB_TESTING')) {
|
||||
throw new \Tests\Support\JsonErrorResponse($code, $message, $status, $details);
|
||||
}
|
||||
if (function_exists('qdb_api_log_note_api_error')) {
|
||||
qdb_api_log_note_api_error($code, $message, $status);
|
||||
}
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=UTF-8');
|
||||
$error = ["code" => $code, "message" => $message];
|
||||
if ($details !== null) {
|
||||
$error['details'] = $details;
|
||||
}
|
||||
echo json_encode(["ok" => false, "error" => $error]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** Raw request body (cached; php://input is single-read). */
|
||||
function qdb_raw_request_body(): string {
|
||||
if (defined('QDB_TESTING') && array_key_exists('qdb_test_request_body', $GLOBALS)) {
|
||||
return (string)$GLOBALS['qdb_test_request_body'];
|
||||
}
|
||||
static $raw = null;
|
||||
if ($raw === null) {
|
||||
$read = file_get_contents('php://input');
|
||||
$raw = $read === false ? '' : $read;
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
function read_json_body(): array {
|
||||
$raw = qdb_raw_request_body();
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_error('INVALID_BODY', 'Request body must be valid JSON', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function read_encrypted_json_body(string $tokenHex): array {
|
||||
$outer = read_json_body();
|
||||
if (empty($outer['encrypted'])) {
|
||||
json_error('ENCRYPTION_REQUIRED', 'Sensitive requests must send an encrypted payload', 400);
|
||||
}
|
||||
try {
|
||||
$plain = qdb_decrypt_sensitive_envelope($outer, $tokenHex);
|
||||
} catch (Throwable $e) {
|
||||
error_log('decrypt body failed: ' . $e->getMessage());
|
||||
json_error('DECRYPT_FAILED', 'Could not decrypt request payload', 400);
|
||||
}
|
||||
$data = json_decode($plain, true);
|
||||
if (!is_array($data)) {
|
||||
json_error('INVALID_BODY', 'Decrypted body must be valid JSON object', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function json_success_sensitive(mixed $data, string $tokenHex, int $status = 200): never {
|
||||
$plain = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
json_success(qdb_sensitive_envelope($plain, $tokenHex), $status);
|
||||
}
|
||||
784
lib/scoring.php
Normal file
784
lib/scoring.php
Normal file
@ -0,0 +1,784 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire scoring engine and scoring-profile aggregation.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/app_answers.php';
|
||||
|
||||
/** Default glass-scale level keys and implicit points (0–4). */
|
||||
function qdb_glass_scale_level_keys(): array {
|
||||
return ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||
}
|
||||
|
||||
function qdb_default_glass_level_points(): array {
|
||||
return [
|
||||
'never_glass' => 0,
|
||||
'little_glass' => 1,
|
||||
'moderate_glass' => 2,
|
||||
'much_glass' => 3,
|
||||
'extreme_glass' => 4,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_make_score_rule_id(string $questionID, string $scopeKey, string $levelKey): string {
|
||||
return substr(hash('sha256', $questionID . "\0" . $scopeKey . "\0" . $levelKey), 0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_get_score_rules_for_question(PDO $pdo, string $questionID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT scopeKey, levelKey, points FROM question_score_rule WHERE questionID = :qid ORDER BY scopeKey, levelKey'
|
||||
);
|
||||
$stmt->execute([':qid' => $questionID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'scopeKey' => (string)($r['scopeKey'] ?? ''),
|
||||
'levelKey' => (string)($r['levelKey'] ?? ''),
|
||||
'points' => (int)($r['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{scopeKey?: string, levelKey: string, points: int}> $rules
|
||||
*/
|
||||
function qdb_sync_question_score_rules(PDO $pdo, string $questionID, array $rules): void {
|
||||
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :qid')
|
||||
->execute([':qid' => $questionID]);
|
||||
if ($rules === []) {
|
||||
return;
|
||||
}
|
||||
$ins = $pdo->prepare(
|
||||
'INSERT INTO question_score_rule (ruleID, questionID, scopeKey, levelKey, points)
|
||||
VALUES (:rid, :qid, :sk, :lk, :pts)'
|
||||
);
|
||||
foreach ($rules as $rule) {
|
||||
$scopeKey = trim((string)($rule['scopeKey'] ?? ''));
|
||||
$levelKey = trim((string)($rule['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$ins->execute([
|
||||
':rid' => qdb_make_score_rule_id($questionID, $scopeKey, $levelKey),
|
||||
':qid' => $questionID,
|
||||
':sk' => $scopeKey,
|
||||
':lk' => $levelKey,
|
||||
':pts' => (int)($rule['points'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested map: scopeKey -> levelKey -> points.
|
||||
*
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
function qdb_score_rule_map_for_question(PDO $pdo, string $questionID): array {
|
||||
$map = [];
|
||||
foreach (qdb_get_score_rules_for_question($pdo, $questionID) as $rule) {
|
||||
$map[$rule['scopeKey']][$rule['levelKey']] = $rule['points'];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach scoreRules to glass symptom rows for editor API.
|
||||
*
|
||||
* @return list<array{key: string, labelDe: string, scoreLevels: array<string, int>}>
|
||||
*/
|
||||
function qdb_glass_symptoms_with_score_rules(PDO $pdo, string $questionID, array $config): array {
|
||||
$ruleMap = $questionID !== '' ? qdb_score_rule_map_for_question($pdo, $questionID) : [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$rows = [];
|
||||
foreach (qdb_glass_symptoms_with_labels($pdo, $config) as $row) {
|
||||
$key = $row['key'];
|
||||
$levels = [];
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$levels[$levelKey] = $ruleMap[$key][$levelKey]
|
||||
?? $ruleMap[''][$levelKey]
|
||||
?? $defaults[$levelKey]
|
||||
?? 0;
|
||||
}
|
||||
$rows[] = [
|
||||
'key' => $key,
|
||||
'labelDe' => $row['labelDe'],
|
||||
'scoreLevels' => $levels,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scoreRules from question save body.
|
||||
*
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_parse_score_rules_body(array $body, string $questionType, array $config): array {
|
||||
if (!isset($body['scoreRules']) || !is_array($body['scoreRules'])) {
|
||||
if ($questionType === 'glass_scale_question' && isset($body['glassSymptoms']) && is_array($body['glassSymptoms'])) {
|
||||
return qdb_score_rules_from_glass_symptoms($body['glassSymptoms']);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
$rules = [];
|
||||
foreach ($body['scoreRules'] as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$levelKey = trim((string)($row['levelKey'] ?? ''));
|
||||
if ($levelKey === '') {
|
||||
continue;
|
||||
}
|
||||
$rules[] = [
|
||||
'scopeKey' => trim((string)($row['scopeKey'] ?? '')),
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($row['points'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{key?: string, scoreLevels?: array<string, int>}> $glassSymptoms
|
||||
* @return list<array{scopeKey: string, levelKey: string, points: int}>
|
||||
*/
|
||||
function qdb_score_rules_from_glass_symptoms(array $glassSymptoms): array {
|
||||
$rules = [];
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
foreach ($glassSymptoms as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$scopeKey = trim((string)($row['key'] ?? ''));
|
||||
if ($scopeKey === '') {
|
||||
continue;
|
||||
}
|
||||
$levels = is_array($row['scoreLevels'] ?? null) ? $row['scoreLevels'] : $defaults;
|
||||
foreach (qdb_glass_scale_level_keys() as $levelKey) {
|
||||
$rules[] = [
|
||||
'scopeKey' => $scopeKey,
|
||||
'levelKey' => $levelKey,
|
||||
'points' => (int)($levels[$levelKey] ?? $defaults[$levelKey] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
function qdb_normalize_scoring_bands(array $row): array {
|
||||
$greenMax = (int)($row['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($row['yellowMax'] ?? 36);
|
||||
return [
|
||||
'greenMin' => (int)($row['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($row['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($row['redMin'] ?? ($yellowMax + 1)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null Error message, or null if valid.
|
||||
*/
|
||||
function qdb_validate_scoring_bands(array $bands): ?string {
|
||||
if ($bands['greenMin'] > $bands['greenMax']) {
|
||||
return 'Green min must not exceed green max';
|
||||
}
|
||||
if ($bands['yellowMin'] > $bands['yellowMax']) {
|
||||
return 'Yellow min must not exceed yellow max';
|
||||
}
|
||||
if ($bands['greenMax'] >= $bands['yellowMin']) {
|
||||
return 'Green range must end before yellow range starts';
|
||||
}
|
||||
if ($bands['yellowMax'] >= $bands['redMin']) {
|
||||
return 'Yellow range must end before red range starts';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int>|int $bandsOrGreenMax Band map or legacy greenMax
|
||||
*/
|
||||
function qdb_band_for_total(float $total, array|int $bandsOrGreenMax, ?int $yellowMax = null): string {
|
||||
$bands = is_array($bandsOrGreenMax)
|
||||
? qdb_normalize_scoring_bands($bandsOrGreenMax)
|
||||
: qdb_normalize_scoring_bands(['greenMax' => $bandsOrGreenMax, 'yellowMax' => $yellowMax ?? 36]);
|
||||
|
||||
if ($total >= $bands['greenMin'] && $total <= $bands['greenMax']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total >= $bands['yellowMin'] && $total <= $bands['yellowMax']) {
|
||||
return 'yellow';
|
||||
}
|
||||
if ($total >= $bands['redMin']) {
|
||||
return 'red';
|
||||
}
|
||||
if ($total < $bands['greenMin']) {
|
||||
return 'green';
|
||||
}
|
||||
if ($total < $bands['yellowMin']) {
|
||||
return 'yellow';
|
||||
}
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function qdb_parse_scoring_bands_body(array $body): array {
|
||||
$greenMax = (int)($body['greenMax'] ?? 12);
|
||||
$yellowMax = (int)($body['yellowMax'] ?? 36);
|
||||
return qdb_normalize_scoring_bands([
|
||||
'greenMin' => (int)($body['greenMin'] ?? 0),
|
||||
'greenMax' => $greenMax,
|
||||
'yellowMin' => (int)($body['yellowMin'] ?? ($greenMax + 1)),
|
||||
'yellowMax' => $yellowMax,
|
||||
'redMin' => (int)($body['redMin'] ?? ($yellowMax + 1)),
|
||||
]);
|
||||
}
|
||||
|
||||
function qdb_scoring_bands_to_api(array $row): array {
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
return $bands;
|
||||
}
|
||||
|
||||
function qdb_compute_questionnaire_score(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn ORDER BY orderIndex'
|
||||
);
|
||||
$qStmt->execute([':qn' => $questionnaireID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$aoStmt = $pdo->prepare(
|
||||
'SELECT ao.questionID, ao.defaultText, ao.points
|
||||
FROM answer_option ao
|
||||
JOIN question q ON q.questionID = ao.questionID
|
||||
WHERE q.questionnaireID = :qn'
|
||||
);
|
||||
$aoStmt->execute([':qn' => $questionnaireID]);
|
||||
$optionPoints = [];
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionPoints[$ao['questionID']][$ao['defaultText']] = (int)$ao['points'];
|
||||
}
|
||||
|
||||
$aStmt = $pdo->prepare(
|
||||
'SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue,
|
||||
ao.defaultText AS optionKey
|
||||
FROM client_answer ca
|
||||
LEFT JOIN answer_option ao ON ao.answerOptionID = ca.answerOptionID
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn'
|
||||
);
|
||||
$aStmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
$answersByQuestion = [];
|
||||
foreach ($aStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answersByQuestion[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($questions as $qRow) {
|
||||
$total += qdb_score_points_for_question(
|
||||
$pdo,
|
||||
$qRow,
|
||||
$answersByQuestion[$qRow['questionID']] ?? null,
|
||||
$optionPoints[$qRow['questionID']] ?? []
|
||||
);
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function qdb_score_points_for_question(
|
||||
PDO $pdo,
|
||||
array $questionRow,
|
||||
?array $answerRow,
|
||||
array $optionPointsMap,
|
||||
): int {
|
||||
if ($answerRow === null) {
|
||||
return 0;
|
||||
}
|
||||
$type = (string)($questionRow['type'] ?? '');
|
||||
$questionID = (string)($questionRow['questionID'] ?? '');
|
||||
$config = json_decode($questionRow['configJson'] ?? '{}', true) ?: [];
|
||||
$ruleMap = qdb_score_rule_map_for_question($pdo, $questionID);
|
||||
|
||||
switch ($type) {
|
||||
case 'radio_question':
|
||||
case 'string_spinner':
|
||||
$key = trim((string)($answerRow['optionKey'] ?? $answerRow['freeTextValue'] ?? ''));
|
||||
return $key !== '' ? (int)($optionPointsMap[$key] ?? 0) : 0;
|
||||
|
||||
case 'multi_check_box_question':
|
||||
$raw = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($raw === '') {
|
||||
return 0;
|
||||
}
|
||||
$sum = 0;
|
||||
foreach (qdb_decode_multi_check_values($raw) as $mk) {
|
||||
$sum += (int)($optionPointsMap[$mk] ?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'glass_scale_question':
|
||||
$json = trim((string)($answerRow['freeTextValue'] ?? ''));
|
||||
if ($json === '') {
|
||||
return 0;
|
||||
}
|
||||
$decoded = json_decode($json, true);
|
||||
if (!is_array($decoded)) {
|
||||
return 0;
|
||||
}
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
$sum = 0;
|
||||
foreach ($decoded as $symptomKey => $levelKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
$lk = trim((string)$levelKey);
|
||||
if ($sk === '' || $lk === '') {
|
||||
continue;
|
||||
}
|
||||
$sum += (int)($ruleMap[$sk][$lk]
|
||||
?? $ruleMap[''][$lk]
|
||||
?? $defaults[$lk]
|
||||
?? 0);
|
||||
}
|
||||
return $sum;
|
||||
|
||||
case 'slider_question':
|
||||
case 'value_spinner':
|
||||
$value = $answerRow['numericValue'];
|
||||
if ($value === null || $value === '') {
|
||||
$value = $answerRow['freeTextValue'];
|
||||
}
|
||||
if ($value === null || $value === '') {
|
||||
return 0;
|
||||
}
|
||||
$levelKey = is_numeric($value)
|
||||
? (string)(int)round((float)$value)
|
||||
: trim((string)$value);
|
||||
return (int)($ruleMap[''][$levelKey] ?? 0);
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_recompute_profile_scores_for_client(PDO $pdo, string $clientCode, ?string $triggerQuestionnaireID = null): void {
|
||||
$profileSql = 'SELECT profileID, name, greenMin, greenMax, yellowMin, yellowMax, redMin FROM scoring_profile WHERE isActive = 1';
|
||||
$params = [];
|
||||
if ($triggerQuestionnaireID !== null && $triggerQuestionnaireID !== '') {
|
||||
$profileSql = '
|
||||
SELECT sp.profileID, sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||
FROM scoring_profile sp
|
||||
JOIN scoring_profile_questionnaire spq ON spq.profileID = sp.profileID
|
||||
WHERE sp.isActive = 1 AND spq.questionnaireID = :qn';
|
||||
$params[':qn'] = $triggerQuestionnaireID;
|
||||
}
|
||||
$pStmt = $pdo->prepare($profileSql);
|
||||
$pStmt->execute($params);
|
||||
$profiles = $pStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$deleteOrphan = $pdo->prepare(
|
||||
'DELETE FROM client_scoring_profile_result WHERE clientCode = :cc AND profileID = :pid'
|
||||
);
|
||||
$upsert = $pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap)
|
||||
ON CONFLICT(clientCode, profileID) DO UPDATE SET
|
||||
weightedTotal = excluded.weightedTotal,
|
||||
band = excluded.band,
|
||||
computedAt = excluded.computedAt,
|
||||
questionnaireSnapshot = excluded.questionnaireSnapshot'
|
||||
);
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profileID = $profile['profileID'];
|
||||
$members = qdb_scoring_profile_members($pdo, $profileID);
|
||||
if ($members === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshot = [];
|
||||
$weightedTotal = 0.0;
|
||||
$allComplete = true;
|
||||
|
||||
foreach ($members as $member) {
|
||||
$qnID = $member['questionnaireID'];
|
||||
$weight = (float)$member['weight'];
|
||||
$cq = $pdo->prepare(
|
||||
"SELECT status, sumPoints FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn"
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$row = $cq->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || ($row['status'] ?? '') !== 'completed') {
|
||||
$allComplete = false;
|
||||
break;
|
||||
}
|
||||
$sumPoints = (int)($row['sumPoints'] ?? 0);
|
||||
$contribution = $sumPoints * $weight;
|
||||
$weightedTotal += $contribution;
|
||||
$snapshot[$qnID] = [
|
||||
'sumPoints' => $sumPoints,
|
||||
'weight' => $weight,
|
||||
'contribution' => round($contribution, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$allComplete) {
|
||||
$deleteOrphan->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$band = qdb_band_for_total($weightedTotal, qdb_normalize_scoring_bands($profile));
|
||||
$upsert->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $band,
|
||||
':at' => time(),
|
||||
':snap' => json_encode($snapshot, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{questionnaireID: string, weight: float, orderIndex: int, name?: string}>
|
||||
*/
|
||||
function qdb_scoring_profile_members(PDO $pdo, string $profileID): array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT spq.questionnaireID, spq.weight, spq.orderIndex, q.name
|
||||
FROM scoring_profile_questionnaire spq
|
||||
JOIN questionnaire q ON q.questionnaireID = spq.questionnaireID
|
||||
WHERE spq.profileID = :pid
|
||||
ORDER BY spq.orderIndex, spq.questionnaireID'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$rows = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$rows[] = [
|
||||
'questionnaireID' => $r['questionnaireID'],
|
||||
'weight' => (float)$r['weight'],
|
||||
'orderIndex' => (int)$r['orderIndex'],
|
||||
'name' => $r['name'] ?? '',
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
function qdb_list_scoring_profiles(PDO $pdo): array {
|
||||
$stmt = $pdo->query(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
createdAt, updatedAt
|
||||
FROM scoring_profile ORDER BY name'
|
||||
);
|
||||
$profiles = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
$row = array_merge($row, $bands);
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $row['profileID']);
|
||||
$profiles[] = $row;
|
||||
}
|
||||
return $profiles;
|
||||
}
|
||||
|
||||
function qdb_get_scoring_profile(PDO $pdo, string $profileID): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin,
|
||||
createdAt, updatedAt
|
||||
FROM scoring_profile WHERE profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$row['isActive'] = (int)$row['isActive'];
|
||||
$row = array_merge($row, qdb_normalize_scoring_bands($row));
|
||||
$row['questionnaires'] = qdb_scoring_profile_members($pdo, $profileID);
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill default glass score rules (0–4) for all glass questions missing rules.
|
||||
*/
|
||||
function qdb_backfill_glass_score_rules(PDO $pdo): int {
|
||||
$stmt = $pdo->query("SELECT questionID, configJson FROM question WHERE type = 'glass_scale_question'");
|
||||
$count = 0;
|
||||
$defaults = qdb_default_glass_level_points();
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$existing = qdb_get_score_rules_for_question($pdo, $row['questionID']);
|
||||
if ($existing !== []) {
|
||||
continue;
|
||||
}
|
||||
$config = json_decode($row['configJson'] ?? '{}', true) ?: [];
|
||||
$rules = [];
|
||||
foreach ($config['symptoms'] ?? [] as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($defaults as $levelKey => $pts) {
|
||||
$rules[] = ['scopeKey' => $sk, 'levelKey' => $levelKey, 'points' => $pts];
|
||||
}
|
||||
}
|
||||
if ($rules !== []) {
|
||||
qdb_sync_question_score_rules($pdo, $row['questionID'], $rules);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute questionnaire sumPoints and scoring-profile results for specific clients.
|
||||
*
|
||||
* @param list<string> $clientCodes
|
||||
*/
|
||||
function qdb_recompute_scores_for_clients(PDO $pdo, array $clientCodes): int {
|
||||
$clientCodes = array_values(array_unique(array_filter(
|
||||
$clientCodes,
|
||||
static fn($cc) => is_string($cc) && trim($cc) !== ''
|
||||
)));
|
||||
if ($clientCodes === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$n = 0;
|
||||
$qnStmt = $pdo->prepare(
|
||||
"SELECT questionnaireID FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND status = 'completed'"
|
||||
);
|
||||
$upd = $pdo->prepare(
|
||||
'UPDATE completed_questionnaire SET sumPoints = :sp WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
foreach ($clientCodes as $clientCode) {
|
||||
$qnStmt->execute([':cc' => $clientCode]);
|
||||
foreach ($qnStmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
|
||||
$score = qdb_compute_questionnaire_score($pdo, $clientCode, (string)$qnID);
|
||||
$upd->execute([
|
||||
':sp' => $score,
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
]);
|
||||
$n++;
|
||||
}
|
||||
qdb_recompute_profile_scores_for_client($pdo, $clientCode);
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
function qdb_recompute_all_questionnaire_scores(PDO $pdo): int {
|
||||
$stmt = $pdo->query(
|
||||
"SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = 'completed'"
|
||||
);
|
||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
return qdb_recompute_scores_for_clients($pdo, $clientCodes);
|
||||
}
|
||||
|
||||
function qdb_seed_default_rhs_scoring_profile(PDO $pdo): ?string {
|
||||
foreach (qdb_list_scoring_profiles($pdo) as $p) {
|
||||
if ($p['name'] === 'RHS Index') {
|
||||
return $p['profileID'];
|
||||
}
|
||||
}
|
||||
$rhsId = 'questionnaire_2_rhs';
|
||||
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
|
||||
$chk->execute([':id' => $rhsId]);
|
||||
if (!$chk->fetch()) {
|
||||
return null;
|
||||
}
|
||||
$profileID = bin2hex(random_bytes(16));
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile (profileID, name, description, isActive,
|
||||
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
|
||||
VALUES (:id, :name, :desc, 1, 0, 12, 13, 36, 37, :ca, :ua)'
|
||||
)->execute([
|
||||
':id' => $profileID,
|
||||
':name' => 'RHS Index',
|
||||
':desc' => 'Legacy RHS thresholds (green 0–12, yellow 13–36, red 37+)',
|
||||
':ca' => $now,
|
||||
':ua' => $now,
|
||||
]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
|
||||
VALUES (:pid, :qn, 1.0, 0)'
|
||||
)->execute([':pid' => $profileID, ':qn' => $rhsId]);
|
||||
return $profileID;
|
||||
}
|
||||
|
||||
function qdb_is_valid_scoring_band(?string $band): bool {
|
||||
return in_array($band, ['green', 'yellow', 'red'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coach decision when set; otherwise the server-computed band.
|
||||
*/
|
||||
function qdb_effective_scoring_band(array $row): string {
|
||||
$coach = trim((string)($row['coachBand'] ?? ''));
|
||||
if ($coach !== '' && qdb_is_valid_scoring_band($coach)) {
|
||||
return $coach;
|
||||
}
|
||||
return (string)($row['band'] ?? '');
|
||||
}
|
||||
|
||||
function qdb_scoring_review_row_to_api(array $row, bool $includeComputed = true): array {
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$out = [
|
||||
'profileID' => (string)($row['profileID'] ?? ''),
|
||||
'name' => (string)($row['name'] ?? ''),
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'pendingReview' => $coachBand === '',
|
||||
];
|
||||
if ($includeComputed) {
|
||||
$out['weightedTotal'] = (float)($row['weightedTotal'] ?? 0);
|
||||
$out['calculatedBand'] = (string)($row['band'] ?? '');
|
||||
$out['computedAt'] = !empty($row['computedAt']) ? date('Y-m-d H:i', (int)$row['computedAt']) : '';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $clientCodes
|
||||
* @return list<array{clientCode: string, profiles: list<array<string, mixed>>}>
|
||||
*/
|
||||
function qdb_app_scoring_review_for_clients(PDO $pdo, array $clientCodes): array {
|
||||
if ($clientCodes === []) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.coachBand, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
WHERE r.clientCode IN ($placeholders)
|
||||
ORDER BY r.clientCode, sp.name"
|
||||
);
|
||||
$stmt->execute(array_values($clientCodes));
|
||||
$byClient = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$byClient[$cc][] = qdb_scoring_review_row_to_api($row, false);
|
||||
}
|
||||
$out = [];
|
||||
foreach ($clientCodes as $code) {
|
||||
$out[] = [
|
||||
'clientCode' => $code,
|
||||
'profiles' => $byClient[$code] ?? [],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* Store coach band; create a result row from app-local computed values when none exists yet.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function qdb_save_coach_scoring_review(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID,
|
||||
?float $weightedTotal = null,
|
||||
?string $calculatedBand = null,
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
if ($weightedTotal === null || $calculatedBand === null || !qdb_is_valid_scoring_band($calculatedBand)) {
|
||||
throw new RuntimeException('Scoring profile result not found; calculatedBand and weightedTotal required');
|
||||
}
|
||||
$profile = qdb_get_scoring_profile($pdo, $profileID);
|
||||
if (!$profile || (int)($profile['isActive'] ?? 0) !== 1) {
|
||||
throw new RuntimeException('Scoring profile not found');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO client_scoring_profile_result
|
||||
(clientCode, profileID, weightedTotal, band, computedAt, questionnaireSnapshot,
|
||||
coachBand, coachReviewedAt, coachReviewedByUserID)
|
||||
VALUES (:cc, :pid, :wt, :band, :at, :snap, :cb, :rat, :uid)'
|
||||
)->execute([
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
':wt' => round($weightedTotal, 4),
|
||||
':band' => $calculatedBand,
|
||||
':at' => $now,
|
||||
':snap' => '{}',
|
||||
':cb' => $coachBand,
|
||||
':rat' => $now,
|
||||
':uid' => $userID,
|
||||
]);
|
||||
return qdb_scoring_review_row_to_api([
|
||||
'profileID' => $profileID,
|
||||
'name' => $profile['name'],
|
||||
'weightedTotal' => $weightedTotal,
|
||||
'band' => $calculatedBand,
|
||||
'coachBand' => $coachBand,
|
||||
'computedAt' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
return qdb_set_coach_scoring_band($pdo, $clientCode, $profileID, $coachBand, $userID);
|
||||
}
|
||||
|
||||
function qdb_set_coach_scoring_band(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $profileID,
|
||||
string $coachBand,
|
||||
string $userID
|
||||
): array {
|
||||
if (!qdb_is_valid_scoring_band($coachBand)) {
|
||||
throw new InvalidArgumentException('coachBand must be green, yellow, or red');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.band, r.coachBand, r.weightedTotal, r.computedAt, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc AND r.profileID = :pid'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':pid' => $profileID]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Scoring profile result not found or profile incomplete');
|
||||
}
|
||||
$now = time();
|
||||
$pdo->prepare(
|
||||
'UPDATE client_scoring_profile_result
|
||||
SET coachBand = :cb, coachReviewedAt = :at, coachReviewedByUserID = :uid
|
||||
WHERE clientCode = :cc AND profileID = :pid'
|
||||
)->execute([
|
||||
':cb' => $coachBand,
|
||||
':at' => $now,
|
||||
':uid' => $userID,
|
||||
':cc' => $clientCode,
|
||||
':pid' => $profileID,
|
||||
]);
|
||||
$row['coachBand'] = $coachBand;
|
||||
$row['profileID'] = $profileID;
|
||||
return qdb_scoring_review_row_to_api($row);
|
||||
}
|
||||
114
lib/settings.php
Normal file
114
lib/settings.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persisted security settings (system_setting table in encrypted DB).
|
||||
*/
|
||||
|
||||
function qdb_settings_defaults(): array
|
||||
{
|
||||
return [
|
||||
'login_max_attempts' => 10,
|
||||
'login_window_seconds' => 900,
|
||||
'login_lockout_seconds' => 900,
|
||||
'session_ttl_seconds' => 30 * 24 * 60 * 60,
|
||||
'temp_session_ttl_seconds' => 10 * 60,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_settings_keys(): array
|
||||
{
|
||||
return array_keys(qdb_settings_defaults());
|
||||
}
|
||||
|
||||
function qdb_ensure_system_setting_table(PDO $pdo): void
|
||||
{
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_settings_get_on_pdo(PDO $pdo): array
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$out = qdb_settings_defaults();
|
||||
$rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string)($row['settingKey'] ?? '');
|
||||
if ($key === '' || !array_key_exists($key, $out)) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = (int)$row['settingValue'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_settings_get(): array
|
||||
{
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
try {
|
||||
return qdb_settings_get_on_pdo($pdo);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $partial
|
||||
* @param array<string, int>|null $base existing settings; defaults used when null
|
||||
* @return array<string, int> saved settings
|
||||
*/
|
||||
function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array
|
||||
{
|
||||
$merged = $base ?? qdb_settings_defaults();
|
||||
$limits = [
|
||||
'login_max_attempts' => [1, 100],
|
||||
'login_window_seconds' => [60, 86400],
|
||||
'login_lockout_seconds' => [60, 86400],
|
||||
'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60],
|
||||
'temp_session_ttl_seconds' => [300, 24 * 60 * 60],
|
||||
];
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
if (!array_key_exists($key, $partial)) {
|
||||
continue;
|
||||
}
|
||||
$val = (int)$partial[$key];
|
||||
[$min, $max] = $limits[$key];
|
||||
if ($val < $min || $val > $max) {
|
||||
throw new InvalidArgumentException(
|
||||
"$key must be between $min and $max"
|
||||
);
|
||||
}
|
||||
$merged[$key] = $val;
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $settings full merged settings to persist
|
||||
*/
|
||||
function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_setting (settingKey, settingValue)
|
||||
VALUES (:k, :v)
|
||||
ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue'
|
||||
);
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
$stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_session_ttl_seconds(?array $settings = null, bool $temp = false): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
return $temp
|
||||
? (int)$settings['temp_session_ttl_seconds']
|
||||
: (int)$settings['session_ttl_seconds'];
|
||||
}
|
||||
999
lib/submissions.php
Normal file
999
lib/submissions.php
Normal file
@ -0,0 +1,999 @@
|
||||
<?php
|
||||
/**
|
||||
* Questionnaire submission versioning (archive each coach upload).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/questionnaire_structure.php';
|
||||
|
||||
/**
|
||||
* Build display columns from a stored structure manifest (historical submissions).
|
||||
*
|
||||
* @return array{questions: array, resultColumns: array, optionTextMap: array, stringLabelCache: array}
|
||||
*/
|
||||
function qdb_display_context_from_manifest(PDO $pdo, array $manifest): array {
|
||||
$stringLabelCache = [];
|
||||
$resultColumns = [];
|
||||
$questions = [];
|
||||
$optionTextMap = [];
|
||||
|
||||
foreach ($manifest['questions'] ?? [] as $qEntry) {
|
||||
if (!is_array($qEntry)) {
|
||||
continue;
|
||||
}
|
||||
$fullId = (string)($qEntry['questionID'] ?? '');
|
||||
if ($fullId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionID = :id'
|
||||
);
|
||||
$qStmt->execute([':id' => $fullId]);
|
||||
$qRow = $qStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$qRow) {
|
||||
$symptoms = $qEntry['symptoms'] ?? [];
|
||||
$qRow = [
|
||||
'questionID' => $fullId,
|
||||
'defaultText' => (string)($qEntry['questionKey'] ?? $qEntry['shortId'] ?? $fullId),
|
||||
'type' => (string)($qEntry['type'] ?? ''),
|
||||
'orderIndex' => 0,
|
||||
'configJson' => json_encode(
|
||||
['symptoms' => is_array($symptoms) ? $symptoms : []],
|
||||
JSON_UNESCAPED_UNICODE
|
||||
),
|
||||
];
|
||||
}
|
||||
$questions[] = $qRow;
|
||||
|
||||
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
||||
$type = $qRow['type'] ?? '';
|
||||
if ($type === 'glass_scale_question') {
|
||||
$symptoms = $cfg['symptoms'] ?? $qEntry['symptoms'] ?? [];
|
||||
if (is_array($symptoms) && $symptoms !== []) {
|
||||
foreach ($symptoms as $symptomKey) {
|
||||
$sk = trim((string)$symptomKey);
|
||||
if ($sk === '') {
|
||||
continue;
|
||||
}
|
||||
$resultColumns[] = [
|
||||
'header' => qdb_string_german_label($pdo, $sk, $stringLabelCache),
|
||||
'questionID' => $fullId,
|
||||
'symptomKey' => $sk,
|
||||
'kind' => 'glass_symptom',
|
||||
'question' => $qRow,
|
||||
];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$resultColumns[] = [
|
||||
'header' => qdb_question_german_label($pdo, $qRow),
|
||||
'questionID' => $fullId,
|
||||
'symptomKey' => null,
|
||||
'kind' => 'question',
|
||||
'question' => $qRow,
|
||||
];
|
||||
|
||||
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||
$aoStmt->execute([':qid' => $fullId]);
|
||||
$dbOptions = $aoStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($dbOptions !== []) {
|
||||
foreach ($dbOptions as $ao) {
|
||||
$optionTextMap[$ao['answerOptionID']] = qdb_option_german_label(
|
||||
$pdo,
|
||||
$ao['answerOptionID'],
|
||||
$ao['defaultText']
|
||||
);
|
||||
}
|
||||
} else {
|
||||
foreach ($qEntry['options'] ?? [] as $opt) {
|
||||
if (!is_array($opt)) {
|
||||
continue;
|
||||
}
|
||||
$key = (string)($opt['key'] ?? '');
|
||||
if ($key !== '') {
|
||||
$optionTextMap[$key] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'questions' => $questions,
|
||||
'resultColumns' => $resultColumns,
|
||||
'optionTextMap' => $optionTextMap,
|
||||
'stringLabelCache' => $stringLabelCache,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_next_submission_version(PDO $pdo, string $clientCode, string $questionnaireID): int {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COALESCE(MAX(version), 0) + 1 FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
return (int)$stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot live answers + completion metadata after a successful submit.
|
||||
*/
|
||||
function qdb_record_submission_after_submit(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
string $questionnaireID,
|
||||
array $tokenRec,
|
||||
?int $startedAt,
|
||||
?int $completedAt,
|
||||
int $sumPoints,
|
||||
string $assignedByCoach,
|
||||
int $structureRevision = 1,
|
||||
?array $structureManifest = null,
|
||||
?array $questionIdsToCopy = null,
|
||||
?int $submittedAt = null
|
||||
): string {
|
||||
$version = qdb_next_submission_version($pdo, $clientCode, $questionnaireID);
|
||||
$submissionID = bin2hex(random_bytes(16));
|
||||
$submittedAtValue = $submittedAt ?? $completedAt ?? time();
|
||||
$snapshotJson = '{}';
|
||||
if ($structureManifest !== null) {
|
||||
$encoded = json_encode($structureManifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded !== false) {
|
||||
$snapshotJson = $encoded;
|
||||
}
|
||||
}
|
||||
|
||||
$cq = $pdo->prepare(
|
||||
'SELECT status FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$cq->execute([':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
$status = $cq->fetchColumn() ?: 'completed';
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire_submission (
|
||||
submissionID, clientCode, questionnaireID, version, submittedAt,
|
||||
submittedByUserID, submittedByRole, assignedByCoach,
|
||||
status, startedAt, completedAt, sumPoints,
|
||||
structureRevision, structureSnapshotJson
|
||||
) VALUES (
|
||||
:sid, :cc, :qn, :ver, :sat,
|
||||
:uid, :role, :abc,
|
||||
:st, :sa, :ca, :sp,
|
||||
:srev, :snap
|
||||
)'
|
||||
)->execute([
|
||||
':sid' => $submissionID,
|
||||
':cc' => $clientCode,
|
||||
':qn' => $questionnaireID,
|
||||
':ver' => $version,
|
||||
':sat' => $submittedAtValue,
|
||||
':uid' => $tokenRec['userID'] ?? '',
|
||||
':role' => $tokenRec['role'] ?? '',
|
||||
':abc' => $assignedByCoach !== '' ? $assignedByCoach : null,
|
||||
':st' => $status,
|
||||
':sa' => $startedAt,
|
||||
':ca' => $completedAt,
|
||||
':sp' => $sumPoints,
|
||||
':srev' => max(1, $structureRevision),
|
||||
':snap' => $snapshotJson,
|
||||
]);
|
||||
|
||||
if ($questionIdsToCopy !== null && $questionIdsToCopy !== []) {
|
||||
$questionIdsToCopy = array_values(array_unique(array_filter($questionIdsToCopy, 'is_string')));
|
||||
$ph = implode(',', array_fill(0, count($questionIdsToCopy), '?'));
|
||||
$copy = $pdo->prepare(
|
||||
"INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
SELECT ?, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($ph)"
|
||||
);
|
||||
$copy->execute(array_merge([$submissionID, $clientCode], $questionIdsToCopy));
|
||||
} else {
|
||||
$copy = $pdo->prepare(
|
||||
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = :cc AND questionID IN (
|
||||
SELECT questionID FROM question WHERE questionnaireID = :qn
|
||||
)'
|
||||
);
|
||||
$copy->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $questionnaireID]);
|
||||
}
|
||||
|
||||
return $submissionID;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time: create version 1 submissions for existing completions without archive rows.
|
||||
*/
|
||||
function qdb_backfill_submissions_from_live(PDO $pdo): bool {
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM questionnaire_submission')->fetchColumn();
|
||||
if ($count > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rows = $pdo->query(
|
||||
'SELECT clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints
|
||||
FROM completed_questionnaire'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$rows) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($rows as $cq) {
|
||||
$clientCode = $cq['clientCode'];
|
||||
$qnID = $cq['questionnaireID'];
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT 1 FROM client_answer ca
|
||||
JOIN question q ON q.questionID = ca.questionID
|
||||
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn LIMIT 1'
|
||||
);
|
||||
$chk->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
if (!$chk->fetchColumn()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$submissionID = bin2hex(random_bytes(16));
|
||||
$completedAt = $cq['completedAt'] ?? time();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO questionnaire_submission (
|
||||
submissionID, clientCode, questionnaireID, version, submittedAt,
|
||||
submittedByUserID, submittedByRole, assignedByCoach,
|
||||
status, startedAt, completedAt, sumPoints
|
||||
) VALUES (
|
||||
:sid, :cc, :qn, 1, :sat,
|
||||
\'\', \'\', :abc,
|
||||
:st, :sa, :ca, :sp
|
||||
)'
|
||||
)->execute([
|
||||
':sid' => $submissionID,
|
||||
':cc' => $clientCode,
|
||||
':qn' => $qnID,
|
||||
':sat' => $completedAt,
|
||||
':abc' => $cq['assignedByCoach'],
|
||||
':st' => $cq['status'] ?: 'completed',
|
||||
':sa' => $cq['startedAt'],
|
||||
':ca' => $cq['completedAt'],
|
||||
':sp' => $cq['sumPoints'],
|
||||
]);
|
||||
|
||||
$pdo->prepare(
|
||||
'INSERT INTO client_answer_submission (submissionID, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||
SELECT :sid, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
|
||||
FROM client_answer
|
||||
WHERE clientCode = :cc AND questionID IN (
|
||||
SELECT questionID FROM question WHERE questionnaireID = :qn
|
||||
)'
|
||||
)->execute([':sid' => $submissionID, ':cc' => $clientCode, ':qn' => $qnID]);
|
||||
}
|
||||
$pdo->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CSV rows for all submission versions of one questionnaire (RBAC-scoped).
|
||||
*
|
||||
* @return list<array<string, string>>
|
||||
*/
|
||||
function qdb_export_all_versions_rows(
|
||||
PDO $pdo,
|
||||
string $questionnaireID,
|
||||
array $questions,
|
||||
array $resultColumns,
|
||||
array $optionTextMap,
|
||||
array $tokenRec
|
||||
): array {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$sql = "
|
||||
SELECT qs.submissionID, qs.version, qs.submittedAt, qs.submittedByUserID, qs.submittedByRole,
|
||||
qs.completedAt AS submissionCompletedAt, qs.sumPoints AS submissionSumPoints,
|
||||
qs.startedAt AS submissionStartedAt, qs.status AS submissionStatus,
|
||||
qs.structureRevision, qs.structureSnapshotJson,
|
||||
cl.clientCode, cl.coachID,
|
||||
co.username AS coachUsername,
|
||||
sv.username AS supervisorUsername
|
||||
FROM questionnaire_submission qs
|
||||
INNER JOIN client cl ON cl.clientCode = qs.clientCode
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||
WHERE qs.questionnaireID = :qnid AND ($rbacClause)
|
||||
ORDER BY cl.clientCode ASC, qs.version ASC
|
||||
";
|
||||
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$submissions = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$defaultQuestionIDs = array_column($questions, 'questionID');
|
||||
|
||||
$userMap = [];
|
||||
$userStmt = $pdo->query('SELECT userID, username FROM users');
|
||||
foreach ($userStmt->fetchAll(PDO::FETCH_ASSOC) as $u) {
|
||||
$userMap[$u['userID']] = $u['username'];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($submissions as $s) {
|
||||
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
|
||||
if (!empty($snap['questions'])) {
|
||||
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
|
||||
$submissionColumns = $snapCtx['resultColumns'];
|
||||
$submissionOptionMap = $snapCtx['optionTextMap'];
|
||||
$submissionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
|
||||
} else {
|
||||
$submissionColumns = $resultColumns;
|
||||
$submissionOptionMap = $optionTextMap;
|
||||
$submissionQuestionIDs = $defaultQuestionIDs;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'submissionID' => $s['submissionID'],
|
||||
'version' => (string)(int)$s['version'],
|
||||
'structureRevision' => (string)max(1, (int)($s['structureRevision'] ?? 1)),
|
||||
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
||||
'submittedByRole' => $s['submittedByRole'] ?? '',
|
||||
'submittedBy' => $userMap[$s['submittedByUserID'] ?? ''] ?? ($s['submittedByUserID'] ?? ''),
|
||||
'clientCode' => $s['clientCode'],
|
||||
'coach' => $s['coachUsername'] ?? $s['coachID'],
|
||||
'supervisor' => $s['supervisorUsername'] ?? '',
|
||||
'status' => $s['submissionStatus'] ?? '',
|
||||
'sumPoints' => $s['submissionSumPoints'] ?? '',
|
||||
'startedAt' => $s['submissionStartedAt'] ? date('Y-m-d H:i', (int)$s['submissionStartedAt']) : '',
|
||||
'completedAt' => $s['submissionCompletedAt'] ? date('Y-m-d H:i', (int)$s['submissionCompletedAt']) : '',
|
||||
];
|
||||
|
||||
$answerMap = qdb_load_client_answer_map(
|
||||
$pdo,
|
||||
$s['clientCode'],
|
||||
$submissionQuestionIDs,
|
||||
'client_answer_submission',
|
||||
$s['submissionID']
|
||||
);
|
||||
|
||||
foreach ($submissionColumns as $col) {
|
||||
$qid = $col['questionID'];
|
||||
$a = $answerMap[$qid] ?? null;
|
||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $submissionOptionMap);
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{questions: array, resultColumns: array, optionTextMap: array}
|
||||
*/
|
||||
function qdb_export_questionnaire_context(PDO $pdo, string $questionnaireID): array {
|
||||
$qStmt = $pdo->prepare(
|
||||
'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex'
|
||||
);
|
||||
$qStmt->execute([':id' => $questionnaireID]);
|
||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
$resultColumns = qdb_results_export_columns($questions, $questionnaireID);
|
||||
|
||||
$optionTextMap = [];
|
||||
foreach ($questionIDs as $qid) {
|
||||
$aoStmt = $pdo->prepare('SELECT answerOptionID, defaultText FROM answer_option WHERE questionID = :qid');
|
||||
$aoStmt->execute([':qid' => $qid]);
|
||||
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
||||
$optionTextMap[$ao['answerOptionID']] = $ao['defaultText'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'questions' => $questions,
|
||||
'resultColumns' => $resultColumns,
|
||||
'optionTextMap' => $optionTextMap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Current (live) response rows for one questionnaire, RBAC-scoped.
|
||||
*
|
||||
* @return list<array<string, string>>
|
||||
*/
|
||||
function qdb_export_current_rows(
|
||||
PDO $pdo,
|
||||
string $questionnaireID,
|
||||
array $questions,
|
||||
array $resultColumns,
|
||||
array $optionTextMap,
|
||||
array $tokenRec
|
||||
): array {
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
$questionIDs = array_column($questions, 'questionID');
|
||||
|
||||
$sql = "
|
||||
SELECT cl.clientCode, cl.coachID,
|
||||
co.username AS coachUsername,
|
||||
sv.username AS supervisorUsername,
|
||||
cq.status, cq.sumPoints, cq.startedAt, cq.completedAt
|
||||
FROM client cl
|
||||
INNER JOIN completed_questionnaire cq ON cq.clientCode = cl.clientCode AND cq.questionnaireID = :qnid
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||
WHERE $rbacClause
|
||||
ORDER BY cl.clientCode
|
||||
";
|
||||
$params = array_merge([':qnid' => $questionnaireID], $rbacParams);
|
||||
$cStmt = $pdo->prepare($sql);
|
||||
$cStmt->execute($params);
|
||||
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$qPlaceholders = !empty($questionIDs) ? implode(',', array_fill(0, count($questionIDs), '?')) : "'__none__'";
|
||||
$answerStmt = $pdo->prepare("
|
||||
SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
|
||||
");
|
||||
|
||||
$rows = [];
|
||||
foreach ($clients as $c) {
|
||||
$row = [
|
||||
'clientCode' => $c['clientCode'],
|
||||
'coach' => $c['coachUsername'] ?? $c['coachID'],
|
||||
'supervisor' => $c['supervisorUsername'] ?? '',
|
||||
'status' => $c['status'],
|
||||
'sumPoints' => $c['sumPoints'],
|
||||
'startedAt' => $c['startedAt'] ? date('Y-m-d H:i', (int)$c['startedAt']) : '',
|
||||
'completedAt' => $c['completedAt'] ? date('Y-m-d H:i', (int)$c['completedAt']) : '',
|
||||
];
|
||||
|
||||
$bindParams = array_merge([$c['clientCode']], $questionIDs);
|
||||
$answerStmt->execute($bindParams);
|
||||
$answerMap = [];
|
||||
foreach ($answerStmt->fetchAll(PDO::FETCH_ASSOC) as $a) {
|
||||
$answerMap[$a['questionID']] = $a;
|
||||
}
|
||||
|
||||
foreach ($resultColumns as $col) {
|
||||
$qid = $col['questionID'];
|
||||
$a = $answerMap[$qid] ?? null;
|
||||
$row[$col['header']] = qdb_results_column_cell_value($col, $a, $optionTextMap);
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
function qdb_export_safe_basename(string $name): string {
|
||||
$safe = preg_replace('/[^a-zA-Z0-9_-]+/', '_', $name);
|
||||
return $safe !== '' ? $safe : 'questionnaire';
|
||||
}
|
||||
|
||||
/** @param array<string, true> $used */
|
||||
function qdb_export_unique_zip_name(array &$used, string $base): string {
|
||||
$name = $base;
|
||||
$n = 2;
|
||||
while (isset($used[$name])) {
|
||||
$name = preg_replace('/(\.csv)$/i', "_{$n}$1", $base, 1, $count);
|
||||
if (!$count) {
|
||||
$name = $base . '_' . $n;
|
||||
}
|
||||
$n++;
|
||||
}
|
||||
$used[$name] = true;
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, string>> $rows
|
||||
* @param list<string> $fallbackHeader
|
||||
*/
|
||||
function qdb_export_rows_to_csv_string(array $rows, array $fallbackHeader = []): string {
|
||||
$fp = fopen('php://temp', 'r+');
|
||||
if ($fp === false) {
|
||||
return '';
|
||||
}
|
||||
fwrite($fp, "\xEF\xBB\xBF");
|
||||
if (!empty($rows)) {
|
||||
fputcsv($fp, array_keys($rows[0]), ',', '"', '\\');
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($fp, array_values($row), ',', '"', '\\');
|
||||
}
|
||||
} elseif ($fallbackHeader !== []) {
|
||||
fputcsv($fp, $fallbackHeader, ',', '"', '\\');
|
||||
}
|
||||
rewind($fp);
|
||||
$csv = stream_get_contents($fp);
|
||||
fclose($fp);
|
||||
return $csv !== false ? $csv : '';
|
||||
}
|
||||
|
||||
function qdb_export_current_csv_fallback_header(array $resultColumns): array {
|
||||
$header = ['clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt'];
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
return $header;
|
||||
}
|
||||
|
||||
function qdb_export_all_versions_csv_fallback_header(array $resultColumns): array {
|
||||
$header = [
|
||||
'submissionID', 'version', 'submittedAt', 'submittedByRole', 'submittedBy',
|
||||
'clientCode', 'coach', 'supervisor', 'status', 'sumPoints', 'startedAt', 'completedAt',
|
||||
];
|
||||
foreach ($resultColumns as $col) {
|
||||
$header[] = $col['header'];
|
||||
}
|
||||
return $header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ZIP of CSV exports for every questionnaire. Returns path to a temp file (caller must unlink).
|
||||
*/
|
||||
function qdb_build_server_export_zip(PDO $pdo, array $tokenRec, bool $allVersions): string {
|
||||
if (!class_exists('ZipArchive')) {
|
||||
json_error('SERVER_ERROR', 'ZIP export is not available on this server', 500);
|
||||
}
|
||||
|
||||
$qnRows = $pdo->query(
|
||||
'SELECT questionnaireID, name, version FROM questionnaire ORDER BY orderIndex, name'
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$tmpZip = tempnam(sys_get_temp_dir(), 'qdb_export_');
|
||||
if ($tmpZip === false) {
|
||||
json_error('SERVER_ERROR', 'Could not create export file', 500);
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($tmpZip, ZipArchive::OVERWRITE) !== true) {
|
||||
@unlink($tmpZip);
|
||||
json_error('SERVER_ERROR', 'Could not create ZIP archive', 500);
|
||||
}
|
||||
|
||||
$usedNames = [];
|
||||
foreach ($qnRows as $qn) {
|
||||
$qnID = $qn['questionnaireID'];
|
||||
$ctx = qdb_export_questionnaire_context($pdo, $qnID);
|
||||
$questions = $ctx['questions'];
|
||||
$resultColumns = $ctx['resultColumns'];
|
||||
$optionTextMap = $ctx['optionTextMap'];
|
||||
|
||||
if ($allVersions) {
|
||||
$rows = qdb_export_all_versions_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
$fallback = qdb_export_all_versions_csv_fallback_header($resultColumns);
|
||||
$base = qdb_export_safe_basename($qn['name']) . '_all_versions.csv';
|
||||
} else {
|
||||
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
|
||||
$fallback = qdb_export_current_csv_fallback_header($resultColumns);
|
||||
$ver = preg_replace('/[^a-zA-Z0-9_.-]+/', '_', (string)($qn['version'] ?? ''));
|
||||
$base = qdb_export_safe_basename($qn['name']) . ($ver !== '' ? "_v{$ver}" : '') . '.csv';
|
||||
}
|
||||
|
||||
$entryName = qdb_export_unique_zip_name($usedNames, $base);
|
||||
$csv = qdb_export_rows_to_csv_string($rows, $fallback);
|
||||
$zip->addFromString($entryName, $csv);
|
||||
}
|
||||
|
||||
$readme = $allVersions
|
||||
? "NAT-AS Ressourcenbarometer — all questionnaire response exports (every upload version).\nOne CSV per questionnaire.\n"
|
||||
: "NAT-AS Ressourcenbarometer — all questionnaire response exports (current data).\nOne CSV per questionnaire.\n";
|
||||
$zip->addFromString('README.txt', $readme);
|
||||
$zip->close();
|
||||
|
||||
return $tmpZip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete uploads, answers, and completions for client codes (does not delete client rows).
|
||||
*
|
||||
* @param list<string> $clientCodes
|
||||
* @return array{submissions: int, followup_notes: int, client_answers: int, completed_questionnaires: int}
|
||||
*/
|
||||
function qdb_delete_client_response_data(PDO $pdo, array $clientCodes): array {
|
||||
$clientCodes = array_values(array_unique(array_filter(
|
||||
$clientCodes,
|
||||
static fn($c) => is_string($c) && $c !== ''
|
||||
)));
|
||||
$deleted = [
|
||||
'submissions' => 0,
|
||||
'followup_notes' => 0,
|
||||
'client_answers' => 0,
|
||||
'completed_questionnaires' => 0,
|
||||
];
|
||||
if ($clientCodes === []) {
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
foreach (array_chunk($clientCodes, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['submissions'] += $stmt->rowCount();
|
||||
}
|
||||
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||
$stmt = $pdo->prepare("DELETE FROM client_followup_note WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['followup_notes'] += $stmt->rowCount();
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM client_answer WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['client_answers'] += $stmt->rowCount();
|
||||
$stmt = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$deleted['completed_questionnaires'] += $stmt->rowCount();
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove submission rows assigned to coaches (FK before coach delete).
|
||||
*
|
||||
* @param list<string> $coachIDs
|
||||
*/
|
||||
function qdb_delete_submissions_for_coaches(PDO $pdo, array $coachIDs): int {
|
||||
if (!qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
return 0;
|
||||
}
|
||||
$coachIDs = array_values(array_unique(array_filter(
|
||||
$coachIDs,
|
||||
static fn($id) => is_string($id) && $id !== ''
|
||||
)));
|
||||
if ($coachIDs === []) {
|
||||
return 0;
|
||||
}
|
||||
$total = 0;
|
||||
foreach (array_chunk($coachIDs, 200) as $chunk) {
|
||||
$ph = implode(',', array_fill(0, count($chunk), '?'));
|
||||
$stmt = $pdo->prepare("DELETE FROM questionnaire_submission WHERE assignedByCoach IN ($ph)");
|
||||
$stmt->execute($chunk);
|
||||
$total += $stmt->rowCount();
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, mixed>> $answerMap questionID => answer row
|
||||
* @param array<string, array<string, mixed>>|null $compareMap live answers to diff against
|
||||
* @return list<array{label: string, value: string, changedFromLive: bool}>
|
||||
*/
|
||||
function qdb_answers_display_rows(
|
||||
PDO $pdo,
|
||||
array $resultColumns,
|
||||
array $answerMap,
|
||||
array $optionTextMap,
|
||||
array &$stringLabelCache,
|
||||
?array $compareMap = null
|
||||
): array {
|
||||
$rows = [];
|
||||
foreach ($resultColumns as $col) {
|
||||
$qid = $col['questionID'];
|
||||
$a = $answerMap[$qid] ?? null;
|
||||
$value = qdb_display_column_cell_value($pdo, $col, $a, $optionTextMap, $stringLabelCache);
|
||||
$changedFromLive = false;
|
||||
if ($compareMap !== null) {
|
||||
$liveValue = qdb_display_column_cell_value(
|
||||
$pdo,
|
||||
$col,
|
||||
$compareMap[$qid] ?? null,
|
||||
$optionTextMap,
|
||||
$stringLabelCache
|
||||
);
|
||||
$changedFromLive = $value !== $liveValue;
|
||||
}
|
||||
$rows[] = [
|
||||
'label' => $col['header'],
|
||||
'value' => $value,
|
||||
'changedFromLive' => $changedFromLive,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $questionIDs
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
function qdb_load_client_answer_map(
|
||||
PDO $pdo,
|
||||
string $clientCode,
|
||||
array $questionIDs,
|
||||
string $table = 'client_answer',
|
||||
?string $submissionID = null
|
||||
): array {
|
||||
if ($questionIDs === []) {
|
||||
return [];
|
||||
}
|
||||
$allowed = ['client_answer', 'client_answer_submission'];
|
||||
if (!in_array($table, $allowed, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ph = implode(',', array_fill(0, count($questionIDs), '?'));
|
||||
if ($table === 'client_answer_submission') {
|
||||
if ($submissionID === null || $submissionID === '') {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer_submission
|
||||
WHERE submissionID = ? AND questionID IN ($ph)";
|
||||
$params = array_merge([$submissionID], $questionIDs);
|
||||
} else {
|
||||
$sql = "SELECT questionID, answerOptionID, freeTextValue, numericValue
|
||||
FROM client_answer
|
||||
WHERE clientCode = ? AND questionID IN ($ph)";
|
||||
$params = array_merge([$clientCode], $questionIDs);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$map[$row['questionID']] = $row;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full client response detail for the web clients list (RBAC-scoped).
|
||||
*/
|
||||
function qdb_client_detail(PDO $pdo, array $tokenRec, string $clientCode): array {
|
||||
$clientCode = trim($clientCode);
|
||||
if ($clientCode === '') {
|
||||
json_error('INVALID_FIELD', 'clientCode is required', 400);
|
||||
}
|
||||
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl', 'all');
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
|
||||
co.username AS coachUsername,
|
||||
sv.username AS supervisorUsername
|
||||
FROM client cl
|
||||
LEFT JOIN coach co ON co.coachID = cl.coachID
|
||||
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
|
||||
WHERE cl.clientCode = :cc AND $rbacClause"
|
||||
);
|
||||
$stmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
|
||||
$client = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$client) {
|
||||
json_error('NOT_FOUND', 'Client not found', 404);
|
||||
}
|
||||
|
||||
$qnStmt = $pdo->prepare(
|
||||
"SELECT q.questionnaireID, q.name, q.version AS questionnaireVersion, q.orderIndex
|
||||
FROM questionnaire q
|
||||
WHERE q.questionnaireID IN (
|
||||
SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc
|
||||
UNION
|
||||
SELECT questionnaireID FROM questionnaire_submission WHERE clientCode = :cc2
|
||||
)
|
||||
ORDER BY q.orderIndex, q.name"
|
||||
);
|
||||
$qnStmt->execute([':cc' => $clientCode, ':cc2' => $clientCode]);
|
||||
$qnRows = $qnStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$questionnaires = [];
|
||||
foreach ($qnRows as $qn) {
|
||||
$qnID = $qn['questionnaireID'];
|
||||
$ctx = qdb_display_questionnaire_context($pdo, $qnID);
|
||||
$questionIDs = array_column($ctx['questions'], 'questionID');
|
||||
$resultColumns = $ctx['resultColumns'];
|
||||
$optionTextMap = $ctx['optionTextMap'];
|
||||
$stringLabelCache = $ctx['stringLabelCache'];
|
||||
|
||||
$cqStmt = $pdo->prepare(
|
||||
'SELECT status, sumPoints, startedAt, completedAt
|
||||
FROM completed_questionnaire
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn'
|
||||
);
|
||||
$cqStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$cq = $cqStmt->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$liveMap = qdb_load_client_answer_map($pdo, $clientCode, $questionIDs);
|
||||
$currentAnswers = qdb_answers_display_rows(
|
||||
$pdo,
|
||||
$resultColumns,
|
||||
$liveMap,
|
||||
$optionTextMap,
|
||||
$stringLabelCache,
|
||||
null
|
||||
);
|
||||
|
||||
$subStmt = $pdo->prepare(
|
||||
'SELECT submissionID, version, submittedAt, status, sumPoints, completedAt,
|
||||
structureRevision, structureSnapshotJson
|
||||
FROM questionnaire_submission
|
||||
WHERE clientCode = :cc AND questionnaireID = :qn
|
||||
ORDER BY version DESC'
|
||||
);
|
||||
$subStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
|
||||
$submissions = [];
|
||||
foreach ($subStmt->fetchAll(PDO::FETCH_ASSOC) as $s) {
|
||||
$snap = json_decode($s['structureSnapshotJson'] ?? '{}', true) ?: [];
|
||||
if (!empty($snap['questions'])) {
|
||||
$snapCtx = qdb_display_context_from_manifest($pdo, $snap);
|
||||
$versionColumns = $snapCtx['resultColumns'];
|
||||
$versionOptionMap = $snapCtx['optionTextMap'];
|
||||
$versionStringCache = $snapCtx['stringLabelCache'];
|
||||
$versionQuestionIDs = array_column($snapCtx['questions'], 'questionID');
|
||||
} else {
|
||||
$versionColumns = $resultColumns;
|
||||
$versionOptionMap = $optionTextMap;
|
||||
$versionStringCache = $stringLabelCache;
|
||||
$versionQuestionIDs = $questionIDs;
|
||||
}
|
||||
|
||||
$subMap = qdb_load_client_answer_map(
|
||||
$pdo,
|
||||
$clientCode,
|
||||
$versionQuestionIDs,
|
||||
'client_answer_submission',
|
||||
$s['submissionID']
|
||||
);
|
||||
$versionAnswers = qdb_answers_display_rows(
|
||||
$pdo,
|
||||
$versionColumns,
|
||||
$subMap,
|
||||
$versionOptionMap,
|
||||
$versionStringCache,
|
||||
$liveMap
|
||||
);
|
||||
$changedCount = count(array_filter(
|
||||
$versionAnswers,
|
||||
static fn($r) => !empty($r['changedFromLive'])
|
||||
));
|
||||
$submissions[] = [
|
||||
'submissionID' => $s['submissionID'],
|
||||
'version' => (int)$s['version'],
|
||||
'structureRevision' => max(1, (int)($s['structureRevision'] ?? 1)),
|
||||
'submittedAt' => $s['submittedAt'] ? date('Y-m-d H:i', (int)$s['submittedAt']) : '',
|
||||
'status' => $s['status'] ?? '',
|
||||
'sumPoints' => $s['sumPoints'] !== null ? (int)$s['sumPoints'] : null,
|
||||
'completedAt' => $s['completedAt'] ? date('Y-m-d H:i', (int)$s['completedAt']) : '',
|
||||
'changedCount' => $changedCount,
|
||||
'answers' => $versionAnswers,
|
||||
];
|
||||
}
|
||||
|
||||
$questionnaires[] = [
|
||||
'questionnaireID' => $qnID,
|
||||
'name' => $qn['name'],
|
||||
'questionnaireVersion' => $qn['questionnaireVersion'] ?? '',
|
||||
'status' => $cq['status'] ?? '',
|
||||
'sumPoints' => isset($cq['sumPoints']) ? (int)$cq['sumPoints'] : null,
|
||||
'startedAt' => !empty($cq['startedAt']) ? date('Y-m-d H:i', (int)$cq['startedAt']) : '',
|
||||
'completedAt' => !empty($cq['completedAt']) ? date('Y-m-d H:i', (int)$cq['completedAt']) : '',
|
||||
'submissionCount' => count($submissions),
|
||||
'currentAnswers' => $currentAnswers,
|
||||
'submissions' => $submissions,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'client' => [
|
||||
'clientCode' => $client['clientCode'],
|
||||
'coachID' => $client['coachID'] ?? '',
|
||||
'coachUsername' => $client['coachUsername'] ?? '',
|
||||
'supervisorUsername' => $client['supervisorUsername'] ?? '',
|
||||
],
|
||||
'questionnaires' => $questionnaires,
|
||||
'scoringProfiles' => qdb_client_scoring_profile_results($pdo, $clientCode),
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_client_scoring_profile_results(PDO $pdo, string $clientCode): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT r.profileID, r.weightedTotal, r.band, r.computedAt, r.questionnaireSnapshot,
|
||||
r.coachBand, r.coachReviewedAt, r.coachReviewedByUserID,
|
||||
sp.name, sp.greenMin, sp.greenMax, sp.yellowMin, sp.yellowMax, sp.redMin
|
||||
FROM client_scoring_profile_result r
|
||||
JOIN scoring_profile sp ON sp.profileID = r.profileID
|
||||
WHERE r.clientCode = :cc
|
||||
ORDER BY sp.name'
|
||||
);
|
||||
$stmt->execute([':cc' => $clientCode]);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$bands = qdb_normalize_scoring_bands($row);
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$out[] = [
|
||||
'profileID' => $row['profileID'],
|
||||
'name' => $row['name'],
|
||||
'weightedTotal' => (float)$row['weightedTotal'],
|
||||
'band' => $row['band'],
|
||||
'calculatedBand' => $row['band'],
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||
'pendingReview' => $coachBand === '',
|
||||
'greenMin' => $bands['greenMin'],
|
||||
'greenMax' => $bands['greenMax'],
|
||||
'yellowMin' => $bands['yellowMin'],
|
||||
'yellowMax' => $bands['yellowMax'],
|
||||
'redMin' => $bands['redMin'],
|
||||
'computedAt' => $row['computedAt'] ? date('Y-m-d H:i', (int)$row['computedAt']) : '',
|
||||
'coachReviewedAt' => !empty($row['coachReviewedAt'])
|
||||
? date('Y-m-d H:i', (int)$row['coachReviewedAt']) : '',
|
||||
'snapshot' => json_decode($row['questionnaireSnapshot'] ?? '{}', true) ?: [],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active scoring profiles + per-client results for the clients list (RBAC-scoped).
|
||||
*
|
||||
* @return array{profiles: list<array{profileID: string, name: string}>, byClient: array<string, array<string, array{band: string, weightedTotal: float, name: string}>>}
|
||||
*/
|
||||
function qdb_clients_scoring_summary_for_list(PDO $pdo, array $tokenRec): array {
|
||||
require_once __DIR__ . '/scoring.php';
|
||||
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
|
||||
|
||||
$profiles = $pdo->query(
|
||||
"SELECT profileID, name FROM scoring_profile WHERE isActive = 1 ORDER BY name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($profiles === []) {
|
||||
return ['profiles' => [], 'byClient' => []];
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT r.clientCode, r.profileID, r.band, r.coachBand, r.weightedTotal, sp.name
|
||||
FROM client_scoring_profile_result r
|
||||
INNER JOIN scoring_profile sp ON sp.profileID = r.profileID AND sp.isActive = 1
|
||||
INNER JOIN client cl ON cl.clientCode = r.clientCode
|
||||
WHERE ($rbacClause)"
|
||||
);
|
||||
$stmt->execute($rbacParams);
|
||||
|
||||
$byClient = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$cc = (string)$row['clientCode'];
|
||||
$coachBand = trim((string)($row['coachBand'] ?? ''));
|
||||
$byClient[$cc][$row['profileID']] = [
|
||||
'band' => (string)$row['band'],
|
||||
'calculatedBand' => (string)$row['band'],
|
||||
'coachBand' => $coachBand !== '' ? $coachBand : null,
|
||||
'effectiveBand' => qdb_effective_scoring_band($row),
|
||||
'pendingReview' => $coachBand === '',
|
||||
'weightedTotal' => (float)$row['weightedTotal'],
|
||||
'name' => (string)$row['name'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'profiles' => array_map(static fn(array $p) => [
|
||||
'profileID' => (string)$p['profileID'],
|
||||
'name' => (string)$p['name'],
|
||||
], $profiles),
|
||||
'byClient' => $byClient,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{profileID: string, name: string, band: ?string, weightedTotal: ?float}>
|
||||
*/
|
||||
function qdb_client_scoring_dots_for_client(string $clientCode, array $summary): array {
|
||||
$out = [];
|
||||
foreach ($summary['profiles'] as $profile) {
|
||||
$result = $summary['byClient'][$clientCode][$profile['profileID']] ?? null;
|
||||
$out[] = [
|
||||
'profileID' => $profile['profileID'],
|
||||
'name' => $profile['name'],
|
||||
'band' => $result['calculatedBand'] ?? null,
|
||||
'calculatedBand' => $result['calculatedBand'] ?? null,
|
||||
'coachBand' => $result['coachBand'] ?? null,
|
||||
'effectiveBand' => $result['effectiveBand'] ?? null,
|
||||
'pendingReview' => $result['pendingReview'] ?? false,
|
||||
'weightedTotal' => $result !== null ? $result['weightedTotal'] : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
61
lib/text_sanitize.php
Normal file
61
lib/text_sanitize.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Sanitize user-entered free text before storage (defense in depth; queries use prepared statements).
|
||||
*/
|
||||
function sanitize_free_text(mixed $value, int $maxLength = 2000): string {
|
||||
if (!is_string($value) && !is_numeric($value)) {
|
||||
return '';
|
||||
}
|
||||
$s = trim((string) $value);
|
||||
if ($s === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$maxLength = max(1, min($maxLength, 10000));
|
||||
$s = mb_substr($s, 0, $maxLength);
|
||||
|
||||
// Control characters (keep tab/newline for multiline answers).
|
||||
$s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s) ?? '';
|
||||
|
||||
$s = str_replace(["\0", '--', '/*', '*/', ';'], '', $s);
|
||||
|
||||
$patterns = [
|
||||
'/\bunion\s+select\b/i',
|
||||
'/\bselect\s+.+\s+from\b/i',
|
||||
'/\binsert\s+into\b/i',
|
||||
'/\bupdate\s+.+\s+set\b/i',
|
||||
'/\bdelete\s+from\b/i',
|
||||
'/\bdrop\s+(table|database)\b/i',
|
||||
'/\bexec(\s|\()/i',
|
||||
'/\bxp_\w+/i',
|
||||
'/\bor\s+1\s*=\s*1\b/i',
|
||||
'/\band\s+1\s*=\s*1\b/i',
|
||||
"/'\s*or\s+'/i",
|
||||
'/"\s*or\s+"/i',
|
||||
'/\bchar\s*\(/i',
|
||||
'/\bconcat\s*\(/i',
|
||||
'/\bsleep\s*\(/i',
|
||||
'/\bbenchmark\s*\(/i',
|
||||
];
|
||||
foreach ($patterns as $pattern) {
|
||||
$s = preg_replace($pattern, '', $s) ?? $s;
|
||||
}
|
||||
|
||||
return trim(preg_replace('/\s{2,}/u', ' ', $s) ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sanitized text or calls json_error when nothing safe remains.
|
||||
*/
|
||||
function require_safe_free_text(mixed $value, string $fieldName, int $maxLength = 2000): string {
|
||||
$raw = is_string($value) || is_numeric($value) ? trim((string) $value) : '';
|
||||
if ($raw === '') {
|
||||
json_error('INVALID_FIELD', "$fieldName is required", 400);
|
||||
}
|
||||
$clean = sanitize_free_text($raw, $maxLength);
|
||||
if ($clean === '') {
|
||||
json_error('INVALID_FIELD', "$fieldName contains disallowed content", 400);
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
71
lib/validate.php
Normal file
71
lib/validate.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Validate that all required fields are present and non-empty in the body.
|
||||
* Calls json_error on failure (never returns in that case).
|
||||
*/
|
||||
function require_fields(array $body, array $fields): void {
|
||||
$missing = [];
|
||||
foreach ($fields as $f) {
|
||||
if (!isset($body[$f]) || (is_string($body[$f]) && trim($body[$f]) === '')) {
|
||||
$missing[] = $f;
|
||||
}
|
||||
}
|
||||
if ($missing) {
|
||||
json_error('MISSING_FIELDS', 'Required fields: ' . implode(', ', $missing), 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a string value with optional min/max length.
|
||||
* Returns the trimmed string or calls json_error.
|
||||
*/
|
||||
function validate_string(mixed $value, string $fieldName, int $min = 0, int $max = 0): string {
|
||||
if (!is_string($value) && !is_numeric($value)) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be a string", 400);
|
||||
}
|
||||
$s = trim((string)$value);
|
||||
if ($min > 0 && strlen($s) < $min) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be at least $min characters", 400);
|
||||
}
|
||||
if ($max > 0 && strlen($s) > $max) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be at most $max characters", 400);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is one of the allowed options.
|
||||
* Returns the value or calls json_error.
|
||||
*/
|
||||
function validate_enum(mixed $value, string $fieldName, array $allowed): string {
|
||||
$s = trim((string)$value);
|
||||
if (!in_array($s, $allowed, true)) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be one of: " . implode(', ', $allowed), 400);
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return an integer from body, with optional min/max bounds.
|
||||
*/
|
||||
function validate_int(mixed $value, string $fieldName, ?int $min = null, ?int $max = null): int {
|
||||
$i = (int)$value;
|
||||
if ($min !== null && $i < $min) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be at least $min", 400);
|
||||
}
|
||||
if ($max !== null && $i > $max) {
|
||||
json_error('INVALID_FIELD', "$fieldName must be at most $max", 400);
|
||||
}
|
||||
return $i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the request method to match, or exit with 405.
|
||||
*/
|
||||
function require_method(string ...$allowed): void {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
if (!in_array($method, $allowed, true)) {
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user