initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

21
handlers/activity_log.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* Admin: read API activity log (app sync + data changes only).
*/
require_once __DIR__ . '/../lib/api_log.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$date = trim((string)($_GET['date'] ?? date('Y-m-d')));
$activity = trim((string)($_GET['activity'] ?? ''));
$limit = (int)($_GET['limit'] ?? 200);
$errorsOnly = !empty($_GET['errorsOnly']) || $activity === 'errors';
json_success(qdb_api_log_read_entries($date, $activity, $limit, $errorsOnly));

51
handlers/analytics.php Normal file
View File

@ -0,0 +1,51 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
require_once __DIR__ . '/../lib/analytics.php';
switch ($method) {
case 'GET':
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!empty($_GET['overview'])) {
$data = qdb_analytics_overview($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success($data);
}
if (!empty($_GET['staleClients'])) {
$clients = qdb_analytics_stale_clients($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success(['clients' => $clients]);
}
qdb_discard($tmpDb, $lockFp);
json_error('BAD_REQUEST', 'Unknown query', 400);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Analytics', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
$body = read_json_body();
$clientCode = trim((string)($body['clientCode'] ?? ''));
$note = (string)($body['note'] ?? '');
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'note' => $note]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save follow-up note', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

217
handlers/answer_options.php Normal file
View File

@ -0,0 +1,217 @@
<?php
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
$method = $_SERVER['REQUEST_METHOD'];
switch ($method) {
case 'GET':
$qID = $_GET['questionID'] ?? '';
if (!$qID) {
json_error('MISSING_PARAM', 'questionID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex');
$stmt->execute([':qid' => $qID]);
$options = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($options as &$o) {
$o['points'] = (int)$o['points'];
$o['orderIndex'] = (int)$o['orderIndex'];
$o['optionKey'] = qdb_option_key($o);
$o['labelGerman'] = qdb_option_german_label($pdo, $o['answerOptionID'], $o['defaultText']);
$tr = $pdo->prepare('SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id');
$tr->execute([':id' => $o['answerOptionID']]);
$o['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($o);
qdb_discard($tmpDb, $lockFp);
json_success(['answerOptions' => $options]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID']) || empty($body['optionKey']) || !isset($body['defaultText'])) {
json_error('MISSING_FIELDS', 'questionID, optionKey, and defaultText (German label) required', 400);
}
$id = bin2hex(random_bytes(16));
$qID = $body['questionID'];
$optKey = qdb_validate_stable_key((string)$body['optionKey'], 'Option key');
$text = trim($body['defaultText']);
qdb_require_non_empty_german($text, 'German label');
$points = (int)($body['points'] ?? 0);
$order = (int)($body['orderIndex'] ?? 0);
$nextQ = trim($body['nextQuestionId'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$chk = $pdo->prepare('SELECT questionnaireID FROM question WHERE questionID = :id');
$chk->execute([':id' => $qID]);
$qnID = $chk->fetchColumn();
if (!$qnID) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$qnID = (string)$qnID;
if ($order === 0) {
$max = $pdo->prepare('SELECT COALESCE(MAX(orderIndex),0)+1 FROM answer_option WHERE questionID = :id');
$max->execute([':id' => $qID]);
$order = (int)$max->fetchColumn();
}
qdb_assert_unique_option_key($pdo, $qID, $optKey);
$pdo->prepare('INSERT INTO answer_option (answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId) VALUES (:id, :qid, :t, :p, :o, :nq)')
->execute([':id' => $id, ':qid' => $qID, ':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $text);
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_added');
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'answerOption' => [
'answerOptionID' => $id,
'questionID' => $qID,
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $text,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
'translations' => [],
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$existing = $pdo->prepare('SELECT * FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$optKey = isset($body['optionKey'])
? qdb_validate_stable_key((string)$body['optionKey'], 'Option key')
: qdb_option_key($row);
if ($optKey === '') {
json_error('MISSING_FIELDS', 'optionKey is required', 400);
}
$labelGerman = trim($body['defaultText'] ?? $body['labelGerman'] ?? '');
if ($labelGerman === '') {
$labelGerman = qdb_option_german_label($pdo, $id, $row['defaultText']);
}
qdb_require_non_empty_german($labelGerman, 'German label');
qdb_assert_unique_option_key($pdo, $row['questionID'], $optKey, $id);
$points = (int)($body['points'] ?? $row['points']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$nextQ = trim($body['nextQuestionId'] ?? $row['nextQuestionId']);
$keyChanged = $optKey !== qdb_option_key($row);
$pdo->prepare('UPDATE answer_option SET defaultText = :t, points = :p, orderIndex = :o, nextQuestionId = :nq WHERE answerOptionID = :id')
->execute([':t' => $optKey, ':p' => $points, ':o' => $order, ':nq' => $nextQ, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'answer_option', $id, $labelGerman);
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$row['questionID']);
$newRev = null;
if ($keyChanged && $qnID !== null) {
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_key_changed');
}
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? ($qnID !== null ? qdb_questionnaire_structure_revision($pdo, $qnID) : 1),
'answerOption' => [
'answerOptionID' => $id,
'questionID' => $row['questionID'],
'optionKey' => $optKey,
'defaultText' => $optKey,
'labelGerman' => $labelGerman,
'points' => $points,
'orderIndex' => $order,
'nextQuestionId' => $nextQ,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['answerOptionID'])) {
json_error('MISSING_FIELDS', 'answerOptionID is required', 400);
}
$id = $body['answerOptionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$existing = $pdo->prepare('SELECT questionID FROM answer_option WHERE answerOptionID = :id');
$existing->execute([':id' => $id]);
$qid = $existing->fetchColumn();
if (!$qid) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Answer option not found', 404);
}
$qnID = qdb_questionnaire_id_for_question($pdo, (string)$qid);
if ($qnID === null) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$pdo->beginTransaction();
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'option_removed');
if (qdb_option_has_client_data($pdo, $id)) {
$pdo->prepare('UPDATE answer_option SET retiredAt = :ts WHERE answerOptionID = :id')
->execute([':ts' => time(), ':id' => $id]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => $newRev]);
break;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM answer_option WHERE answerOptionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true, 'retired' => false, 'structureRevision' => $newRev]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete answer option', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID']) || !is_array($body['order'] ?? null)) {
json_error('MISSING_FIELDS', 'questionID and order[] required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$stmt = $pdo->prepare('UPDATE answer_option SET orderIndex = :o WHERE answerOptionID = :id AND questionID = :qid');
foreach ($body['order'] as $idx => $aoid) {
$stmt->execute([':o' => $idx, ':id' => $aoid, ':qid' => $body['questionID']]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Answer options', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

View File

@ -0,0 +1,626 @@
<?php
/**
* Android app API endpoint.
* GET (no params) -> ordered questionnaire list with conditions
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format
* GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen).
* GET ?clients=1 -> list of clients assigned to the authenticated coach
* GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted)
* GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted)
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
* POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
*/
// Public app UI catalog for the login screen (no token, no PII).
if ($method === 'GET' && !empty($_GET['translations'])) {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
json_success(['translations' => qdb_build_app_translations_map($pdo)]);
qdb_discard($tmpDb, $lockFp);
return;
}
$tokenRec = require_valid_token();
if ($method === 'POST') {
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
$body = read_encrypted_json_body($tokenRec['_token']);
if (($body['action'] ?? '') === 'coachScoringReview') {
require_fields($body, ['clientCode', 'profileID', 'coachBand']);
$clientCode = trim((string)$body['clientCode']);
$profileID = trim((string)$body['profileID']);
$coachBand = trim((string)($body['coachBand'] ?? ''));
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/scoring.php';
if (!qdb_is_valid_scoring_band($coachBand)) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
}
$profile = qdb_save_coach_scoring_review(
$pdo,
$clientCode,
$profileID,
$coachBand,
(string)($tokenRec['userID'] ?? ''),
$weightedTotal,
$calculatedBand !== '' ? $calculatedBand : null,
);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => $profile]);
} catch (InvalidArgumentException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('INVALID_FIELD', $e->getMessage(), 400);
} catch (RuntimeException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('NOT_FOUND', $e->getMessage(), 404);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
$qnID = trim($body['questionnaireID']);
$clientCode = trim($body['clientCode']);
$answers = $body['answers'];
if (!is_array($answers)) {
json_error('INVALID_FIELD', 'answers must be an array', 400);
}
$startedAt = isset($body['startedAt']) ? (int)$body['startedAt'] : null;
$completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null;
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qnStmt = $pdo->prepare(
'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire WHERE questionnaireID = :id'
);
$qnStmt->execute([':id' => $qnID]);
$qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$currentRev = max(1, (int)$qnRow['structureRevision']);
$submitRev = max(1, (int)($body['structureRevision'] ?? 1));
if ($submitRev > $currentRev) {
qdb_discard($tmpDb, $lockFp);
json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
$clientRow = $clStmt->fetch(PDO::FETCH_ASSOC);
if (!$clientRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$assignedByCoach = ($tokenRec['role'] === 'coach')
? ($tokenRec['entityID'] ?? '')
: ($clientRow['coachID'] ?? '');
$isLegacySubmit = $submitRev < $currentRev;
if ($isLegacySubmit) {
$submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev);
if ($submitManifest === null) {
qdb_discard($tmpDb, $lockFp);
json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400);
}
} else {
$submitManifest = qdb_build_structure_manifest($pdo, $qnID, false);
$submitManifest['structureRevision'] = $currentRev;
}
$maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest);
$shortIdMap = $maps['shortIdMap'];
$shortIdToType = $maps['shortIdToType'];
$optionMap = $maps['optionMap'];
$symptomParentMap = $maps['symptomParentMap'];
$freeTextMaxLen = $maps['freeTextMaxLen'];
require_once __DIR__ . '/../lib/app_submit_validate.php';
$validationErrors = qdb_validate_app_submit_payload(
$pdo,
$qnID,
$answers,
$shortIdMap,
$shortIdToType,
$symptomParentMap,
$optionMap,
$submitManifest
);
if ($validationErrors !== []) {
qdb_discard($tmpDb, $lockFp);
json_error(
'VALIDATION_FAILED',
'Some answers are invalid or missing',
400,
['errors' => $validationErrors]
);
}
$pdo->beginTransaction();
$glassByParent = [];
$submittedQuestionIds = [];
$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
");
$answerDelete = $pdo->prepare(
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
require_once __DIR__ . '/../lib/app_answers.php';
$answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap);
foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') {
continue;
}
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
// Glass-scale symptoms use string keys (not question localIds).
if (isset($symptomParentMap[$shortId])) {
$label = trim((string)($a['answerOptionKey'] ?? $a['freeTextValue'] ?? ''));
if ($label !== '') {
$parentId = $symptomParentMap[$shortId];
$glassByParent[$parentId][$shortId] = $label;
}
continue;
}
// Resolve short ID to full questionID
$fullQID = $shortIdMap[$shortId] ?? null;
if ($fullQID === null) {
continue;
}
$answerOptionID = null;
$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 === '') {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
}
// Resolve option key to answerOptionID (points computed by scoring engine after save)
$optionKey = $a['answerOptionKey'] ?? null;
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
$opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID'];
}
$hasValue = $answerOptionID !== null
|| ($freeTextValue !== null && $freeTextValue !== '')
|| $numericValue !== null;
if (!$hasValue) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $fullQID,
':aoid' => $answerOptionID,
':ftv' => $freeTextValue,
':nv' => $numericValue,
':at' => $answeredAt,
]);
$submittedQuestionIds[$fullQID] = true;
}
foreach ($glassByParent as $parentQID => $symptomLabels) {
$json = qdb_build_glass_symptom_json($symptomLabels);
if ($json === null) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $parentQID]);
} else {
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $parentQID,
':aoid' => null,
':ftv' => $json,
':nv' => null,
':at' => $completedAt ?? $startedAt,
]);
}
$submittedQuestionIds[$parentQID] = true;
}
if (!$isLegacySubmit) {
$activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID);
$staleQuestionIds = array_diff(
array_values($activeMaps['shortIdMap']),
array_keys($submittedQuestionIds)
);
if ($staleQuestionIds !== []) {
$placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?'));
$staleDelete = $pdo->prepare(
"DELETE FROM client_answer WHERE clientCode = ? AND questionID IN ($placeholders)"
);
$staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds)));
}
}
require_once __DIR__ . '/../lib/scoring.php';
$sumPoints = $isLegacySubmit
? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest)
: qdb_compute_questionnaire_score($pdo, $clientCode, $qnID);
$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,
]);
require_once __DIR__ . '/../lib/submissions.php';
qdb_record_submission_after_submit(
$pdo,
$clientCode,
$qnID,
$tokenRec,
$startedAt,
$completedAt,
$sumPoints,
$assignedByCoach,
$submitRev,
$submitManifest,
array_keys($submittedQuestionIds)
);
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'submitted' => true,
'sumPoints' => $sumPoints,
'structureRevision' => $submitRev,
'legacySubmit' => $isLegacySubmit,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
}
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? '';
$fetchAnswers = $_GET['answers'] ?? '';
$fetchAnswersBulk = $_GET['answersBulk'] ?? '';
$fetchScoringProfiles = $_GET['scoringProfiles'] ?? '';
$fetchScoringReview = $_GET['scoringReview'] ?? '';
$clientCode = trim($_GET['clientCode'] ?? '');
if ($fetchScoringProfiles) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$profiles = array_values(array_filter(
qdb_list_scoring_profiles($pdo),
static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1
));
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']);
}
if ($fetchScoringReview) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/scoring.php';
$coachID = $tokenRec['entityID'] ?? '';
if ($clientCode !== '') {
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$clientCodes = [$clientCode];
} else {
$stmt = $pdo->prepare(
'SELECT clientCode FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
}
$clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswersBulk) {
require_role(['coach'], $tokenRec);
require_once __DIR__ . '/../lib/app_answers.php';
$clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['clients' => $clients], $tokenRec['_token']);
}
if ($fetchAnswers) {
require_role(['admin', 'supervisor', 'coach'], $tokenRec);
if ($clientCode === '') {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_PARAM', 'clientCode query param required', 400);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/app_answers.php';
$questionnaires = qdb_export_app_client_answers_bundle($pdo, $clientCode);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive([
'clientCode' => $clientCode,
'questionnaires' => $questionnaires,
], $tokenRec['_token']);
}
if ($fetchClients) {
require_role(['coach'], $tokenRec);
$coachID = $tokenRec['entityID'] ?? '';
$stmt = $pdo->prepare("
SELECT clientCode
FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode
");
$stmt->execute([':cid' => $coachID]);
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
$completions = [];
if (!empty($clientCodes)) {
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
$stmt2 = $pdo->prepare("
SELECT clientCode, questionnaireID, sumPoints, completedAt
FROM completed_questionnaire
WHERE clientCode IN ($placeholders)
");
$stmt2->execute($clientCodes);
foreach ($stmt2->fetchAll(PDO::FETCH_ASSOC) as $row) {
$completions[$row['clientCode']][] = [
'questionnaireID' => $row['questionnaireID'],
'sumPoints' => (int) $row['sumPoints'],
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
];
}
}
$clients = array_map(function ($code) use ($completions) {
return [
'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [],
];
}, $clientCodes);
qdb_discard($tmpDb, $lockFp);
json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']);
}
if ($qnID) {
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$qnRow = $qn->fetch(PDO::FETCH_ASSOC);
if (!$qnRow) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
$stmt = $pdo->prepare(
'SELECT questionID, defaultText, type, orderIndex, configJson
FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . '
ORDER BY orderIndex'
);
$stmt->execute([':id' => $qnID]);
$dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$questions = [];
foreach ($dbQuestions as $dbQ) {
$localId = $dbQ['questionID'];
$parts = explode('__', $localId);
$shortId = end($parts);
$layout = $dbQ['type'];
$config = qdb_parse_config_json($dbQ['configJson']);
$qKey = qdb_question_key($config, $dbQ['defaultText']);
$q = [
'id' => $shortId,
'layout' => $layout,
'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'],
];
if ($qKey !== '' && !empty($config['noteBefore'])) {
$q['noteBeforeKey'] = qdb_note_before_key($qKey);
}
if ($qKey !== '' && !empty($config['noteAfter'])) {
$q['noteAfterKey'] = qdb_note_after_key($qKey);
}
if (isset($config['textKey'])) $q['textKey'] = $config['textKey'];
if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1'];
if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2'];
if (isset($config['hint'])) $q['hint'] = $config['hint'];
if (isset($config['hint1'])) $q['hint1'] = $config['hint1'];
if (isset($config['hint2'])) $q['hint2'] = $config['hint2'];
if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms'];
if ($layout === 'glass_scale_question' && !empty($config['symptoms'])) {
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $config);
}
if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType'];
if (isset($config['range'])) $q['range'] = $config['range'];
if (isset($config['step'])) $q['step'] = (int)$config['step'];
if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel'];
if (isset($config['constraints'])) $q['constraints'] = $config['constraints'];
if (isset($config['precision'])) $q['precision'] = $config['precision'];
if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection'];
if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength'];
if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId'];
if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId'];
if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey'];
if ($layout === 'string_spinner' && isset($config['options'])) {
$q['options'] = $config['options'];
}
if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) {
$q['options'] = $config['valueOptions'];
}
if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) {
require_once __DIR__ . '/../lib/scoring.php';
$ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']);
if ($ruleMap !== []) {
$q['scoreRuleMap'] = $ruleMap;
}
}
$ao = $pdo->prepare(
'SELECT defaultText, points, nextQuestionId
FROM answer_option WHERE questionID = :qid AND ' . qdb_active_options_clause('answer_option') . '
ORDER BY orderIndex'
);
$ao->execute([':qid' => $dbQ['questionID']]);
$opts = $ao->fetchAll(PDO::FETCH_ASSOC);
if ($opts) {
$optionsArr = [];
$pointsMap = [];
foreach ($opts as $opt) {
$o = ['key' => $opt['defaultText']];
if ($opt['nextQuestionId'] !== '') {
$o['nextQuestionId'] = $opt['nextQuestionId'];
}
$optionsArr[] = $o;
$pointsMap[$opt['defaultText']] = (int)$opt['points'];
}
// DB answer_option rows are for choice layouts only — do not overwrite string/value spinners.
if (in_array($layout, ['radio_question', 'multi_check_box_question', 'glass_scale_question'], true)) {
$q['options'] = $optionsArr;
$q['pointsMap'] = $pointsMap;
}
}
$questions[] = $q;
}
qdb_discard($tmpDb, $lockFp);
json_success([
'meta' => ['id' => $qnID],
'structureRevision' => $structureRevision,
'questions' => $questions,
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
]);
}
// Default: ordered questionnaire list
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey,
COALESCE(structureRevision, 1) AS structureRevision
FROM questionnaire
WHERE state = 'active'
ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$list = [];
foreach ($rows as $r) {
$item = [
'id' => $r['questionnaireID'],
'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'],
'structureRevision' => max(1, (int)$r['structureRevision']),
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
];
$cat = trim($r['categoryKey'] ?? '');
if ($cat !== '') {
$item['categoryKey'] = $cat;
}
$list[] = $item;
}
qdb_discard($tmpDb, $lockFp);
json_success($list);

99
handlers/assignments.php Normal file
View File

@ -0,0 +1,99 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
if ($callerRole === 'admin') {
$coaches = $pdo->query(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY sv.username, c.username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare(
"SELECT c.coachID, c.username, c.supervisorID, sv.username AS supervisorUsername
FROM coach c
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
WHERE c.supervisorID = :sid ORDER BY c.username"
);
$stmt->execute([':sid' => $callerEntityID]);
$coaches = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
$clientStmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, co.username AS coachUsername
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.coachID, cl.clientCode"
);
foreach ($params as $k => $v) $clientStmt->bindValue($k, $v);
$clientStmt->execute();
$clients = $clientStmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches, 'clients' => $clients]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$clientCodes = $body['clientCodes'] ?? [];
$coachID = trim($body['coachID'] ?? '');
if (empty($clientCodes) || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCodes and coachID are required', 400);
}
if (!is_array($clientCodes)) $clientCodes = [$clientCodes];
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
if ($callerRole === 'supervisor') {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chk->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chk->execute([':cid' => $coachID]);
}
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
}
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec);
$pdo->beginTransaction();
$updStmt = $pdo->prepare(
"UPDATE client SET coachID = :cid WHERE clientCode = :cc AND ($rbacClause)"
);
$count = 0;
foreach ($clientCodes as $cc) {
$cc = trim($cc);
if ($cc === '') continue;
$updStmt->execute(array_merge([':cid' => $coachID, ':cc' => $cc], $rbacParams));
$count += $updStmt->rowCount();
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success(['assigned' => $count]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'assignments', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

286
handlers/auth.php Normal file
View File

@ -0,0 +1,286 @@
<?php
require_once __DIR__ . '/../lib/settings.php';
require_once __DIR__ . '/../lib/login_rate_limit.php';
require_once __DIR__ . '/../lib/keycloak_auth.php';
switch ($route) {
case 'auth/login':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
if ($username === '' || $password === '') {
json_error('MISSING_FIELDS', 'Username and password are required', 400);
}
$securitySettings = qdb_settings_get();
[$allowed, $retryAfter] = qdb_login_rate_limit_check($username, $securitySettings);
if (!$allowed) {
qdb_login_rate_limit_deny($retryAfter);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID, mustChangePassword
FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
$retryAfter = qdb_login_rate_limit_record_failure($username, $securitySettings);
if ($retryAfter > 0) {
qdb_login_rate_limit_deny($retryAfter);
}
json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401);
}
qdb_login_rate_limit_clear($username);
$assignedClients = [];
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
$cStmt = $pdo->prepare(
"SELECT clientCode FROM client
WHERE coachID = :cid AND COALESCE(archived, 0) = 0
ORDER BY clientCode"
);
$cStmt->execute([':cid' => $user['entityID']]);
$assignedClients = array_map(
fn($code) => ['clientCode' => $code],
$cStmt->fetchAll(PDO::FETCH_COLUMN)
);
}
qdb_discard($tmpDb, $lockFp);
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
if ((int)$user['mustChangePassword'] === 1) {
$tempToken = bin2hex(random_bytes(32));
token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
'temp' => true,
]);
json_success([
'mustChangePassword' => true,
'token' => $tempToken,
'user' => $username,
'role' => $user['role'],
]);
}
$token = bin2hex(random_bytes(32));
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
$loginData = [
'token' => $token,
'user' => $username,
'role' => $user['role'],
];
if ($assignedClients !== []) {
$loginData['clientsPayload'] = qdb_sensitive_envelope(
json_encode(['clients' => $assignedClients], JSON_UNESCAPED_UNICODE),
$token
);
}
json_success($loginData);
} catch (Throwable $e) {
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'auth/change-password':
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$bearerToken = get_bearer_token();
if (!$bearerToken) {
json_error('UNAUTHORIZED', 'Bearer token required', 401);
}
$tokenRec = token_get_record($bearerToken);
if (!$tokenRec) {
json_error('FORBIDDEN', 'Invalid or expired token', 403);
}
$body = read_json_body();
$username = trim($body['username'] ?? '');
$oldPassword = (string)($body['old_password'] ?? '');
$newPassword = (string)($body['new_password'] ?? '');
if ($username === '' || $oldPassword === '' || $newPassword === '') {
json_error('MISSING_FIELDS', 'username, old_password, and new_password are required', 400);
}
if (strlen($newPassword) < 6) {
json_error('PASSWORD_TOO_SHORT', 'New password must be at least 6 characters', 400);
}
if (($tokenRec['userID'] ?? '') === '') {
json_error('FORBIDDEN', 'Token not associated with a user', 403);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_CREDENTIALS', 'Invalid credentials', 401);
}
if ($user['userID'] !== $tokenRec['userID']) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Token does not match user', 403);
}
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
if (!password_verify($oldPassword, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_CREDENTIALS', 'Old password incorrect', 401);
}
$newHash = password_hash($newPassword, PASSWORD_DEFAULT);
$pdo->prepare(
"UPDATE users SET passwordHash = :h, mustChangePassword = 0 WHERE userID = :uid"
)->execute([':h' => $newHash, ':uid' => $user['userID']]);
token_revoke_all_for_user_on_pdo($pdo, $user['userID']);
qdb_save($tmpDb, $lockFp);
$securitySettings = qdb_settings_get();
// Issue a fresh session for this browser only
$newToken = bin2hex(random_bytes(32));
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
json_success([
'token' => $newToken,
'user' => $username,
'role' => $user['role'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'auth', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'auth/keycloak-config':
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$kcConfig = qdb_keycloak_config();
json_success([
'enabled' => qdb_keycloak_is_configured($kcConfig),
'displayName' => $kcConfig['display_name'],
'loginUrl' => qdb_keycloak_is_configured($kcConfig) ? 'auth/keycloak-login' : '',
]);
break;
case 'auth/keycloak-login':
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$kcConfig = qdb_keycloak_config();
if (!qdb_keycloak_is_configured($kcConfig)) {
json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503);
}
try {
$discovery = qdb_keycloak_discovery($kcConfig);
$nonce = bin2hex(random_bytes(16));
$params = [
'client_id' => $kcConfig['client_id'],
'redirect_uri' => $kcConfig['redirect_uri'],
'response_type' => 'code',
'scope' => 'openid profile email',
'state' => qdb_keycloak_create_state($nonce),
'nonce' => $nonce,
];
qdb_keycloak_redirect($discovery['authorization_endpoint'] . '?' . http_build_query($params));
} catch (Throwable $e) {
qdb_handler_fail($e, 'keycloak-login', null, null, null);
}
break;
case 'auth/keycloak-callback':
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
if (isset($_GET['error'])) {
json_error('KEYCLOAK_DENIED', 'University login was cancelled or denied', 401);
}
$code = trim((string)($_GET['code'] ?? ''));
$state = trim((string)($_GET['state'] ?? ''));
if ($code === '' || $state === '') {
json_error('MISSING_FIELDS', 'Keycloak callback requires code and state', 400);
}
$kcConfig = qdb_keycloak_config();
if (!qdb_keycloak_is_configured($kcConfig)) {
json_error('KEYCLOAK_NOT_CONFIGURED', 'University login is not configured', 503);
}
try {
qdb_keycloak_verify_state($state);
$discovery = qdb_keycloak_discovery($kcConfig);
$tokenResponse = qdb_keycloak_exchange_code($kcConfig, $discovery, $code);
$accessToken = (string)($tokenResponse['access_token'] ?? '');
if ($accessToken === '') {
json_error('KEYCLOAK_TOKEN_FAILED', 'University login did not return an access token', 401);
}
$claims = qdb_keycloak_userinfo($discovery, $accessToken);
$username = qdb_keycloak_claim_username($kcConfig, $claims);
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$stmt = $pdo->prepare(
"SELECT userID, role, entityID, mustChangePassword
FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
if (!$user) {
json_error('KEYCLOAK_USER_NOT_ALLOWED', 'University account is not registered for this application', 403);
}
qdb_reject_coach_web_login((string)($user['role'] ?? ''));
if ((int)($user['mustChangePassword'] ?? 0) === 1) {
json_error('PASSWORD_CHANGE_REQUIRED', 'Please sign in locally once to change your temporary password before using University login', 403);
}
$securitySettings = qdb_settings_get();
$token = bin2hex(random_bytes(32));
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
]);
qdb_keycloak_finish_html($token, $username, (string)$user['role']);
} catch (Throwable $e) {
qdb_handler_fail($e, 'keycloak-callback', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('NOT_FOUND', 'Unknown auth route', 404);
}

33
handlers/backup.php Normal file
View File

@ -0,0 +1,33 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$dbPath = QDB_PATH;
$backupDir = QDB_UPLOADS_DIR . '/backups';
if (!file_exists($dbPath)) {
json_error('NOT_FOUND', 'No database to back up', 404);
}
if (!is_dir($backupDir)) {
if (!mkdir($backupDir, 0755, true) && !is_dir($backupDir)) {
json_error('SERVER_ERROR', 'Could not create backups directory', 500);
}
}
$timestamp = date('Y-m-d_H-i-s');
$filename = "questionnaire_database.$timestamp";
$backupFile = "$backupDir/$filename";
if (!copy($dbPath, $backupFile)) {
json_error('SERVER_ERROR', 'Backup copy failed', 500);
}
@chmod($backupFile, 0644);
json_success(['filename' => $filename]);

239
handlers/clients.php Normal file
View File

@ -0,0 +1,239 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$clientCode = trim((string)($_GET['clientCode'] ?? ''));
if ($clientCode !== '' && !empty($_GET['detail'])) {
require_once __DIR__ . '/../lib/submissions.php';
$detail = qdb_client_detail($pdo, $tokenRec, $clientCode);
qdb_discard($tmpDb, $lockFp);
json_success($detail);
}
$archiveFilter = trim((string)($_GET['archiveFilter'] ?? 'active'));
if (!in_array($archiveFilter, ['active', 'archived', 'all'], true)) {
$archiveFilter = 'active';
}
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', $archiveFilter);
$stmt = $pdo->prepare(
"SELECT cl.clientCode, cl.coachID, cl.archived, cl.archivedAt,
co.username AS coachUsername,
CASE WHEN EXISTS (
SELECT 1 FROM completed_questionnaire cq WHERE cq.clientCode = cl.clientCode
) OR EXISTS (
SELECT 1 FROM questionnaire_submission qs WHERE qs.clientCode = cl.clientCode
) THEN 1 ELSE 0 END AS hasResponseData
FROM client cl
LEFT JOIN coach co ON co.coachID = cl.coachID
WHERE $clause
ORDER BY cl.archived ASC, hasResponseData DESC, cl.clientCode ASC"
);
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->execute();
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../lib/submissions.php';
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
foreach ($clients as &$client) {
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
$client['clientCode'],
$scoringSummary
);
}
unset($client);
qdb_discard($tmpDb, $lockFp);
json_success([
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
$coachID = trim($body['coachID'] ?? '');
if ($clientCode === '' || $coachID === '') {
json_error('MISSING_FIELDS', 'clientCode and coachID are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
// Validate coach exists and caller is allowed to assign to them
if ($callerRole === 'supervisor') {
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :sid");
$chkCoach->execute([':cid' => $coachID, ':sid' => $callerEntityID]);
} else {
$chkCoach = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid");
$chkCoach->execute([':cid' => $coachID]);
}
if (!$chkCoach->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Counselor not found or not authorized', 404);
}
$chk = $pdo->prepare("SELECT clientCode FROM client WHERE clientCode = :cc");
$chk->execute([':cc' => $clientCode]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('DUPLICATE', 'Client code already exists', 409);
}
$pdo->prepare(
"INSERT INTO client (clientCode, coachID, archived, archivedAt) VALUES (:cc, :cid, 0, 0)"
)->execute([':cc' => $clientCode, ':cid' => $coachID]);
qdb_save($tmpDb, $lockFp);
json_success(['clientCode' => $clientCode, 'coachID' => $coachID]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
$body = read_json_body();
$clientCode = trim((string)($body['clientCode'] ?? ''));
$profileID = trim((string)($body['profileID'] ?? ''));
$coachBand = trim((string)($body['coachBand'] ?? ''));
if ($clientCode === '' || $profileID === '' || $coachBand === '') {
json_error('MISSING_FIELDS', 'clientCode, profileID, and coachBand are required', 400);
}
$calculatedBand = trim((string)($body['calculatedBand'] ?? ''));
$weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null;
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
$chk = $pdo->prepare(
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
);
$chk->execute(array_merge([':cc' => $clientCode], $params));
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/scoring.php';
if (!qdb_is_valid_scoring_band($coachBand)) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400);
}
$profile = qdb_save_coach_scoring_review(
$pdo,
$clientCode,
$profileID,
$coachBand,
(string)($tokenRec['userID'] ?? ''),
$weightedTotal,
$calculatedBand !== '' ? $calculatedBand : null,
);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => $profile]);
} catch (InvalidArgumentException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('INVALID_FIELD', $e->getMessage(), 400);
} catch (RuntimeException $e) {
qdb_discard($tmpDb ?? null, $lockFp ?? null);
json_error('NOT_FOUND', $e->getMessage(), 404);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PATCH':
$body = read_json_body();
$clientCode = trim((string)($body['clientCode'] ?? ''));
if ($clientCode === '' || !array_key_exists('archived', $body)) {
json_error('MISSING_FIELDS', 'clientCode and archived are required', 400);
}
$archived = !empty($body['archived']) ? 1 : 0;
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
[$clause, $params] = rbac_client_filter($tokenRec, 'cl', 'all');
$chk = $pdo->prepare(
"SELECT clientCode, archived FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
);
$chk->execute(array_merge([':cc' => $clientCode], $params));
$row = $chk->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
$archivedAt = $archived ? time() : 0;
$pdo->prepare(
'UPDATE client SET archived = :a, archivedAt = :at WHERE clientCode = :cc'
)->execute([':a' => $archived, ':at' => $archivedAt, ':cc' => $clientCode]);
qdb_save($tmpDb, $lockFp);
json_success([
'clientCode' => $clientCode,
'archived' => $archived,
'archivedAt' => $archivedAt,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
$body = read_json_body();
$clientCode = trim($body['clientCode'] ?? '');
if ($clientCode === '') {
json_error('MISSING_FIELDS', 'clientCode is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
// Verify the client exists and the caller can see it (RBAC)
[$clause, $params] = rbac_client_filter($tokenRec, 'cl');
$chk = $pdo->prepare(
"SELECT clientCode FROM client cl WHERE cl.clientCode = :cc AND ($clause)"
);
$chk->execute(array_merge([':cc' => $clientCode], $params));
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/submissions.php';
$pdo->beginTransaction();
qdb_delete_client_response_data($pdo, [$clientCode]);
$pdo->prepare('DELETE FROM client WHERE clientCode = :cc')
->execute([':cc' => $clientCode]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

29
handlers/coaches.php Normal file
View File

@ -0,0 +1,29 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
require_once __DIR__ . '/../lib/analytics.php';
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$coachID = trim((string)($_GET['coachID'] ?? ''));
if ($coachID !== '' && !empty($_GET['recent'])) {
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 100;
$offset = isset($_GET['offset']) ? (int)$_GET['offset'] : 0;
$recent = qdb_coach_recent_submissions($pdo, $tokenRec, $coachID, $limit, $offset);
qdb_discard($tmpDb, $lockFp);
json_success($recent);
}
$coaches = qdb_coach_activity_list($pdo, $tokenRec);
qdb_discard($tmpDb, $lockFp);
json_success(['coaches' => $coaches]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load coaches', null, $tmpDb ?? null, $lockFp ?? null);
}

53
handlers/dev.php Normal file
View File

@ -0,0 +1,53 @@
<?php
/**
* Admin-only dev fixture import (local / test environments).
*/
require_once __DIR__ . '/../lib/dev_fixture.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
$action = trim((string)($body['action'] ?? 'import'));
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
if ($action === 'remove') {
$result = qdb_remove_dev_test_data($pdo, [
'usernamePrefix' => $body['usernamePrefix'] ?? 'dev_',
'clientCodePrefix' => $body['clientCodePrefix'] ?? 'DEV-CL-',
]);
qdb_save($tmpDb, $lockFp);
json_success($result);
}
if ($action === 'wipeExceptAdmins') {
$result = qdb_wipe_all_data_except_admins($pdo);
qdb_save($tmpDb, $lockFp);
json_success($result);
}
$fixture = $body['fixture'] ?? null;
if (!is_array($fixture)) {
qdb_discard($tmpDb, $lockFp);
json_error('MISSING_FIELDS', 'fixture object is required for import', 400);
}
$result = qdb_import_dev_fixture($pdo, $fixture);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
$labels = [
'remove' => 'Remove dev data',
'wipeExceptAdmins' => 'Wipe database',
'import' => 'Import dev fixture',
];
$label = $labels[$action ?? 'import'] ?? 'Dev fixture';
qdb_handler_fail($e, $label, $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}

111
handlers/export.php Normal file
View File

@ -0,0 +1,111 @@
<?php
require_once __DIR__ . '/../lib/submissions.php';
$tokenRec = require_valid_token_web();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
if (!empty($_GET['bundle'])) {
require_role(['admin', 'supervisor'], $tokenRec);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$bundle = qdb_export_all_questionnaires_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
$filename = 'questionnaires_bundle_' . date('Y-m-d_His') . '.json';
qdb_http_download(
json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
[
'Content-Type' => 'application/json; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export questionnaires bundle', null, $tmpDb ?? null, $lockFp ?? null);
}
}
if (!empty($_GET['exportAll'])) {
require_role(['admin'], $tokenRec);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$allVersions = !empty($_GET['allVersions']);
$zipPath = qdb_build_server_export_zip($pdo, $tokenRec, $allVersions);
qdb_discard($tmpDb, $lockFp);
$label = $allVersions ? 'all_versions' : 'current';
$filename = 'responses_export_' . $label . '_' . date('Y-m-d_His') . '.zip';
$zipBody = (string)file_get_contents($zipPath);
@unlink($zipPath);
qdb_http_download($zipBody, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string)strlen($zipBody),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export responses ZIP', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
$allVersions = !empty($_GET['allVersions']);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$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);
qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_all_versions_' . date('Y-m-d') . '.csv';
qdb_http_download(
qdb_export_rows_to_csv_string($rows, qdb_export_all_versions_csv_fallback_header($resultColumns)),
[
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
$rows = qdb_export_current_rows($pdo, $qnID, $questions, $resultColumns, $optionTextMap, $tokenRec);
qdb_discard($tmpDb, $lockFp);
$safeName = qdb_export_safe_basename($questionnaire['name']);
$filename = $safeName . '_v' . $questionnaire['version'] . '.csv';
qdb_http_download(
qdb_export_rows_to_csv_string($rows, qdb_export_current_csv_fallback_header($resultColumns)),
[
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export CSV', null, $tmpDb ?? null, $lockFp ?? null);
}

18
handlers/logout.php Normal file
View File

@ -0,0 +1,18 @@
<?php
if ($method !== 'DELETE' && $method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$token = get_bearer_token();
if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
try {
token_revoke($token);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Logout');
}
json_success(['loggedOut' => true]);

244
handlers/questionnaires.php Normal file
View File

@ -0,0 +1,244 @@
<?php
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey,
COUNT(qu.questionID) AS questionCount,
(
SELECT COUNT(*)
FROM completed_questionnaire cq
INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE cq.questionnaireID = q.questionnaireID
AND $rbacClause
) AS completedCount
FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID
ORDER BY q.orderIndex, q.name
";
$stmt = $pdo->prepare($sql);
$stmt->execute($rbacParams);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as &$r) {
$r['orderIndex'] = (int)$r['orderIndex'];
$r['showPoints'] = (int)$r['showPoints'];
$r['questionCount'] = (int)$r['questionCount'];
$r['completedCount'] = (int)$r['completedCount'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}';
}
unset($r);
$categoryKeys = qdb_list_category_keys_for_dashboard($pdo);
qdb_discard($tmpDb, $lockFp);
json_success(['questionnaires' => $rows, 'categoryKeys' => $categoryKeys]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load questionnaires', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (($body['action'] ?? '') === 'updateConditionMessageLabel') {
$messageKey = trim((string)($body['messageKey'] ?? ''));
$germanLabel = (string)($body['germanLabel'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
qdb_set_app_string_german_label($pdo, $messageKey, $germanLabel);
qdb_save($tmpDb, $lockFp);
json_success([
'messageKey' => qdb_validate_stable_key($messageKey, 'messageKey'),
'germanLabel' => trim($germanLabel),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update condition message label', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'updateCategoryLabel') {
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
$germanLabel = (string)($body['germanLabel'] ?? '');
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$stringKey = qdb_set_category_german_label($pdo, $categoryKey, $germanLabel);
qdb_save($tmpDb, $lockFp);
json_success([
'categoryKey' => $categoryKey,
'stringKey' => $stringKey,
'germanLabel' => trim($germanLabel),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update category label', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'deleteCategoryKey') {
$categoryKey = trim((string)($body['categoryKey'] ?? ''));
if ($categoryKey === '') {
json_error('INVALID_FIELD', 'categoryKey is required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$cleared = qdb_delete_category_key($pdo, $categoryKey);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => $categoryKey, 'questionnairesCleared' => $cleared]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete category key', null, $tmpDb, $lockFp);
}
break;
}
if (($body['action'] ?? '') === 'importBundle') {
$bundle = $body['bundle'] ?? null;
if (!is_array($bundle)) {
json_error('MISSING_FIELDS', 'bundle object is required', 400);
}
$replaceIfExists = !empty($body['replaceIfExists']);
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$result = qdb_import_questionnaires_bundle($pdo, $bundle, $replaceIfExists);
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Import questionnaires', null, $tmpDb, $lockFp);
}
break;
}
if (empty($body['name'])) {
json_error('NAME_REQUIRED', 'name is required', 400);
}
$id = bin2hex(random_bytes(16));
$name = trim($body['name']);
$version = trim($body['version'] ?? '');
$state = trim($body['state'] ?? 'draft');
$order = (int)($body['orderIndex'] ?? 0);
$showPts = (int)($body['showPoints'] ?? 0);
$condJson = $body['conditionJson'] ?? '{}';
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
if ($order === 0) {
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
}
$catKey = trim($body['categoryKey'] ?? '');
if ($catKey !== '') {
$catKey = qdb_normalize_stable_key($catKey);
}
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
'state' => $state, 'orderIndex' => $order, 'showPoints' => $showPts,
'conditionJson' => $condJson, 'questionCount' => 0,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create questionnaire', null, $tmpDb, $lockFp);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$existing = $pdo->prepare('SELECT * FROM questionnaire WHERE questionnaireID = :id');
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$name = trim($body['name'] ?? $row['name']);
$version = trim($body['version'] ?? $row['version']);
$state = trim($body['state'] ?? $row['state']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$showPts = (int)($body['showPoints'] ?? $row['showPoints']);
$condJson = $body['conditionJson'] ?? $row['conditionJson'];
$catKey = array_key_exists('categoryKey', $body)
? trim((string)$body['categoryKey'])
: trim($row['categoryKey'] ?? '');
if ($catKey !== '') {
$catKey = qdb_normalize_stable_key($catKey);
}
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck
WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]);
qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
'orderIndex' => $order, 'showPoints' => $showPts, 'conditionJson' => $condJson,
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update questionnaire', null, $tmpDb, $lockFp);
}
break;
case 'DELETE':
require_role(['admin'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID'])) {
json_error('QUESTIONNAIRE_ID_REQUIRED', 'questionnaireID is required', 400);
}
$id = $body['questionnaireID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$pdo->exec('PRAGMA foreign_keys = OFF;');
$pdo->beginTransaction();
$qIds = $pdo->prepare('SELECT questionID FROM question WHERE questionnaireID = :id');
$qIds->execute([':id' => $id]);
$questionIDs = $qIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($questionIDs as $qid) {
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :qid');
$aoIds->execute([':qid' => $qid]);
$optionIDs = $aoIds->fetchAll(PDO::FETCH_COLUMN);
foreach ($optionIDs as $aoid) {
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $aoid]);
}
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :qid')->execute([':qid' => $qid]);
$pdo->prepare('DELETE FROM client_answer WHERE questionID = :qid')->execute([':qid' => $qid]);
}
$pdo->prepare('DELETE FROM question WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM completed_questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM questionnaire WHERE questionnaireID = :id')->execute([':id' => $id]);
$pdo->commit();
$pdo->exec('PRAGMA foreign_keys = ON;');
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete questionnaire', $pdo, $tmpDb, $lockFp);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

357
handlers/questions.php Normal file
View File

@ -0,0 +1,357 @@
<?php
require_once __DIR__ . '/../lib/scoring.php';
require_once __DIR__ . '/../lib/questionnaire_structure.php';
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
$qnID = $_GET['questionnaireID'] ?? '';
if (!$qnID) {
json_error('BAD_REQUEST', 'questionnaireID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$includeRetired = !empty($_GET['includeRetired']);
$qWhere = 'questionnaireID = :qid';
if (!$includeRetired) {
$qWhere .= ' AND ' . qdb_active_questions_clause('question');
}
$stmt = $pdo->prepare(
"SELECT questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson,
retiredAt, retiredInRevision
FROM question WHERE $qWhere ORDER BY orderIndex"
);
$stmt->execute([':qid' => $qnID]);
$questions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID);
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$q['retiredAt'] = (int)($q['retiredAt'] ?? 0);
$q['retiredInRevision'] = (int)($q['retiredInRevision'] ?? 0);
$q['retired'] = $q['retiredAt'] > 0;
$cfg = qdb_parse_config_json($q['configJson']);
$q['configJson'] = json_encode($cfg, JSON_UNESCAPED_UNICODE);
$q['questionKey'] = qdb_question_key($cfg, $q['defaultText']);
$q['localId'] = qdb_question_local_id($q['questionID'], $qnID);
$ao = $pdo->prepare(
"SELECT answerOptionID, questionID, defaultText, points, orderIndex, nextQuestionId, retiredAt
FROM answer_option WHERE questionID = :qid AND " . qdb_active_options_clause('answer_option') . '
ORDER BY orderIndex'
);
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
$opt['optionKey'] = qdb_option_key($opt);
$opt['labelGerman'] = qdb_option_german_label($pdo, $opt['answerOptionID'], $opt['defaultText']);
}
unset($opt);
if (($q['type'] ?? '') === 'glass_scale_question') {
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]);
$q['translations'] = $tr->fetchAll(PDO::FETCH_ASSOC);
}
unset($q);
qdb_discard($tmpDb, $lockFp);
json_success([
'questions' => $questions,
'structureRevision' => $structureRevision,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID']) || !isset($body['defaultText']) || empty($body['questionKey'])) {
json_error('BAD_REQUEST', 'questionnaireID, questionKey, and defaultText required', 400);
}
$qnID = $body['questionnaireID'];
$text = trim($body['defaultText']);
qdb_require_non_empty_german($text);
$questionKey = qdb_validate_stable_key((string)$body['questionKey'], 'Question key');
$type = trim($body['type'] ?? '');
$order = (int)($body['orderIndex'] ?? 0);
$req = (int)($body['isRequired'] ?? 0);
$cfg = qdb_parse_config_json($body['configJson'] ?? '{}');
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfg);
if ($type === 'glass_scale_question') {
$cfg = qdb_apply_glass_symptoms_config($cfg, $glassSymptoms);
}
$config = qdb_normalize_question_config(
$cfg,
$questionKey,
$body['noteBefore'] ?? ($cfg['noteBefore'] ?? null),
$body['noteAfter'] ?? ($cfg['noteAfter'] ?? null)
);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$chk = $pdo->prepare("SELECT 1 FROM questionnaire WHERE questionnaireID = :id");
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
if ($order === 0) {
$max = $pdo->prepare("SELECT COALESCE(MAX(orderIndex),0)+1 FROM question WHERE questionnaireID = :id");
$max->execute([':id' => $qnID]);
$order = (int)$max->fetchColumn();
}
$localId = trim($body['localId'] ?? '');
if ($localId === '') {
$localId = qdb_allocate_question_local_id($pdo, $qnID);
}
$id = qdb_make_question_id($qnID, $localId);
$dup = $pdo->prepare('SELECT 1 FROM question WHERE questionID = :id');
$dup->execute([':id' => $id]);
if ($dup->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('CONFLICT', "Question id \"$localId\" already exists in this questionnaire", 409);
}
$pdo->prepare("INSERT INTO question (questionID, questionnaireID, defaultText, type, orderIndex, isRequired, configJson)
VALUES (:id, :qid, :t, :ty, :o, :r, :cj)")
->execute([':id' => $id, ':qid' => $qnID, ':t' => $text, ':ty' => $type,
':o' => $order, ':r' => $req, ':cj' => $configJson]);
qdb_upsert_source_translation($pdo, 'question', $id, $text);
qdb_sync_question_note_strings($pdo, $questionKey, $config);
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_added');
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev,
'question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
'questionKey' => $questionKey, 'localId' => $localId,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$existing = $pdo->prepare("SELECT * FROM question WHERE questionID = :id");
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$text = trim($body['defaultText'] ?? $row['defaultText']);
qdb_require_non_empty_german($text);
$type = trim($body['type'] ?? $row['type']);
$order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$req = (int)($body['isRequired'] ?? $row['isRequired']);
$oldCfg = qdb_parse_config_json($row['configJson']);
$questionKey = isset($body['questionKey'])
? qdb_validate_stable_key((string)$body['questionKey'], 'Question key')
: qdb_question_key($oldCfg, $row['defaultText']);
if ($questionKey === '') {
json_error('MISSING_FIELDS', 'questionKey is required', 400);
}
$cfgIn = isset($body['configJson']) ? qdb_parse_config_json($body['configJson']) : $oldCfg;
$glassSymptoms = qdb_parse_glass_symptoms_body($body, $cfgIn);
if ($type === 'glass_scale_question') {
$cfgIn = qdb_apply_glass_symptoms_config($cfgIn, $glassSymptoms);
}
$config = qdb_normalize_question_config(
$cfgIn,
$questionKey,
$body['noteBefore'] ?? ($cfgIn['noteBefore'] ?? null),
$body['noteAfter'] ?? ($cfgIn['noteAfter'] ?? null)
);
$configJson = json_encode($config, JSON_UNESCAPED_UNICODE);
$oldKey = qdb_question_key($oldCfg, $row['defaultText']);
$oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE);
$structuralChange = ($type !== (string)$row['type'])
|| ($req !== (int)$row['isRequired'])
|| ($questionKey !== $oldKey)
|| ($newSymptoms !== $oldSymptoms);
if ((int)($row['retiredAt'] ?? 0) > 0) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'Cannot edit a retired question', 400);
}
$pdo->prepare("UPDATE question SET defaultText = :t, type = :ty, orderIndex = :o, isRequired = :r, configJson = :cj WHERE questionID = :id")
->execute([':t' => $text, ':ty' => $type, ':o' => $order, ':r' => $req, ':cj' => $configJson, ':id' => $id]);
qdb_upsert_source_translation($pdo, 'question', $id, $text);
qdb_sync_question_note_strings($pdo, $questionKey, $config);
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
$newRev = null;
if ($structuralChange) {
$newRev = qdb_bump_structure_revision($pdo, (string)$row['questionnaireID'], 'question_updated');
}
qdb_save($tmpDb, $lockFp);
json_success([
'structureRevision' => $newRev ?? qdb_questionnaire_structure_revision($pdo, (string)$row['questionnaireID']),
'question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
'defaultText' => $text, 'questionKey' => $questionKey,
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson,
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionID'])) {
json_error('BAD_REQUEST', 'questionID is required', 400);
}
$id = $body['questionID'];
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$existing = $pdo->prepare('SELECT questionnaireID, retiredAt FROM question WHERE questionID = :id');
$existing->execute([':id' => $id]);
$row = $existing->fetch(PDO::FETCH_ASSOC);
if (!$row) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Question not found', 404);
}
$qnID = (string)$row['questionnaireID'];
if ((int)($row['retiredAt'] ?? 0) > 0) {
qdb_discard($tmpDb, $lockFp);
json_success(['deleted' => false, 'retired' => true, 'structureRevision' => qdb_questionnaire_structure_revision($pdo, $qnID)]);
break;
}
$pdo->beginTransaction();
$newRev = qdb_bump_structure_revision($pdo, $qnID, 'question_removed');
if (qdb_question_has_client_data($pdo, $id)) {
qdb_retire_question($pdo, $id, $newRev);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'deleted' => false,
'retired' => true,
'structureRevision' => $newRev,
]);
break;
}
$pdo->exec('PRAGMA foreign_keys = OFF;');
$aoIds = $pdo->prepare('SELECT answerOptionID FROM answer_option WHERE questionID = :id');
$aoIds->execute([':id' => $id]);
foreach ($aoIds->fetchAll(PDO::FETCH_COLUMN) as $aoid) {
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id')->execute([':id' => $aoid]);
}
$pdo->prepare('DELETE FROM answer_option WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question_score_rule WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id')->execute([':id' => $id]);
$pdo->prepare('DELETE FROM question WHERE questionID = :id')->execute([':id' => $id]);
$pdo->exec('PRAGMA foreign_keys = ON;');
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([
'deleted' => true,
'retired' => false,
'structureRevision' => $newRev,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete question', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PATCH':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (empty($body['questionnaireID']) || !is_array($body['order'] ?? null)) {
json_error('BAD_REQUEST', 'questionnaireID and order[] required', 400);
}
$qnID = (string)$body['questionnaireID'];
$orderIds = array_values(array_filter(
array_map('strval', $body['order']),
static fn($id) => $id !== ''
));
if ($orderIds === []) {
json_error('BAD_REQUEST', 'order[] must list at least one questionID', 400);
}
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$chk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id');
$chk->execute([':id' => $qnID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$ph = implode(',', array_fill(0, count($orderIds), '?'));
$stmt = $pdo->prepare(
"SELECT questionID FROM question WHERE questionnaireID = ? AND questionID IN ($ph)"
);
$stmt->execute(array_merge([$qnID], $orderIds));
$found = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (count($found) !== count($orderIds)) {
$missing = array_values(array_diff($orderIds, $found));
qdb_discard($tmpDb, $lockFp);
json_error(
'INVALID_FIELD',
'order[] contains questionIDs not in this questionnaire',
400,
['missingQuestionIDs' => $missing]
);
}
$upd = $pdo->prepare(
'UPDATE question SET orderIndex = :o WHERE questionID = :id AND questionnaireID = :qid'
);
foreach ($orderIds as $idx => $qid) {
$upd->execute([':o' => $idx, ':id' => $qid, ':qid' => $qnID]);
}
qdb_save($tmpDb, $lockFp);
json_success(['reordered' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Reorder questions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

124
handlers/results.php Normal file
View File

@ -0,0 +1,124 @@
<?php
$tokenRec = require_valid_token_web();
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$qnID = $_GET['questionnaireID'] ?? '';
$clientCode = $_GET['clientCode'] ?? '';
if (!$qnID) {
json_error('MISSING_PARAM', 'questionnaireID query param required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id");
$qn->execute([':id' => $qnID]);
$questionnaire = $qn->fetch(PDO::FETCH_ASSOC);
if (!$questionnaire) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$qStmt = $pdo->prepare("
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
");
$qStmt->execute([':id' => $qnID]);
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
$questionIDs = array_column($questions, 'questionID');
foreach ($questions as &$q) {
$q['isRequired'] = (int)$q['isRequired'];
$q['orderIndex'] = (int)$q['orderIndex'];
$cfg = json_decode($q['configJson'] ?? '{}', true) ?: [];
$q['questionKey'] = qdb_question_column_label($cfg, $q['defaultText'], $q['questionID'], $qnID);
$ao = $pdo->prepare("SELECT answerOptionID, defaultText, points, orderIndex
FROM answer_option WHERE questionID = :qid ORDER BY orderIndex");
$ao->execute([':qid' => $q['questionID']]);
$q['answerOptions'] = $ao->fetchAll(PDO::FETCH_ASSOC);
foreach ($q['answerOptions'] as &$opt) {
$opt['points'] = (int)$opt['points'];
$opt['orderIndex'] = (int)$opt['orderIndex'];
}
}
unset($q);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT cl.clientCode, cl.coachID,
co.username AS coachUsername,
sv.username AS supervisorUsername,
cq.status, cq.startedAt, cq.completedAt, cq.sumPoints, cq.assignedByCoach
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
";
$params = array_merge([':qnid' => $qnID], $rbacParams);
if ($clientCode) {
$sql .= " AND cl.clientCode = :cc";
$params[':cc'] = $clientCode;
}
$sql .= " ORDER BY cl.clientCode";
$cStmt = $pdo->prepare($sql);
$cStmt->execute($params);
$clients = $cStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($questionIDs) && !empty($clients)) {
$qPlaceholders = implode(',', array_fill(0, count($questionIDs), '?'));
$answerStmt = $pdo->prepare("
SELECT clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt
FROM client_answer
WHERE clientCode = ? AND questionID IN ($qPlaceholders)
");
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$bindParams = array_merge([$c['clientCode']], $questionIDs);
$answerStmt->execute($bindParams);
$answers = $answerStmt->fetchAll(PDO::FETCH_ASSOC);
$answerMap = [];
foreach ($answers as $a) {
$answerMap[$a['questionID']] = [
'answerOptionID' => $a['answerOptionID'],
'freeTextValue' => $a['freeTextValue'],
'numericValue' => $a['numericValue'] !== null ? (float)$a['numericValue'] : null,
'answeredAt' => $a['answeredAt'] !== null ? (int)$a['answeredAt'] : null,
];
}
$c['answers'] = $answerMap;
}
unset($c);
} else {
foreach ($clients as &$c) {
$c['sumPoints'] = $c['sumPoints'] !== null ? (int)$c['sumPoints'] : null;
$c['startedAt'] = $c['startedAt'] !== null ? (int)$c['startedAt'] : null;
$c['completedAt'] = $c['completedAt'] !== null ? (int)$c['completedAt'] : null;
$c['answers'] = (object)[];
}
unset($c);
}
qdb_discard($tmpDb, $lockFp);
json_success([
'questionnaire' => $questionnaire,
'questions' => $questions,
'clients' => $clients,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load results', null, $tmpDb ?? null, $lockFp ?? null);
}

View File

@ -0,0 +1,203 @@
<?php
/**
* Scoring profiles CRUD (weighted multi-questionnaire Green/Yellow/Red bands).
*/
require_once __DIR__ . '/../lib/scoring.php';
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$profileID = trim($_GET['id'] ?? '');
if ($profileID !== '') {
$profile = qdb_get_scoring_profile($pdo, $profileID);
qdb_discard($tmpDb, $lockFp);
if (!$profile) {
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
json_success(['profile' => $profile]);
}
json_success(['profiles' => qdb_list_scoring_profiles($pdo)]);
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'List scoring profiles', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$name = trim($body['name'] ?? '');
if ($name === '') {
json_error('BAD_REQUEST', 'name is required', 400);
}
$bands = qdb_parse_scoring_bands_body($body);
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = qdb_parse_profile_questionnaires($body['questionnaires'] ?? []);
if ($members === []) {
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$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, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => $name,
':desc' => trim($body['description'] ?? ''),
':act' => !empty($body['isActive']) ? 1 : 0,
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ca' => $now,
':ua' => $now,
]);
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
qdb_recompute_all_profiles_after_change($pdo);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$existing = qdb_get_scoring_profile($pdo, $profileID);
if (!$existing) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
$name = trim($body['name'] ?? $existing['name']);
$bands = qdb_normalize_scoring_bands(array_merge($existing, $body));
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = isset($body['questionnaires'])
? qdb_parse_profile_questionnaires($body['questionnaires'])
: null;
if ($members !== null && $members === []) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
$pdo->prepare(
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
updatedAt = :ua WHERE profileID = :id'
)->execute([
':name' => $name,
':desc' => trim($body['description'] ?? $existing['description']),
':act' => isset($body['isActive']) ? (!empty($body['isActive']) ? 1 : 0) : $existing['isActive'],
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ua' => time(),
':id' => $profileID,
]);
if ($members !== null) {
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
}
qdb_recompute_all_profiles_after_change($pdo, $profileID);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
*/
function qdb_parse_profile_questionnaires(array $rows): array {
$out = [];
$idx = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$qnID = trim((string)($row['questionnaireID'] ?? ''));
if ($qnID === '') {
continue;
}
$weight = (float)($row['weight'] ?? 1.0);
if ($weight <= 0) {
$weight = 1.0;
}
$out[] = [
'questionnaireID' => $qnID,
'weight' => $weight,
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
];
$idx++;
}
return $out;
}
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
->execute([':pid' => $profileID]);
$ins = $pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, :w, :o)'
);
foreach ($members as $m) {
$ins->execute([
':pid' => $profileID,
':qn' => $m['questionnaireID'],
':w' => $m['weight'],
':o' => $m['orderIndex'],
]);
}
}
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
->fetchAll(PDO::FETCH_COLUMN);
foreach ($clients as $clientCode) {
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
}
}

44
handlers/session.php Normal file
View File

@ -0,0 +1,44 @@
<?php
/**
* GET /api/session — verify Bearer token is still active and return role metadata.
*/
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$token = get_bearer_token();
if (!$token) {
json_error('UNAUTHORIZED', 'Missing Bearer token', 401);
}
$rec = token_get_record($token);
if (!$rec) {
json_error('UNAUTHORIZED', 'Invalid or expired token', 401);
}
$role = (string)($rec['role'] ?? '');
$username = '';
if (($rec['userID'] ?? '') !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$stmt = $pdo->prepare('SELECT username FROM users WHERE userID = :uid');
$stmt->execute([':uid' => $rec['userID']]);
$row = $stmt->fetchColumn();
$username = $row !== false ? (string)$row : '';
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Session check', null, $tmpDb ?? null, $lockFp ?? null);
}
}
json_success([
'valid' => true,
'user' => $username,
'role' => $role,
'userID' => $rec['userID'] ?? '',
'mustChangePassword' => !empty($rec['temp']),
]);

70
handlers/settings.php Normal file
View File

@ -0,0 +1,70 @@
<?php
require_once __DIR__ . '/../lib/settings.php';
$tokenRec = require_valid_token_web();
require_role(['admin'], $tokenRec);
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$settings = qdb_settings_get_on_pdo($pdo);
$sessionCount = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn();
qdb_discard($tmpDb, $lockFp);
json_success([
'settings' => $settings,
'activeSessions' => $sessionCount,
'defaults' => qdb_settings_defaults(),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load settings', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
case 'PATCH':
$body = read_json_body();
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$current = qdb_settings_get_on_pdo($pdo);
$merged = qdb_settings_validate_and_merge($body, $current);
qdb_settings_save_on_pdo($pdo, $merged);
qdb_save($tmpDb, $lockFp);
json_success(['settings' => $merged]);
} catch (InvalidArgumentException $e) {
json_error('INVALID_FIELD', $e->getMessage(), 400);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save settings', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$action = trim((string)($body['action'] ?? ''));
if ($action !== 'revokeAllSessions') {
json_error('BAD_REQUEST', 'Unknown action', 400);
}
$confirm = trim((string)($body['confirmPhrase'] ?? ''));
$revokeAllPhrase = 'REVOKE ALL SESSIONS';
if ($confirm !== $revokeAllPhrase) {
json_error(
'CONFIRMATION_REQUIRED',
'Type exactly "' . $revokeAllPhrase . '" to revoke every session',
400
);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$removed = token_revoke_all_on_pdo($pdo);
qdb_save($tmpDb, $lockFp);
json_success(['revokedSessions' => $removed]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Revoke all sessions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

302
handlers/translations.php Normal file
View File

@ -0,0 +1,302 @@
<?php
$tokenRec = require_valid_token_web();
$validTypes = ['question', 'answer_option', 'string', 'app_string', 'language'];
switch ($method) {
case 'GET':
if (!empty($_GET['exportBundle'])) {
require_role(['admin', 'supervisor'], $tokenRec);
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$bundle = qdb_export_translations_bundle($pdo);
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Export translations', null, $tmpDb ?? null, $lockFp ?? null);
}
$filename = 'translations_bundle_' . date('Y-m-d_His') . '.json';
qdb_http_download(
json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
[
'Content-Type' => 'application/json; charset=UTF-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]
);
}
if (isset($_GET['languages'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$rows = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(['languages' => $rows]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load languages', null, $tmpDb ?? null, $lockFp ?? null);
}
}
if (!empty($_GET['all'])) {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
}
$qnRows = $pdo->query("
SELECT questionnaireID, name, orderIndex FROM questionnaire ORDER BY orderIndex
")->fetchAll(PDO::FETCH_ASSOC);
$appEntries = qdb_app_ui_string_entries_for_website($pdo);
$appTranslations = qdb_load_translations_for_entries($pdo, $appEntries);
qdb_attach_translation_texts($appEntries, $appTranslations);
$questionnaires = [];
$allEntries = $appEntries;
foreach ($qnRows as $qn) {
$lists = qdb_translation_entry_lists($pdo, $qn['questionnaireID']);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
if (!$entries) {
continue;
}
$translations = qdb_load_translations_for_entries($pdo, $entries);
qdb_attach_translation_texts($entries, $translations);
$questionnaires[] = [
'questionnaireID' => $qn['questionnaireID'],
'name' => $qn['name'],
'orderIndex' => (int)$qn['orderIndex'],
'entries' => $entries,
];
foreach ($entries as $e) {
$allEntries[] = $e;
}
}
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $languages,
'appStrings' => $appEntries,
'questionnaires' => $questionnaires,
'entries' => $allEntries,
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$qnID = trim((string)($_GET['questionnaireID'] ?? ''));
if ($qnID !== '') {
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
$languages = $pdo->query("SELECT languageCode, name FROM language ORDER BY languageCode")->fetchAll(PDO::FETCH_ASSOC);
if (!array_filter($languages, fn($l) => $l['languageCode'] === QDB_SOURCE_LANGUAGE)) {
array_unshift($languages, ['languageCode' => QDB_SOURCE_LANGUAGE, 'name' => 'German']);
}
$qnRow = $pdo->prepare('SELECT name FROM questionnaire WHERE questionnaireID = :id');
$qnRow->execute([':id' => $qnID]);
$qnName = $qnRow->fetchColumn();
if ($qnName === false) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Questionnaire not found', 404);
}
$lists = qdb_translation_entry_lists($pdo, $qnID);
$entries = array_merge($lists['stringEntries'], $lists['contentEntries']);
$translations = qdb_load_translations_for_entries($pdo, $entries);
qdb_attach_translation_texts($entries, $translations);
qdb_discard($tmpDb, $lockFp);
json_success([
'languages' => $languages,
'entries' => $entries,
'questionnaire' => ['questionnaireID' => $qnID, 'name' => (string)$qnName],
'sourceLanguage' => QDB_SOURCE_LANGUAGE,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
}
}
$type = $_GET['type'] ?? '';
$id = $_GET['id'] ?? '';
if (!in_array($type, $validTypes, true) || (!$id && $type !== 'string')) {
json_error('BAD_REQUEST', 'type (question|answer_option|string) and id required', 400);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if ($type === 'question') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :id");
$stmt->execute([':id' => $id]);
} elseif ($type === 'answer_option') {
$stmt = $pdo->prepare("SELECT languageCode, text FROM answer_option_translation WHERE answerOptionID = :id");
$stmt->execute([':id' => $id]);
} else {
if ($id) {
$stmt = $pdo->prepare("SELECT stringKey, languageCode, text FROM string_translation WHERE stringKey = :id");
$stmt->execute([':id' => $id]);
} else {
$stmt = $pdo->query("SELECT stringKey, languageCode, text FROM string_translation ORDER BY stringKey, languageCode");
}
}
if ($type === 'question' || $type === 'answer_option') {
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
qdb_discard($tmpDb, $lockFp);
json_success(['translations' => $rows]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load translations', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
if (($body['action'] ?? '') === 'importBundle') {
$bundle = $body['bundle'] ?? null;
if (!is_array($bundle)) {
json_error('MISSING_FIELDS', 'bundle object is required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$result = qdb_import_translations_bundle($pdo, $bundle);
qdb_save($tmpDb, $lockFp);
json_success($result);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Import translations', null, $tmpDb, $lockFp);
}
break;
}
json_error('BAD_REQUEST', 'Unknown action', 400);
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
$name = trim($body['name'] ?? '');
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
if ($lang === QDB_SOURCE_LANGUAGE) {
qdb_ensure_source_language($pdo);
} else {
$pdo->prepare("INSERT INTO language (languageCode, name) VALUES (:lc, :n)
ON CONFLICT(languageCode) DO UPDATE SET name = excluded.name")
->execute([':lc' => $lang, ':n' => $name]);
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save language', null, $tmpDb, $lockFp);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
$text = $body['text'] ?? '';
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
qdb_put_translation($pdo, $type, $id, $lang, $text);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save translation', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$type = $body['type'] ?? '';
if ($type === 'language') {
$lang = trim($body['languageCode'] ?? '');
if (!$lang) {
json_error('MISSING_FIELDS', 'languageCode required', 400);
}
if ($lang === QDB_SOURCE_LANGUAGE) {
json_error('BAD_REQUEST', 'German (de) cannot be removed', 400);
}
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
try {
$pdo->prepare("DELETE FROM language WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM question_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM answer_option_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
$pdo->prepare("DELETE FROM string_translation WHERE languageCode = :lc")->execute([':lc' => $lang]);
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete language', null, $tmpDb, $lockFp);
}
break;
}
$id = $body['id'] ?? '';
$lang = trim($body['languageCode'] ?? '');
if (!in_array($type, $validTypes, true) || !$id || !$lang) {
json_error('MISSING_FIELDS', 'type, id, and languageCode required', 400);
}
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
if ($type === 'question' || $type === 'answer_option') {
if (!qdb_translation_entity_exists($pdo, $type, (string)$id)) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Translation entity not found', 404);
}
}
if ($type === 'question') {
$pdo->prepare('DELETE FROM question_translation WHERE questionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
} elseif ($type === 'answer_option') {
$pdo->prepare('DELETE FROM answer_option_translation WHERE answerOptionID = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
} else {
$pdo->prepare('DELETE FROM string_translation WHERE stringKey = :id AND languageCode = :lang')
->execute([':id' => $id, ':lang' => $lang]);
}
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete translation', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}

427
handlers/users.php Normal file
View File

@ -0,0 +1,427 @@
<?php
$tokenRec = require_valid_token_web();
$callerRole = $tokenRec['role'];
$callerEntityID = $tokenRec['entityID'] ?? '';
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
if ($callerRole === 'admin') {
$users = $pdo->query(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
COALESCE(a.location, s.location, '') AS location,
c.supervisorID,
sv.username AS supervisorUsername
FROM users u
LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin'
LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor'
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID
ORDER BY u.role, u.username"
)->fetchAll(PDO::FETCH_ASSOC);
$supervisors = $pdo->query(
"SELECT supervisorID, username, location FROM supervisor ORDER BY username"
)->fetchAll(PDO::FETCH_ASSOC);
} else {
$svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid");
$svNameStmt->execute([':svid' => $callerEntityID]);
$svName = $svNameStmt->fetchColumn() ?: '';
$stmt = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt,
'' AS location, c.supervisorID, :svname AS supervisorUsername
FROM users u
JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE c.supervisorID = :svid
ORDER BY u.username"
);
$stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
$supervisors = [];
}
qdb_discard($tmpDb, $lockFp);
json_success([
'users' => $users,
'supervisors' => $supervisors,
'callerRole' => $callerRole,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
$body = read_json_body();
$username = trim($body['username'] ?? '');
$password = (string)($body['password'] ?? '');
$role = strtolower(trim($body['role'] ?? ''));
$location = trim($body['location'] ?? '');
$supervisorID = trim($body['supervisorID'] ?? '');
$mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1;
if ($username === '' || $password === '' || $role === '') {
json_error('MISSING_FIELDS', 'username, password, and role are required', 400);
}
if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) {
json_error('INVALID_ROLE', 'role must be admin, supervisor, or coach', 400);
}
if (strlen($password) < 6) {
json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400);
}
if ($callerRole === 'supervisor' && $role !== 'coach') {
json_error('FORBIDDEN', 'Supervisors may only create coaches', 403);
}
if ($callerRole === 'supervisor') {
$supervisorID = $callerEntityID;
}
if ($role === 'coach' && $supervisorID === '') {
json_error('MISSING_FIELDS', 'supervisorID is required for coaches', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u");
$chk->execute([':u' => $username]);
if ($chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('DUPLICATE', 'Username already exists', 409);
}
if ($role === 'coach') {
$svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid");
$svCheck->execute([':sid' => $supervisorID]);
if (!$svCheck->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Supervisor not found', 400);
}
if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Cannot create coaches for another supervisor', 403);
}
}
$entityID = bin2hex(random_bytes(16));
$userID = bin2hex(random_bytes(16));
$hash = password_hash($password, PASSWORD_DEFAULT);
$now = time();
$pdo->beginTransaction();
switch ($role) {
case 'admin':
$pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'supervisor':
$pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)")
->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]);
break;
case 'coach':
$pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)")
->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]);
break;
}
$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' => $mustChange, ':now' => $now,
]);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
$svUsername = null;
if ($role === 'coach') {
[$pdo2, $tmp2, $lk2] = qdb_open_read_or_fail();
$svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid");
$svr->execute([':sid' => $supervisorID]);
$svUsername = $svr->fetchColumn() ?: null;
qdb_discard($tmp2, $lk2);
}
json_success([
'userID' => $userID,
'entityID' => $entityID,
'username' => $username,
'role' => $role,
'location' => $location,
'supervisorID' => $role === 'coach' ? $supervisorID : null,
'supervisorUsername' => $svUsername,
'mustChangePassword' => $mustChange,
'createdAt' => $now,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
case 'PATCH':
$body = read_json_body();
$targetUserID = trim($body['userID'] ?? '');
$action = trim((string)($body['action'] ?? ''));
$newPassword = (string)($body['password'] ?? $body['newPassword'] ?? '');
if ($action === 'revokeSessions') {
if ($targetUserID === '') {
json_error('MISSING_FIELDS', 'userID is required', 400);
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
json_error('FORBIDDEN', 'Use Admin settings to revoke your own other sessions, or sign out', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$row = $pdo->prepare('SELECT userID, username, role, entityID FROM users WHERE userID = :uid');
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
$targetRole = $target['role'] ?? '';
if ($callerRole === 'admin') {
if (!in_array($targetRole, ['admin', 'supervisor', 'coach'], true)) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Cannot revoke sessions for this user', 403);
}
} elseif ($callerRole === 'supervisor') {
if ($targetRole !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Supervisors may only revoke sessions for their coaches', 403);
}
$chk = $pdo->prepare(
'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid'
);
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
}
} else {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Not allowed to revoke sessions', 403);
}
$removed = token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
qdb_save($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'username' => $target['username'],
'revokedSessions' => $removed,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Revoke user sessions', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
}
if ($newPassword !== '') {
if ($targetUserID === '') {
json_error('MISSING_FIELDS', 'userID is required', 400);
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
json_error('FORBIDDEN', 'Cannot reset your own password', 400);
}
if (strlen($newPassword) < 6) {
json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400);
}
$mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1;
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$row = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID
FROM users u
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE u.userID = :uid"
);
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
$targetRole = $target['role'] ?? '';
if ($callerRole === 'admin') {
if (!in_array($targetRole, ['supervisor', 'coach'], true)) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Admins may only reset passwords for supervisors and coaches', 403);
}
} elseif ($callerRole === 'supervisor') {
if ($targetRole !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Supervisors may only reset passwords for their coaches', 403);
}
$chk = $pdo->prepare(
'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid'
);
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
}
} else {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Not allowed to reset passwords', 403);
}
$hash = password_hash($newPassword, PASSWORD_DEFAULT);
$pdo->prepare(
'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid'
)->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]);
token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
qdb_save($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'username' => $target['username'],
'mustChangePassword' => $mustChange,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
}
if ($callerRole !== 'admin') {
json_error('FORBIDDEN', 'Only admins can reassign coaches', 403);
}
$supervisorID = trim($body['supervisorID'] ?? '');
if ($targetUserID === '' || $supervisorID === '') {
json_error('MISSING_FIELDS', 'userID and supervisorID are required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$row = $pdo->prepare(
"SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID
FROM users u
LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach'
WHERE u.userID = :uid"
);
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
if (($target['role'] ?? '') !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'Only coaches can be reassigned to a supervisor', 400);
}
$svCheck = $pdo->prepare('SELECT username FROM supervisor WHERE supervisorID = :sid');
$svCheck->execute([':sid' => $supervisorID]);
$svUsername = $svCheck->fetchColumn();
if ($svUsername === false) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Supervisor not found', 404);
}
if (($target['supervisorID'] ?? '') === $supervisorID) {
qdb_discard($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'supervisorID' => $supervisorID,
'supervisorUsername' => $svUsername,
]);
}
$pdo->prepare('UPDATE coach SET supervisorID = :sid WHERE coachID = :cid')
->execute([':sid' => $supervisorID, ':cid' => $target['entityID']]);
qdb_save($tmpDb, $lockFp);
json_success([
'userID' => $targetUserID,
'supervisorID' => $supervisorID,
'supervisorUsername' => $svUsername,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
$body = read_json_body();
$targetUserID = trim($body['userID'] ?? '');
if ($targetUserID === '') {
json_error('MISSING_FIELDS', 'userID is required', 400);
}
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
json_error('FORBIDDEN', 'Cannot delete your own account', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid");
$row->execute([':uid' => $targetUserID]);
$target = $row->fetch(PDO::FETCH_ASSOC);
if (!$target) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'User not found', 404);
}
if ($callerRole === 'supervisor') {
if ($target['role'] !== 'coach') {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Supervisors can only delete coaches', 403);
}
$chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid");
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
if (!$chk->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('FORBIDDEN', 'Counselor is not under your supervision', 403);
}
}
$pdo->beginTransaction();
$pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]);
switch ($target['role']) {
case 'admin':
$pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]);
break;
case 'supervisor':
$pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]);
break;
case 'coach':
$pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]);
break;
}
$pdo->commit();
qdb_save($tmpDb, $lockFp);
json_success([]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}