added categoryKey to questionnaire schema and updated related functions for questionnaire handling; enhanced API to fetch client answers
This commit is contained in:
@ -1172,8 +1172,9 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
|
||||
$condJson = json_encode($condJson, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
|
||||
$catKey = trim($meta['categoryKey'] ?? '');
|
||||
$pdo->prepare("INSERT INTO questionnaire (questionnaireID, name, version, state, orderIndex, showPoints, conditionJson, categoryKey)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj, :ck)")
|
||||
->execute([
|
||||
':id' => $qnID,
|
||||
':n' => trim($meta['name']),
|
||||
@ -1182,6 +1183,7 @@ function qdb_import_questionnaire_bundle(PDO $pdo, array $item, bool $replaceIfE
|
||||
':o' => $order,
|
||||
':sp' => (int)($meta['showPoints'] ?? 0),
|
||||
':cj' => $condJson ?: '{}',
|
||||
':ck' => $catKey,
|
||||
]);
|
||||
|
||||
foreach ($item['questions'] ?? [] as $q) {
|
||||
|
||||
@ -39,7 +39,16 @@
|
||||
"july",
|
||||
"june",
|
||||
"lay",
|
||||
"consultation_unlock_requires_rhs",
|
||||
"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_failed_with_reason",
|
||||
"login_required",
|
||||
@ -127,7 +136,16 @@
|
||||
"july": "Juli",
|
||||
"june": "Juni",
|
||||
"lay": "liegen.",
|
||||
"consultation_unlock_requires_rhs": "Freischaltung nach Abschluss von Demografie und RHS",
|
||||
"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_failed_with_reason": "Login fehlgeschlagen: {reason}",
|
||||
"login_required": "Bitte zuerst einloggen",
|
||||
|
||||
15
db_init.php
15
db_init.php
@ -6,7 +6,7 @@ require_once __DIR__ . '/common.php';
|
||||
define('QDB_PATH', __DIR__ . '/uploads/questionnaire_database');
|
||||
define('QDB_LOCK', __DIR__ . '/uploads/.qdb_lock');
|
||||
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
|
||||
@ -72,6 +72,19 @@ function qdb_open(bool $writable = false): array {
|
||||
if ($currentVersion < QDB_VERSION) {
|
||||
$pdo->exec("PRAGMA foreign_keys = OFF;");
|
||||
$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 foreign_keys = ON;");
|
||||
}
|
||||
|
||||
@ -149,6 +149,7 @@ Fields:
|
||||
- `name`: display/admin name.
|
||||
- `showPoints`: whether the app may show the points result.
|
||||
- `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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
`GET /api/app_questionnaires?clients=1`
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
* 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).
|
||||
@ -69,12 +70,15 @@ if ($method === 'POST') {
|
||||
);
|
||||
$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);
|
||||
$shortIdMap[end($parts)] = $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));
|
||||
@ -111,6 +115,9 @@ if ($method === 'POST') {
|
||||
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;
|
||||
@ -149,6 +156,13 @@ if ($method === 'POST') {
|
||||
$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([
|
||||
@ -218,6 +232,34 @@ if ($method !== 'GET') {
|
||||
$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);
|
||||
@ -364,7 +406,7 @@ if ($qnID) {
|
||||
|
||||
// Default: ordered questionnaire list
|
||||
$rows = $pdo->query("
|
||||
SELECT questionnaireID, name, showPoints, conditionJson
|
||||
SELECT questionnaireID, name, showPoints, conditionJson, categoryKey
|
||||
FROM questionnaire
|
||||
WHERE state = 'active'
|
||||
ORDER BY orderIndex
|
||||
@ -372,12 +414,17 @@ $rows = $pdo->query("
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $r) {
|
||||
$list[] = [
|
||||
$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);
|
||||
|
||||
@ -8,7 +8,7 @@ case 'GET':
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
$rows = $pdo->query("
|
||||
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
|
||||
FROM questionnaire q
|
||||
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
|
||||
@ -66,10 +66,11 @@ case 'POST':
|
||||
if ($order === 0) {
|
||||
$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)
|
||||
VALUES (:id, :n, :v, :s, :o, :sp, :cj)")
|
||||
$catKey = trim($body['categoryKey'] ?? '');
|
||||
$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]);
|
||||
':o' => $order, ':sp' => $showPts, ':cj' => $condJson, ':ck' => $catKey]);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['questionnaire' => [
|
||||
'questionnaireID' => $id, 'name' => $name, 'version' => $version,
|
||||
@ -106,12 +107,15 @@ case 'PUT':
|
||||
$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'] ?? '');
|
||||
|
||||
$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")
|
||||
->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);
|
||||
json_success(['questionnaire' => [
|
||||
'questionnaireID' => $id, 'name' => $name, 'version' => $version, 'state' => $state,
|
||||
|
||||
257
lib/app_answers.php
Normal file
257
lib/app_answers.php
Normal 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;
|
||||
}
|
||||
@ -42,7 +42,8 @@ CREATE TABLE IF NOT EXISTS questionnaire (
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
orderIndex 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 (
|
||||
|
||||
@ -279,7 +279,8 @@ function configFormHTML(layout, config, prefix) {
|
||||
<div class="form-group">
|
||||
<label>Min selections</label>
|
||||
<input type="number" id="${prefix}_minSelection" value="${config.minSelection || 0}" min="0">
|
||||
</div>`;
|
||||
</div>
|
||||
${stringSpinnerOtherSectionHTML(config, prefix)}`;
|
||||
break;
|
||||
case 'glass_scale_question': {
|
||||
const scaleType = config.scaleType || 'glass';
|
||||
@ -426,6 +427,13 @@ function readConfigFromForm(layout, prefix) {
|
||||
case 'multi_check_box_question': {
|
||||
const ms = parseInt(val('minSelection') || '0', 10);
|
||||
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;
|
||||
}
|
||||
case 'glass_scale_question': {
|
||||
@ -534,6 +542,10 @@ function renderEditor() {
|
||||
<input type="checkbox" id="metaShowPoints" ${questionnaire.showPoints ? 'checked' : ''} ${editable ? '' : 'disabled'}> Show points
|
||||
</label>
|
||||
</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>
|
||||
${editable ? `
|
||||
<details class="condition-details" style="margin-bottom:16px">
|
||||
@ -617,6 +629,7 @@ async function saveMeta() {
|
||||
const state = document.getElementById('metaState').value;
|
||||
const orderIndex = parseInt(document.getElementById('metaOrder').value || '0', 10);
|
||||
const showPoints = document.getElementById('metaShowPoints').checked ? 1 : 0;
|
||||
const categoryKey = (document.getElementById('metaCategoryKey')?.value || '').trim();
|
||||
let conditionJson = '{}';
|
||||
const condEl = document.getElementById('metaCondition');
|
||||
if (condEl) {
|
||||
@ -632,7 +645,7 @@ async function saveMeta() {
|
||||
|
||||
try {
|
||||
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) {
|
||||
questionnaire = data.questionnaire;
|
||||
isNew = false;
|
||||
@ -642,10 +655,10 @@ async function saveMeta() {
|
||||
} else {
|
||||
const data = await apiPut('questionnaires.php', {
|
||||
questionnaireID: questionnaire.questionnaireID,
|
||||
name, version, state, orderIndex, showPoints, conditionJson
|
||||
name, version, state, orderIndex, showPoints, conditionJson, categoryKey
|
||||
});
|
||||
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;
|
||||
showToast('Saved', 'success');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user