468 lines
18 KiB
PHP
468 lines
18 KiB
PHP
<?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)
|
|
* 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']);
|
|
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;
|
|
|
|
// Verify questionnaire exists
|
|
$qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id");
|
|
$qnStmt->execute([':id' => $qnID]);
|
|
if (!$qnStmt->fetch()) {
|
|
qdb_discard($tmpDb, $lockFp);
|
|
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
|
}
|
|
|
|
[$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'] ?? '');
|
|
|
|
// Load all questions for this questionnaire: full questionID keyed by short ID
|
|
$qRows = $pdo->prepare(
|
|
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
|
|
);
|
|
$qRows->execute([':qn' => $qnID]);
|
|
$shortIdMap = []; // shortId -> fullQuestionID
|
|
$shortIdToType = []; // shortId -> layout type
|
|
$freeTextMaxLen = []; // fullQuestionID -> maxLength
|
|
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
|
|
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
|
|
$fullId = $qRow['questionID'];
|
|
$parts = explode('__', $fullId);
|
|
$short = end($parts);
|
|
$shortIdMap[$short] = $fullId;
|
|
$shortIdToType[$short] = $qRow['type'] ?? '';
|
|
if (($qRow['type'] ?? '') === 'free_text') {
|
|
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
|
|
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
|
|
}
|
|
}
|
|
|
|
// Load all answer options for this questionnaire: keyed by [questionID][defaultText]
|
|
$aoRows = $pdo->prepare("
|
|
SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points
|
|
FROM answer_option ao
|
|
JOIN question q ON q.questionID = ao.questionID
|
|
WHERE q.questionnaireID = :qn
|
|
");
|
|
$aoRows->execute([':qn' => $qnID]);
|
|
$optionMap = []; // fullQuestionID -> [defaultText -> {answerOptionID, points}]
|
|
foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) {
|
|
$optionMap[$ao['questionID']][$ao['defaultText']] = [
|
|
'answerOptionID' => $ao['answerOptionID'],
|
|
'points' => (int)$ao['points'],
|
|
];
|
|
}
|
|
|
|
require_once __DIR__ . '/../lib/app_submit_validate.php';
|
|
$validationErrors = qdb_validate_app_submit_payload(
|
|
$pdo,
|
|
$qnID,
|
|
$answers,
|
|
$shortIdMap,
|
|
$shortIdToType,
|
|
$symptomParentMap,
|
|
$optionMap
|
|
);
|
|
if ($validationErrors !== []) {
|
|
qdb_discard($tmpDb, $lockFp);
|
|
json_error(
|
|
'VALIDATION_FAILED',
|
|
'Some answers are invalid or missing',
|
|
400,
|
|
['errors' => $validationErrors]
|
|
);
|
|
}
|
|
|
|
$pdo->beginTransaction();
|
|
|
|
$sumPoints = 0;
|
|
$glassByParent = [];
|
|
$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
|
|
");
|
|
|
|
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 === '') {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Resolve option key to answerOptionID and accumulate points
|
|
$optionKey = $a['answerOptionKey'] ?? null;
|
|
if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) {
|
|
$opt = $optionMap[$fullQID][(string)$optionKey];
|
|
$answerOptionID = $opt['answerOptionID'];
|
|
$sumPoints += $opt['points'];
|
|
} elseif (($shortIdToType[$shortId] ?? '') === 'multi_check_box_question'
|
|
&& $freeTextValue !== null && $freeTextValue !== '') {
|
|
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
|
|
if (isset($optionMap[$fullQID][$mk])) {
|
|
$sumPoints += $optionMap[$fullQID][$mk]['points'];
|
|
}
|
|
}
|
|
}
|
|
|
|
$answerInsert->execute([
|
|
':cc' => $clientCode,
|
|
':qid' => $fullQID,
|
|
':aoid' => $answerOptionID,
|
|
':ftv' => $freeTextValue,
|
|
':nv' => $numericValue,
|
|
':at' => $answeredAt,
|
|
]);
|
|
}
|
|
|
|
foreach ($glassByParent as $parentQID => $symptomLabels) {
|
|
$existing = $pdo->prepare(
|
|
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
|
|
);
|
|
$existing->execute([':cc' => $clientCode, ':qid' => $parentQID]);
|
|
$prevJson = $existing->fetchColumn();
|
|
$merged = qdb_merge_glass_symptom_json($prevJson !== false ? (string)$prevJson : null, $symptomLabels);
|
|
$answerInsert->execute([
|
|
':cc' => $clientCode,
|
|
':qid' => $parentQID,
|
|
':aoid' => null,
|
|
':ftv' => $merged,
|
|
':nv' => null,
|
|
':at' => $completedAt ?? $startedAt,
|
|
]);
|
|
}
|
|
|
|
// Upsert completed_questionnaire record
|
|
$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
|
|
);
|
|
|
|
$pdo->commit();
|
|
qdb_save($tmpDb, $lockFp);
|
|
|
|
json_success(['submitted' => true, 'sumPoints' => $sumPoints]);
|
|
} 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'] ?? '';
|
|
$clientCode = trim($_GET['clientCode'] ?? '');
|
|
|
|
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
|
|
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) {
|
|
$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);
|
|
}
|
|
|
|
$stmt = $pdo->prepare("
|
|
SELECT questionID, defaultText, type, orderIndex, configJson
|
|
FROM question WHERE questionnaireID = :id 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 (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'];
|
|
}
|
|
|
|
$ao = $pdo->prepare("
|
|
SELECT defaultText, points, nextQuestionId
|
|
FROM answer_option WHERE questionID = :qid 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],
|
|
'questions' => $questions,
|
|
'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID),
|
|
]);
|
|
}
|
|
|
|
// Default: ordered questionnaire list
|
|
$rows = $pdo->query("
|
|
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey
|
|
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'],
|
|
'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);
|