Files
nat-as-server/lib/app_answers.php
Tom Hempel c37940c9a9
Some checks failed
PHPUnit / test (push) Has been cancelled
enhanced submission handling with deccision trees
2026-06-05 11:57:28 +02:00

305 lines
9.9 KiB
PHP

<?php
/**
* Mobile app answer ingest/export helpers (submit payload ↔ client_answer rows).
*/
/** Encode multi-select keys the same way the Android app stores them locally. */
function qdb_encode_multi_check_values(array $keys): string {
$clean = [];
foreach ($keys as $k) {
$k = trim((string)$k);
if ($k !== '') {
$clean[$k] = true;
}
}
return json_encode(array_keys($clean), JSON_UNESCAPED_UNICODE);
}
/** @return list<string> */
function qdb_decode_multi_check_values(?string $raw): array {
if ($raw === null || trim($raw) === '') {
return [];
}
$trimmed = trim($raw);
if ($trimmed[0] === '[') {
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$out = [];
foreach ($decoded as $v) {
$s = trim((string)$v);
if ($s !== '') {
$out[] = $s;
}
}
return $out;
}
}
$sep = str_contains($trimmed, ',') ? ',' : (str_contains($trimmed, ';') ? ';' : null);
if ($sep !== null) {
$parts = [];
foreach (explode($sep, $trimmed) as $p) {
$s = trim($p, " \t\n\r\0\x0B\"'");
if ($s !== '') {
$parts[] = $s;
}
}
return $parts;
}
return [$trimmed];
}
/**
* Build glass-scale symptom JSON from the current submit only (no merge with prior rows).
*
* @param array<string, string> $symptomLabels symptom key => selected label
*/
function qdb_build_glass_symptom_json(array $symptomLabels): ?string {
$data = [];
foreach ($symptomLabels as $key => $label) {
$k = trim((string)$key);
$v = trim((string)$label);
if ($k !== '' && $v !== '') {
$data[$k] = $v;
}
}
if ($data === []) {
return null;
}
return json_encode($data, JSON_UNESCAPED_UNICODE);
}
/**
* Group raw POST answer rows by question short id; merge multi_check option keys.
*
* @param array<string, string> $shortIdToType question localId => layout type
* @return list<array<string, mixed>>
*/
function qdb_group_app_submit_answers(array $answers, array $shortIdToType, array $symptomParentMap): array {
$grouped = [];
$order = [];
foreach ($answers as $a) {
if (!is_array($a)) {
continue;
}
$shortId = trim($a['questionID'] ?? '');
if ($shortId === '') {
continue;
}
if (isset($symptomParentMap[$shortId])) {
$order[] = $shortId;
$grouped[$shortId] = $a;
continue;
}
$type = $shortIdToType[$shortId] ?? '';
if ($type === 'multi_check_box_question') {
if (!isset($grouped[$shortId])) {
$order[] = $shortId;
$grouped[$shortId] = [
'questionID' => $shortId,
'multiOptionKeys' => [],
'answeredAt' => $a['answeredAt'] ?? null,
];
}
$key = trim((string)($a['answerOptionKey'] ?? ''));
if ($key !== '') {
$grouped[$shortId]['multiOptionKeys'][$key] = true;
}
continue;
}
$order[] = $shortId;
$grouped[$shortId] = $a;
}
$out = [];
foreach ($order as $shortId) {
$row = $grouped[$shortId];
if (!empty($row['multiOptionKeys'])) {
$keys = array_keys($row['multiOptionKeys']);
$out[] = [
'questionID' => $shortId,
'freeTextValue' => qdb_encode_multi_check_values($keys),
'answeredAt' => $row['answeredAt'] ?? null,
];
} else {
unset($row['multiOptionKeys']);
$out[] = $row;
}
}
return $out;
}
/**
* Export one client's answers for one questionnaire into app submit shape.
*
* @return array{answers: list<array<string, mixed>>, sumPoints: int, completedAt: ?int}
*/
function qdb_export_app_questionnaire_answers(
PDO $pdo,
string $clientCode,
string $qnID
): array {
$qStmt = $pdo->prepare(
"SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn"
);
$qStmt->execute([':qn' => $qnID]);
$shortToFull = [];
$fullToShort = [];
$fullToType = [];
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
foreach ($qStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$full = $row['questionID'];
$parts = explode('__', $full);
$short = end($parts);
$shortToFull[$short] = $full;
$fullToShort[$full] = $short;
$fullToType[$full] = $row['type'] ?? '';
}
$aoStmt = $pdo->prepare("
SELECT ao.answerOptionID, ao.questionID, ao.defaultText
FROM answer_option ao
JOIN question q ON q.questionID = ao.questionID
WHERE q.questionnaireID = :qn
");
$aoStmt->execute([':qn' => $qnID]);
$optionIdToKey = [];
foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) {
$optionIdToKey[$ao['answerOptionID']] = $ao['defaultText'];
}
$caStmt = $pdo->prepare("
SELECT ca.questionID, ca.answerOptionID, ca.freeTextValue, ca.numericValue, ca.answeredAt
FROM client_answer ca
JOIN question q ON q.questionID = ca.questionID
WHERE ca.clientCode = :cc AND q.questionnaireID = :qn
");
$caStmt->execute([':cc' => $clientCode, ':qn' => $qnID]);
$rows = $caStmt->fetchAll(PDO::FETCH_ASSOC);
$export = [];
foreach ($rows as $row) {
$fullQid = $row['questionID'];
$shortId = $fullToShort[$fullQid] ?? '';
if ($shortId === '') {
continue;
}
$type = $fullToType[$fullQid] ?? '';
if ($type === 'glass_scale_question' && ($row['freeTextValue'] ?? '') !== '') {
$decoded = json_decode($row['freeTextValue'], true);
if (is_array($decoded)) {
foreach ($decoded as $symptomKey => $label) {
$sk = trim((string)$symptomKey);
$lab = trim((string)$label);
if ($sk !== '' && $lab !== '') {
$export[] = [
'questionID' => $sk,
'answerOptionKey' => $lab,
];
}
}
}
continue;
}
if ($type === 'multi_check_box_question') {
foreach (qdb_decode_multi_check_values($row['freeTextValue'] ?? null) as $key) {
$export[] = [
'questionID' => $shortId,
'answerOptionKey' => $key,
];
}
if ($export === [] && !empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
$export[] = [
'questionID' => $shortId,
'answerOptionKey' => $optionIdToKey[$row['answerOptionID']],
];
}
continue;
}
$entry = ['questionID' => $shortId];
if (!empty($row['answerOptionID']) && isset($optionIdToKey[$row['answerOptionID']])) {
$entry['answerOptionKey'] = $optionIdToKey[$row['answerOptionID']];
}
if (isset($row['freeTextValue']) && $row['freeTextValue'] !== null && $row['freeTextValue'] !== '') {
$entry['freeTextValue'] = $row['freeTextValue'];
}
if (isset($row['numericValue']) && $row['numericValue'] !== null && $row['numericValue'] !== '') {
$entry['numericValue'] = (float)$row['numericValue'];
}
if (!empty($row['answeredAt'])) {
$entry['answeredAt'] = (int)$row['answeredAt'];
}
$export[] = $entry;
}
$cq = $pdo->prepare(
"SELECT sumPoints, completedAt FROM completed_questionnaire
WHERE clientCode = :cc AND questionnaireID = :qn"
);
$cq->execute([':cc' => $clientCode, ':qn' => $qnID]);
$meta = $cq->fetch(PDO::FETCH_ASSOC) ?: [];
return [
'answers' => $export,
'sumPoints' => isset($meta['sumPoints']) ? (int)$meta['sumPoints'] : 0,
'completedAt' => isset($meta['completedAt']) ? (int)$meta['completedAt'] : null,
];
}
/**
* Export all completed questionnaires for a client (coach RBAC must be applied by caller).
*
* @return list<array<string, mixed>>
*/
function qdb_export_app_client_answers_bundle(PDO $pdo, string $clientCode): array {
$stmt = $pdo->prepare(
"SELECT questionnaireID FROM completed_questionnaire WHERE clientCode = :cc"
);
$stmt->execute([':cc' => $clientCode]);
$items = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $qnID) {
$block = qdb_export_app_questionnaire_answers($pdo, $clientCode, $qnID);
$items[] = [
'questionnaireID' => $qnID,
'completedAt' => $block['completedAt'],
'sumPoints' => $block['sumPoints'],
'answers' => $block['answers'],
];
}
return $items;
}
/**
* Bulk export: all coach-assigned clients with full answer bundles (caller applies RBAC).
*
* @return list<array{clientCode: string, questionnaires: list<array<string, mixed>>}>
*/
function qdb_export_app_bulk_answers_for_coach(PDO $pdo, array $tokenRec): array {
if (($tokenRec['role'] ?? '') !== 'coach') {
return [];
}
$coachID = trim((string)($tokenRec['entityID'] ?? ''));
if ($coachID === '') {
return [];
}
$stmt = $pdo->prepare(
'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode'
);
$stmt->execute([':cid' => $coachID]);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $clientCode) {
$out[] = [
'clientCode' => $clientCode,
'questionnaires' => qdb_export_app_client_answers_bundle($pdo, (string)$clientCode),
];
}
return $out;
}