added categoryKey to questionnaire schema and updated related functions for questionnaire handling; enhanced API to fetch client answers

This commit is contained in:
2026-05-27 19:38:49 +02:00
parent 9c887537e8
commit 36b6d63266
9 changed files with 424 additions and 17 deletions

View File

@ -1172,8 +1172,9 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
$condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE); $condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE);
} }
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson) $catKey = trim($meta['categoryKey'] ?? '');
VALUES (:id, :n, :v, :s, :o, :sp, :cj)") $pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
->execute([ ->execute([
':id' => $qnID, ':id' => $qnID,
':n' => trim($meta['name']), ':n' => trim($meta['name']),
@ -1182,6 +1183,7 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
':o' => $order, ':o' => $order,
':sp' => (int)($meta['showPoints'] ?? 0), ':sp' => (int)($meta['showPoints'] ?? 0),
':cj' => $condJson ?: '{}', ':cj' => $condJson ?: '{}',
':ck' => $catKey,
]); ]);
foreach ($item['questions'] ?? [] as $q) { foreach ($item['questions'] ?? [] as $q) {

View File

@ -39,7 +39,16 @@
"july", "july",
"june", "june",
"lay", "lay",
"consultation_unlock_requires_rhs",
"locked", "locked",
"questionnaire_chip_edit",
"questionnaire_chip_edit_pending",
"questionnaire_group_closure",
"questionnaire_group_consultation",
"questionnaire_group_demographics",
"questionnaire_group_followup",
"questionnaire_group_health_interview",
"questionnaire_group_integration",
"login_btn", "login_btn",
"login_failed_with_reason", "login_failed_with_reason",
"login_required", "login_required",
@ -127,7 +136,16 @@
"july": "Juli", "july": "Juli",
"june": "Juni", "june": "Juni",
"lay": "liegen.", "lay": "liegen.",
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
"locked": "Gesperrt", "locked": "Gesperrt",
"questionnaire_chip_edit": "Bearbeiten",
"questionnaire_chip_edit_pending": "Bearbeiten · Upload ausstehend",
"questionnaire_group_closure": "Abschluss",
"questionnaire_group_consultation": "Beratung",
"questionnaire_group_demographics": "Demografie",
"questionnaire_group_followup": "Nachbefragung",
"questionnaire_group_health_interview": "Gesundheitsinterview",
"questionnaire_group_integration": "Integration (IPL)",
"login_btn": "Login", "login_btn": "Login",
"login_failed_with_reason": "Login fehlgeschlagen: {reason}", "login_failed_with_reason": "Login fehlgeschlagen: {reason}",
"login_required": "Bitte zuerst einloggen", "login_required": "Bitte zuerst einloggen",

View File

@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php';
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database'); define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock'); define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 3); define('QDB_VERSION', 4);
/** /**
* Decrypt the master DB file into a temp SQLite file, or create a fresh one * Decrypt the master DB file into a temp SQLite file, or create a fresh one
@ -72,6 +72,19 @@ function qdb_open(bool $writable = false): array {
if ($currentVersion < QDB_VERSION) { if ($currentVersion < QDB_VERSION) {
$pdo->exec("PRAGMA foreign_keys = OFF;"); $pdo->exec("PRAGMA foreign_keys = OFF;");
$pdo->exec($sql); $pdo->exec($sql);
if ($currentVersion < 4) {
$cols = $pdo->query("PRAGMA table_info(questionnaire)")->fetchAll(PDO::FETCH_ASSOC);
$hasCategory = false;
foreach ($cols as $col) {
if (($col['name'] ?? '') === 'categoryKey') {
$hasCategory = true;
break;
}
}
if (!$hasCategory) {
$pdo->exec("ALTER TABLE questionnaire ADD COLUMN categoryKey TEXT NOT NULL DEFAULT ''");
}
}
$pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";"); $pdo->exec("PRAGMA user_version = " . QDB_VERSION . ";");
$pdo->exec("PRAGMA foreign_keys = ON;"); $pdo->exec("PRAGMA foreign_keys = ON;");
} }

View File

@ -149,6 +149,7 @@ Fields:
- `name`: display/admin name. - `name`: display/admin name.
- `showPoints`: whether the app may show the points result. - `showPoints`: whether the app may show the points result.
- `condition`: JSON condition object. Empty conditions are returned as `{}`. - `condition`: JSON condition object. Empty conditions are returned as `{}`.
- `categoryKey` (optional): grouping key for the opening screen; labels come from app UI strings (e.g. `questionnaire_group_health_interview`).
Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`. Only questionnaires with `state = "active"` are returned, ordered by server `orderIndex`.
@ -207,6 +208,57 @@ Question fields:
Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received.
## Download Client Answers (restore on device)
`GET /api/app_questionnaires?clientCode=<code>&answers=1`
Allowed roles: `coach`, `supervisor`, `admin` (coach must be assigned to the client).
Headers:
```http
Authorization: Bearer <token>
```
Success (encrypted envelope, same as `?clients=1`):
```json
{
"ok": true,
"data": {
"encrypted": true,
"payload": { "...": "..." }
}
}
```
Decrypted `data`:
```json
{
"clientCode": "K008",
"questionnaires": [
{
"questionnaireID": "questionnaire_2_rhs",
"completedAt": 1710000000,
"sumPoints": 12,
"answers": [
{ "questionID": "q1", "answerOptionKey": "consent_signed" },
{ "questionID": "languages_spoken", "answerOptionKey": "language_arabic" },
{ "questionID": "languages_spoken", "answerOptionKey": "language_english" }
]
}
]
}
```
Notes:
- Returns all `completed_questionnaire` rows for the client.
- `answers` mirrors the POST submit shape (short `questionID`, glass-scale symptoms as separate entries, multi-checkbox as multiple rows with the same `questionID`).
- On the server, multi-checkbox answers are stored in one `client_answer` row (`freeTextValue` JSON array); export expands them for the app importer.
- Use on **client load** to restore answers after upload or on a fresh install.
## Fetch Assigned Clients ## Fetch Assigned Clients
`GET /api/app_questionnaires?clients=1` `GET /api/app_questionnaires?clients=1`

View File

@ -5,6 +5,7 @@
* GET ?id=<questionnaireID> -> single questionnaire in app JSON format * 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 ?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 ?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). * POST -> submit interview answers for a client (coach / supervisor / admin).
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * 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). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
@ -69,12 +70,15 @@ if ($method === 'POST') {
); );
$qRows->execute([':qn' => $qnID]); $qRows->execute([':qn' => $qnID]);
$shortIdMap = []; // shortId -> fullQuestionID $shortIdMap = []; // shortId -> fullQuestionID
$shortIdToType = []; // shortId -> layout type
$freeTextMaxLen = []; // fullQuestionID -> maxLength $freeTextMaxLen = []; // fullQuestionID -> maxLength
$symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID); $symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID);
foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) {
$fullId = $qRow['questionID']; $fullId = $qRow['questionID'];
$parts = explode('__', $fullId); $parts = explode('__', $fullId);
$shortIdMap[end($parts)] = $fullId; $short = end($parts);
$shortIdMap[$short] = $fullId;
$shortIdToType[$short] = $qRow['type'] ?? '';
if (($qRow['type'] ?? '') === 'free_text') { if (($qRow['type'] ?? '') === 'free_text') {
$cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: [];
$freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000));
@ -111,6 +115,9 @@ if ($method === 'POST') {
answeredAt = excluded.answeredAt answeredAt = excluded.answeredAt
"); ");
require_once __DIR__ . '/../lib/app_answers.php';
$answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap);
foreach ($answers as $a) { foreach ($answers as $a) {
$shortId = trim($a['questionID'] ?? ''); $shortId = trim($a['questionID'] ?? '');
if ($shortId === '') continue; if ($shortId === '') continue;
@ -149,6 +156,13 @@ if ($method === 'POST') {
$opt = $optionMap[$fullQID][(string)$optionKey]; $opt = $optionMap[$fullQID][(string)$optionKey];
$answerOptionID = $opt['answerOptionID']; $answerOptionID = $opt['answerOptionID'];
$sumPoints += $opt['points']; $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([ $answerInsert->execute([
@ -218,6 +232,34 @@ if ($method !== 'GET') {
$qnID = $_GET['id'] ?? ''; $qnID = $_GET['id'] ?? '';
$translations = $_GET['translations'] ?? ''; $translations = $_GET['translations'] ?? '';
$fetchClients = $_GET['clients'] ?? ''; $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) { if ($fetchClients) {
require_role(['coach'], $tokenRec); require_role(['coach'], $tokenRec);
@ -364,7 +406,7 @@ if ($qnID) {
// Default: ordered questionnaire list // Default: ordered questionnaire list
$rows = $pdo->query(" $rows = $pdo->query("
SELECT questionnaireID, name, showPoints, conditionJson SELECT questionnaireID, name, showPoints, conditionJson, categoryKey
FROM questionnaire FROM questionnaire
WHERE state = 'active' WHERE state = 'active'
ORDER BY orderIndex ORDER BY orderIndex
@ -372,12 +414,17 @@ $rows = $pdo->query("
$list = []; $list = [];
foreach ($rows as $r) { foreach ($rows as $r) {
$list[] = [ $item = [
'id' => $r['questionnaireID'], 'id' => $r['questionnaireID'],
'name' => $r['name'], 'name' => $r['name'],
'showPoints' => (bool)(int)$r['showPoints'], 'showPoints' => (bool)(int)$r['showPoints'],
'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(),
]; ];
$cat = trim($r['categoryKey'] ?? '');
if ($cat !== '') {
$item['categoryKey'] = $cat;
}
$list[] = $item;
} }
qdb_discard($tmpDb, $lockFp); qdb_discard($tmpDb, $lockFp);

View File

@ -8,7 +8,7 @@ case 'GET':
[$pdo, $tmpDb, $lockFp] = qdb_open(false); [$pdo, $tmpDb, $lockFp] = qdb_open(false);
$rows = $pdo->query(" $rows = $pdo->query("
SELECT q.questionnaireID, q.name, q.version, q.state, SELECT q.questionnaireID, q.name, q.version, q.state,
q.orderIndex, q.showPoints, q.conditionJson, q.orderIndex, q.showPoints, q.conditionJson, q.categoryKey,
COUNT(qu.questionID) AS questionCount COUNT(qu.questionID) AS questionCount
FROM questionnaire q FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
@ -66,10 +66,11 @@ case 'POST':
if ($order === 0) { if ($order === 0) {
$order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn(); $order = (int)$pdo->query("SELECT COALESCE(MAX(orderIndex),0)+1 FROM questionnaire")->fetchColumn();
} }
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson) $catKey = trim($body['categoryKey'] ?? '');
VALUES (:id, :n, :v, :s, :o, :sp, :cj)") $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, ->execute([':id' => $id, ':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson]); ':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [ json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'questionnaireID' => $id, 'name' => $name, 'version' => $version,
@ -106,12 +107,15 @@ case 'PUT':
$order = (int)($body['orderIndex'] ?? $row['orderIndex']); $order = (int)($body['orderIndex'] ?? $row['orderIndex']);
$showPts = (int)($body['showPoints'] ?? $row['showPoints']); $showPts = (int)($body['showPoints'] ?? $row['showPoints']);
$condJson = $body['conditionJson'] ?? $row['conditionJson']; $condJson = $body['conditionJson'] ?? $row['conditionJson'];
$catKey = array_key_exists('categoryKey', $body)
? trim((string)$body['categoryKey'])
: trim($row['categoryKey'] ?? '');
$pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s, $pdo->prepare("UPDATE questionnaire SET name = :n, version = :v, state = :s,
orderIndex = :o, showPoints = :sp, conditionJson = :cj orderIndex = :o, showPoints = :sp, conditionJson = :cj, categoryKey = :ck
WHERE questionnaireID = :id") WHERE questionnaireID = :id")
->execute([':n' => $name, ':v' => $version, ':s' => $state, ->execute([':n' => $name, ':v' => $version, ':s' => $state,
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':id' => $id]); ':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey, ':id' => $id]);
qdb_save($tmpDb, $lockFp); qdb_save($tmpDb, $lockFp);
json_success(['questionnaire' => [ json_success(['questionnaire' => [
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state, 'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,

257
lib/app_answers.php Normal file
View File

@ -0,0 +1,257 @@
<?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];
}
/**
* 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;
}

View File

@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS questionnaire (
state TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT '',
orderIndex INTEGER NOT NULL DEFAULT 0, orderIndex INTEGER NOT NULL DEFAULT 0,
showPoints INTEGER NOT NULL DEFAULT 0, showPoints INTEGER NOT NULL DEFAULT 0,
conditionJson TEXT NOT NULL DEFAULT '{}' conditionJson TEXT NOT NULL DEFAULT '{}',
categoryKey TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS question ( CREATE TABLE IF NOT EXISTS question (

View File

@ -279,7 +279,8 @@ function configFormHTML(layout, config, prefix) {
<div class="form-group"> <div class="form-group">
<label>Min selections</label> <label>Min selections</label>
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0"> <input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
</div>`; </div>
${stringSpinnerOtherSectionHTML(config, prefix)}`;
break; break;
case 'glass_scale_question': { case 'glass_scale_question': {
const scaleType = config.scaleType || 'glass'; const scaleType = config.scaleType || 'glass';
@ -426,6 +427,13 @@ function readConfigFromForm(layout, prefix) {
case 'multi_check_box_question': { case 'multi_check_box_question': {
const ms = parseInt(val('minSelection') || '0', 10); const ms = parseInt(val('minSelection') || '0', 10);
if (ms > 0) config.minSelection = ms; if (ms > 0) config.minSelection = ms;
const otherEnabled = document.getElementById(`${prefix}_otherEnabled`)?.checked;
if (otherEnabled) {
const ok = val('otherOptionKey') || 'other_option';
const on = val('otherNextQuestionId');
if (ok) config.otherOptionKey = ok;
if (on) config.otherNextQuestionId = on;
}
break; break;
} }
case 'glass_scale_question': { case 'glass_scale_question': {
@ -534,6 +542,10 @@ function renderEditor() {
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points <input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
</label> </label>
</div> </div>
<div class="form-group">
<label>Category key (app grouping)</label>
<input type="text" id="metaCategoryKey" value="${esc(questionnaire.categoryKey || '')}" placeholder="e.g. health_interview" ${editable ? '' : 'disabled'}>
</div>
</div> </div>
${editable ? ` ${editable ? `
<details class="condition-details" style="margin-bottom:16px"> <details class="condition-details" style="margin-bottom:16px">
@ -617,6 +629,7 @@ async function saveMeta() {
const state = document.getElementById('metaState').value; const state = document.getElementById('metaState').value;
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10); const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0; const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
let conditionJson = '{}'; let conditionJson = '{}';
const condEl = document.getElementById('metaCondition'); const condEl = document.getElementById('metaCondition');
if (condEl) { if (condEl) {
@ -632,7 +645,7 @@ async function saveMeta() {
try { try {
if (isNew) { if (isNew) {
const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson }); const data = await apiPost('questionnaires.php', { name, version, state, orderIndex, showPoints, conditionJson, categoryKey });
if (data.success) { if (data.success) {
questionnaire = data.questionnaire; questionnaire = data.questionnaire;
isNew = false; isNew = false;
@ -642,10 +655,10 @@ async function saveMeta() {
} else { } else {
const data = await apiPut('questionnaires.php', { const data = await apiPut('questionnaires.php', {
questionnaireID: questionnaire.questionnaireID, questionnaireID: questionnaire.questionnaireID,
name, version, state, orderIndex, showPoints, conditionJson name, version, state, orderIndex, showPoints, conditionJson, categoryKey
}); });
if (data.success) { if (data.success) {
questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson }; questionnaire = { ...questionnaire, name, version, state, orderIndex, showPoints, conditionJson, categoryKey };
document.getElementById('editorTitle').textContent = name; document.getElementById('editorTitle').textContent = name;
showToast('Saved', 'success'); showToast('Saved', 'success');
} }