initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled
Some checks failed
PHPUnit / test (push) Has been cancelled
This commit is contained in:
21
handlers/activity_log.php
Normal file
21
handlers/activity_log.php
Normal 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
51
handlers/analytics.php
Normal 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
217
handlers/answer_options.php
Normal 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);
|
||||
}
|
||||
626
handlers/app_questionnaires.php
Normal file
626
handlers/app_questionnaires.php
Normal 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
99
handlers/assignments.php
Normal 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
286
handlers/auth.php
Normal 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
33
handlers/backup.php
Normal 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
239
handlers/clients.php
Normal 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
29
handlers/coaches.php
Normal 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
53
handlers/dev.php
Normal 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
111
handlers/export.php
Normal 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
18
handlers/logout.php
Normal 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
244
handlers/questionnaires.php
Normal 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
357
handlers/questions.php
Normal 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
124
handlers/results.php
Normal 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);
|
||||
}
|
||||
203
handlers/scoring_profiles.php
Normal file
203
handlers/scoring_profiles.php
Normal 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
44
handlers/session.php
Normal 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
70
handlers/settings.php
Normal 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
302
handlers/translations.php
Normal 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
427
handlers/users.php
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user