From 9d783b60a92c9d27a82f009a950c4544618a49e4 Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Tue, 26 May 2026 16:38:37 +0200 Subject: [PATCH] added prototype dev settings with test import and db wipe --- api/export.php | 18 +- api/index.php | 2 + common.php | 87 ++++ handlers/app_questionnaires.php | 34 +- handlers/dev.php | 57 +++ handlers/export.php | 18 +- handlers/results.php | 2 +- lib/dev_fixture.php | 748 ++++++++++++++++++++++++++++++++ scripts/generate_dev_fixture.py | 99 +++++ website/index.html | 6 + website/js/app.js | 2 + website/js/pages/dev.js | 186 ++++++++ website/js/pages/results.js | 27 ++ 13 files changed, 1253 insertions(+), 33 deletions(-) create mode 100644 handlers/dev.php create mode 100644 lib/dev_fixture.php create mode 100644 scripts/generate_dev_fixture.py create mode 100644 website/js/pages/dev.js diff --git a/api/export.php b/api/export.php index 61fd1a6..8fb280f 100644 --- a/api/export.php +++ b/api/export.php @@ -54,7 +54,7 @@ if (!$questionnaire) { } // Questions ordered -$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); +$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); $qStmt->execute([':id' => $qnID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questionIDs = array_column($questions, 'questionID'); @@ -117,20 +117,8 @@ foreach ($clients as $c) { foreach ($questions as $q) { $qid = $q['questionID']; - if (isset($answerMap[$qid])) { - $a = $answerMap[$qid]; - if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) { - $row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']]; - } elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') { - $row[$q['defaultText']] = $a['freeTextValue']; - } elseif ($a['numericValue'] !== null) { - $row[$q['defaultText']] = $a['numericValue']; - } else { - $row[$q['defaultText']] = ''; - } - } else { - $row[$q['defaultText']] = ''; - } + $a = $answerMap[$qid] ?? null; + $row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap); } $rows[] = $row; } diff --git a/api/index.php b/api/index.php index f63c10b..6e0db7d 100644 --- a/api/index.php +++ b/api/index.php @@ -46,6 +46,8 @@ $routes = [ 'auth/login' => __DIR__ . '/../handlers/auth.php', 'auth/change-password' => __DIR__ . '/../handlers/auth.php', 'backup' => __DIR__ . '/../handlers/backup.php', + 'dev' => __DIR__ . '/../handlers/dev.php', + 'dev/import' => __DIR__ . '/../handlers/dev.php', 'download' => __DIR__ . '/../handlers/download.php', ]; diff --git a/common.php b/common.php index b638688..50870d1 100644 --- a/common.php +++ b/common.php @@ -467,6 +467,93 @@ function qdb_option_key(array $aoRow): string { return qdb_is_stable_key($dt) ? $dt : ''; } +/** Human-readable text for glass-scale answers stored as JSON on the parent question row. */ +function qdb_format_glass_answer_json(?string $raw): string +{ + if ($raw === null || trim($raw) === '') { + return ''; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return $raw; + } + $parts = []; + foreach ($decoded as $key => $val) { + $val = trim((string)$val); + if ($val === '') { + continue; + } + $parts[] = $key . ': ' . $val; + } + return implode('; ', $parts); +} + +/** + * Map glass symptom string keys to parent questionID for one questionnaire. + * + * @return array symptomKey => parentQuestionID + */ +function qdb_glass_symptom_parent_map(PDO $pdo, string $qnID): array +{ + $stmt = $pdo->prepare( + "SELECT questionID, configJson FROM question WHERE questionnaireID = :qn AND type = 'glass_scale_question'" + ); + $stmt->execute([':qn' => $qnID]); + $map = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $cfg = json_decode($row['configJson'] ?? '{}', true) ?: []; + foreach ($cfg['symptoms'] ?? [] as $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk !== '') { + $map[$sk] = $row['questionID']; + } + } + } + return $map; +} + +/** Merge symptom => label selections into JSON for one glass-scale parent question. */ +function qdb_merge_glass_symptom_json(?string $existingJson, array $symptomLabels): string +{ + $data = []; + if ($existingJson !== null && trim($existingJson) !== '') { + $decoded = json_decode($existingJson, true); + if (is_array($decoded)) { + $data = $decoded; + } + } + foreach ($symptomLabels as $key => $label) { + $k = trim((string)$key); + $v = trim((string)$label); + if ($k !== '' && $v !== '') { + $data[$k] = $v; + } + } + return json_encode($data, JSON_UNESCAPED_UNICODE); +} + +/** Format one client_answer row for results table / CSV export. */ +function qdb_format_client_answer_display(array $question, ?array $answerRow, array $optionTextMap): string +{ + if (!$answerRow) { + return ''; + } + if (($question['type'] ?? '') === 'glass_scale_question') { + return qdb_format_glass_answer_json($answerRow['freeTextValue'] ?? null); + } + $aoid = $answerRow['answerOptionID'] ?? null; + if ($aoid && isset($optionTextMap[$aoid])) { + return (string)$optionTextMap[$aoid]; + } + if (!empty($answerRow['freeTextValue'])) { + return (string)$answerRow['freeTextValue']; + } + if (isset($answerRow['numericValue']) && $answerRow['numericValue'] !== null && $answerRow['numericValue'] !== '') { + return (string)$answerRow['numericValue']; + } + return ''; +} + function qdb_note_before_key(string $questionKey): string { return $questionKey . '_note_before'; } diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index cc653bc..91173cf 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -60,6 +60,7 @@ if ($method === 'POST') { $qRows->execute([':qn' => $qnID]); $shortIdMap = []; // shortId -> fullQuestionID $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); @@ -89,6 +90,7 @@ if ($method === 'POST') { $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) @@ -103,14 +105,25 @@ if ($method === 'POST') { $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; // skip unknown questions + if ($fullQID === null) continue; $answerOptionID = null; $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null; $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null; - $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; if ($freeTextValue !== null && $freeTextValue !== '') { $maxLen = $freeTextMaxLen[$fullQID] ?? 2000; @@ -138,6 +151,23 @@ if ($method === 'POST') { ]); } + 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) diff --git a/handlers/dev.php b/handlers/dev.php new file mode 100644 index 0000000..3478dcd --- /dev/null +++ b/handlers/dev.php @@ -0,0 +1,57 @@ + $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) { + if (isset($tmpDb, $lockFp)) { + qdb_discard($tmpDb, $lockFp); + } + $labels = [ + 'remove' => 'Remove', + 'wipeExceptAdmins' => 'Wipe', + 'import' => 'Import', + ]; + $label = $labels[$action ?? 'import'] ?? 'Operation'; + error_log("dev fixture $label: " . $e->getMessage()); + json_error('SERVER_ERROR', "$label failed: " . $e->getMessage(), 500); +} diff --git a/handlers/export.php b/handlers/export.php index c41ccd8..1546946 100644 --- a/handlers/export.php +++ b/handlers/export.php @@ -34,7 +34,7 @@ if (!$questionnaire) { json_error('NOT_FOUND', 'Questionnaire not found', 404); } -$qStmt = $pdo->prepare("SELECT questionID, defaultText, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); +$qStmt = $pdo->prepare("SELECT questionID, defaultText, type, orderIndex FROM question WHERE questionnaireID = :id ORDER BY orderIndex"); $qStmt->execute([':id' => $qnID]); $questions = $qStmt->fetchAll(PDO::FETCH_ASSOC); $questionIDs = array_column($questions, 'questionID'); @@ -94,20 +94,8 @@ foreach ($clients as $c) { foreach ($questions as $q) { $qid = $q['questionID']; - if (isset($answerMap[$qid])) { - $a = $answerMap[$qid]; - if ($a['answerOptionID'] && isset($optionTextMap[$a['answerOptionID']])) { - $row[$q['defaultText']] = $optionTextMap[$a['answerOptionID']]; - } elseif ($a['freeTextValue'] !== null && $a['freeTextValue'] !== '') { - $row[$q['defaultText']] = $a['freeTextValue']; - } elseif ($a['numericValue'] !== null) { - $row[$q['defaultText']] = $a['numericValue']; - } else { - $row[$q['defaultText']] = ''; - } - } else { - $row[$q['defaultText']] = ''; - } + $a = $answerMap[$qid] ?? null; + $row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap); } $rows[] = $row; } diff --git a/handlers/results.php b/handlers/results.php index f11ac0b..b05d4f6 100644 --- a/handlers/results.php +++ b/handlers/results.php @@ -24,7 +24,7 @@ if (!$questionnaire) { } $qStmt = $pdo->prepare(" - SELECT questionID, defaultText, type, orderIndex, isRequired + SELECT questionID, defaultText, type, orderIndex, isRequired, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex "); $qStmt->execute([':id' => $qnID]); diff --git a/lib/dev_fixture.php b/lib/dev_fixture.php new file mode 100644 index 0000000..ea344f2 --- /dev/null +++ b/lib/dev_fixture.php @@ -0,0 +1,748 @@ + 0, + 'supervisors' => 0, + 'coaches' => 0, + 'clients' => 0, + 'completions' => 0, + 'answers' => 0, + ]; + $skipped = [ + 'admins' => 0, + 'supervisors' => 0, + 'coaches' => 0, + 'clients' => 0, + 'completions' => 0, + ]; + $errors = []; + + $coachIdByUsername = []; + $supervisorIdByUsername = []; + + $hash = password_hash($password, PASSWORD_DEFAULT); + $now = time(); + + $pdo->beginTransaction(); + try { + foreach ($fixture['admins'] ?? [] as $row) { + $username = qdb_dev_norm_username($row, $prefix); + if (qdb_dev_user_exists($pdo, $username)) { + $skipped['admins']++; + continue; + } + $entityID = qdb_dev_entity_id('admin', $username); + $userID = qdb_dev_user_id($username); + $location = trim($row['location'] ?? 'Dev'); + $pdo->prepare('INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)') + ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]); + qdb_dev_insert_user($pdo, $userID, $username, $hash, 'admin', $entityID, $now); + $imported['admins']++; + } + + foreach ($fixture['supervisors'] ?? [] as $row) { + $username = qdb_dev_norm_username($row, $prefix); + if (qdb_dev_user_exists($pdo, $username)) { + $skipped['supervisors']++; + $supervisorIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username); + continue; + } + $entityID = qdb_dev_entity_id('supervisor', $username); + $userID = qdb_dev_user_id($username); + $location = trim($row['location'] ?? 'Dev'); + $pdo->prepare('INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)') + ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]); + qdb_dev_insert_user($pdo, $userID, $username, $hash, 'supervisor', $entityID, $now); + $supervisorIdByUsername[$username] = $entityID; + $imported['supervisors']++; + } + + foreach ($fixture['coaches'] ?? [] as $row) { + $username = qdb_dev_norm_username($row, $prefix); + $svUser = trim($row['supervisor'] ?? $row['supervisorUsername'] ?? ''); + if ($svUser === '' || !qdb_dev_has_prefix($svUser, $prefix)) { + $errors[] = "Coach $username: missing or invalid supervisor"; + continue; + } + $supervisorID = $supervisorIdByUsername[$svUser] + ?? qdb_dev_lookup_entity_id($pdo, $svUser); + if ($supervisorID === '') { + $errors[] = "Coach $username: supervisor $svUser not found"; + continue; + } + if (qdb_dev_user_exists($pdo, $username)) { + $skipped['coaches']++; + $coachIdByUsername[$username] = qdb_dev_lookup_entity_id($pdo, $username); + continue; + } + $entityID = qdb_dev_entity_id('coach', $username); + $userID = qdb_dev_user_id($username); + $pdo->prepare('INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)') + ->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]); + qdb_dev_insert_user($pdo, $userID, $username, $hash, 'coach', $entityID, $now); + $coachIdByUsername[$username] = $entityID; + $imported['coaches']++; + } + + foreach ($fixture['clients'] ?? [] as $row) { + $clientCode = qdb_dev_norm_client_code($row, $prefix); + $coachUser = trim($row['coach'] ?? $row['coachUsername'] ?? ''); + if ($coachUser === '' || !qdb_dev_has_prefix($coachUser, $prefix)) { + $errors[] = "Client $clientCode: missing or invalid coach"; + continue; + } + $coachID = $coachIdByUsername[$coachUser] + ?? qdb_dev_lookup_entity_id($pdo, $coachUser); + if ($coachID === '') { + $errors[] = "Client $clientCode: coach $coachUser not found"; + continue; + } + $chk = $pdo->prepare('SELECT 1 FROM client WHERE clientCode = :cc'); + $chk->execute([':cc' => $clientCode]); + if ($chk->fetch()) { + $skipped['clients']++; + continue; + } + $pdo->prepare('INSERT INTO client (clientCode, coachID) VALUES (:cc, :cid)') + ->execute([':cc' => $clientCode, ':cid' => $coachID]); + $imported['clients']++; + } + + $variant = 0; + foreach ($fixture['completions'] ?? [] as $row) { + $clientCode = qdb_dev_norm_client_code($row, $prefix); + $qnID = trim($row['questionnaireID'] ?? ''); + if ($qnID === '') { + $errors[] = 'Completion missing questionnaireID'; + continue; + } + $qnChk = $pdo->prepare('SELECT 1 FROM questionnaire WHERE questionnaireID = :id'); + $qnChk->execute([':id' => $qnID]); + if (!$qnChk->fetch()) { + $errors[] = "Questionnaire not in DB: $qnID"; + continue; + } + $clChk = $pdo->prepare('SELECT coachID FROM client WHERE clientCode = :cc'); + $clChk->execute([':cc' => $clientCode]); + $coachID = $clChk->fetchColumn(); + if ($coachID === false) { + $errors[] = "Client not found: $clientCode"; + continue; + } + $doneChk = $pdo->prepare( + 'SELECT 1 FROM completed_questionnaire WHERE clientCode = :cc AND questionnaireID = :qn' + ); + $doneChk->execute([':cc' => $clientCode, ':qn' => $qnID]); + if ($doneChk->fetch()) { + $skipped['completions']++; + continue; + } + + $completedAt = isset($row['completedAt']) ? (int)$row['completedAt'] : ($now - ($variant % 90) * 86400); + $startedAt = isset($row['startedAt']) ? (int)$row['startedAt'] : ($completedAt - 600); + + $answers = qdb_dev_build_answers_for_questionnaire($pdo, $qnID, $variant); + $answerCount = qdb_dev_persist_submission( + $pdo, + $qnID, + $clientCode, + (string)$coachID, + $answers, + $startedAt, + $completedAt + ); + $imported['completions']++; + $imported['answers'] += $answerCount; + $variant++; + } + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + return ['imported' => $imported, 'skipped' => $skipped, 'errors' => $errors]; +} + +function qdb_dev_validate_fixture(array $fixture): string +{ + $prefix = trim((string)($fixture['prefix'] ?? 'dev_')); + if ($prefix === '' || !preg_match('/^dev[a-z0-9_]*$/i', $prefix)) { + json_error('INVALID_FIELD', 'fixture.prefix must start with "dev" (e.g. dev_)', 400); + } + if ((int)($fixture['fixtureVersion'] ?? 0) !== 1) { + json_error('INVALID_FIELD', 'fixtureVersion must be 1', 400); + } + return $prefix; +} + +function qdb_dev_has_prefix(string $value, string $prefix): bool +{ + return str_starts_with(strtolower($value), strtolower($prefix)) + || str_starts_with(strtoupper($value), strtoupper(rtrim($prefix, '_'))); +} + +function qdb_dev_norm_username(array $row, string $prefix): string +{ + $username = trim($row['username'] ?? ''); + if ($username === '' || !qdb_dev_has_prefix($username, $prefix)) { + json_error('INVALID_FIELD', "Username must use prefix $prefix", 400); + } + return $username; +} + +function qdb_dev_norm_client_code(array $row, string $prefix): string +{ + $code = trim($row['clientCode'] ?? ''); + $upperPrefix = strtoupper(rtrim($prefix, '_')); + if ($code === '' || !str_starts_with($code, $upperPrefix)) { + json_error('INVALID_FIELD', "clientCode must start with $upperPrefix", 400); + } + return $code; +} + +function qdb_dev_entity_id(string $role, string $username): string +{ + return 'dev_ent_' . $role . '_' . substr(hash('sha256', $username), 0, 24); +} + +function qdb_dev_user_id(string $username): string +{ + return 'dev_uid_' . substr(hash('sha256', 'user:' . $username), 0, 28); +} + +function qdb_dev_user_exists(PDO $pdo, string $username): bool +{ + $stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :u'); + $stmt->execute([':u' => $username]); + return (bool)$stmt->fetch(); +} + +function qdb_dev_lookup_entity_id(PDO $pdo, string $username): string +{ + $stmt = $pdo->prepare('SELECT entityID FROM users WHERE username = :u'); + $stmt->execute([':u' => $username]); + $id = $stmt->fetchColumn(); + return $id !== false ? (string)$id : ''; +} + +function qdb_dev_insert_user( + PDO $pdo, + string $userID, + string $username, + string $hash, + string $role, + string $entityID, + int $now +): void { + $mcp = 0; + $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' => $mcp, + ':now' => $now, + ]); +} + +/** + * @return list + */ +function qdb_dev_build_answers_for_questionnaire(PDO $pdo, string $qnID, int $variant): array +{ + $stmt = $pdo->prepare( + 'SELECT questionID, type, configJson, orderIndex + FROM question WHERE questionnaireID = :qn ORDER BY orderIndex' + ); + $stmt->execute([':qn' => $qnID]); + $questions = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $aoStmt = $pdo->prepare( + 'SELECT ao.answerOptionID, ao.questionID, ao.defaultText, ao.points, ao.orderIndex + FROM answer_option ao + JOIN question q ON q.questionID = ao.questionID + WHERE q.questionnaireID = :qn + ORDER BY ao.orderIndex' + ); + $aoStmt->execute([':qn' => $qnID]); + $optionsByQuestion = []; + foreach ($aoStmt->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionsByQuestion[$ao['questionID']][] = $ao; + } + + $answers = []; + $freeTextSamples = ['Testantwort', 'Dev-Eingabe', 'Beispieltext', 'N/A']; + + foreach ($questions as $q) { + $type = $q['type'] ?? ''; + $localId = qdb_question_local_id($q['questionID'], $qnID); + $config = json_decode($q['configJson'] ?? '{}', true) ?: []; + $v = $variant + (int)($q['orderIndex'] ?? 0); + + switch ($type) { + case 'last_page': + break; + + case 'glass_scale_question': + $symData = []; + foreach ($config['symptoms'] ?? [] as $si => $symptomKey) { + $sk = trim((string)$symptomKey); + if ($sk === '') { + continue; + } + $symData[$sk] = QDB_DEV_GLASS_LABELS[($v + $si) % count(QDB_DEV_GLASS_LABELS)]; + } + if ($symData !== []) { + $answers[] = [ + 'questionID' => $localId, + 'freeTextValue' => json_encode($symData, JSON_UNESCAPED_UNICODE), + ]; + } + break; + + case 'radio_question': + case 'client_not_signed': + $opts = $optionsByQuestion[$q['questionID']] ?? []; + if ($opts) { + $pick = $opts[$v % count($opts)]; + $key = qdb_option_key($pick) ?: $pick['defaultText']; + $answers[] = ['questionID' => $localId, 'answerOptionKey' => $key]; + } + break; + + case 'multi_check_box_question': + $opts = $optionsByQuestion[$q['questionID']] ?? []; + if ($opts) { + $count = min(2, count($opts)); + for ($i = 0; $i < $count; $i++) { + $pick = $opts[($v + $i) % count($opts)]; + $key = qdb_option_key($pick) ?: $pick['defaultText']; + $answers[] = ['questionID' => $localId, 'answerOptionKey' => $key]; + } + } + break; + + case 'string_spinner': + $opts = $config['options'] ?? []; + if (is_array($opts) && $opts !== []) { + $answers[] = [ + 'questionID' => $localId, + 'freeTextValue' => (string)$opts[$v % count($opts)], + ]; + } + break; + + case 'value_spinner': + case 'slider_question': + $range = $config['range'] ?? ['min' => 0, 'max' => 10]; + $min = (int)($range['min'] ?? 0); + $max = (int)($range['max'] ?? 10); + if ($max < $min) { + $max = $min; + } + $span = max(1, $max - $min + 1); + $answers[] = [ + 'questionID' => $localId, + 'numericValue' => (float)($min + ($v % $span)), + ]; + break; + + case 'date_spinner': + $year = 2018 + ($v % 6); + $month = 1 + ($v % 12); + $day = 1 + ($v % 28); + $answers[] = [ + 'questionID' => $localId, + 'freeTextValue' => sprintf('%04d-%02d-%02d', $year, $month, $day), + ]; + break; + + case 'free_text': + $answers[] = [ + 'questionID' => $localId, + 'freeTextValue' => $freeTextSamples[$v % count($freeTextSamples)], + ]; + break; + + case 'client_coach_code_question': + $answers[] = [ + 'questionID' => $localId, + 'freeTextValue' => 'DEV-IMPORT', + ]; + break; + + default: + break; + } + } + + return $answers; +} + +/** + * @param list $answers + */ +function qdb_dev_persist_submission( + PDO $pdo, + string $qnID, + string $clientCode, + string $assignedByCoach, + array $answers, + int $startedAt, + int $completedAt +): int { + $qRows = $pdo->prepare('SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn'); + $qRows->execute([':qn' => $qnID]); + $shortIdMap = []; + $freeTextMaxLen = []; + foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { + $fullId = $qRow['questionID']; + $parts = explode('__', $fullId); + $shortIdMap[end($parts)] = $fullId; + if (($qRow['type'] ?? '') === 'free_text') { + $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; + $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); + } + } + + $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 = []; + foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) { + $optionMap[$ao['questionID']][$ao['defaultText']] = [ + 'answerOptionID' => $ao['answerOptionID'], + 'points' => (int)$ao['points'], + ]; + } + + $sumPoints = 0; + $written = 0; + $answeredAt = $completedAt; + $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' + ); + + foreach ($answers as $a) { + $shortId = trim($a['questionID'] ?? ''); + if ($shortId === '') { + continue; + } + + $fullQID = $shortIdMap[$shortId] ?? null; + if ($fullQID === null) { + continue; + } + + $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; + } + } + + $answerOptionID = null; + $optionKey = $a['answerOptionKey'] ?? null; + if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { + $opt = $optionMap[$fullQID][(string)$optionKey]; + $answerOptionID = $opt['answerOptionID']; + $sumPoints += $opt['points']; + } + + $answerInsert->execute([ + ':cc' => $clientCode, + ':qid' => $fullQID, + ':aoid' => $answerOptionID, + ':ftv' => $freeTextValue, + ':nv' => $numericValue, + ':at' => $answeredAt, + ]); + $written++; + } + + $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, + ]); + + return $written; +} + +/** @param list $ids */ +function qdb_dev_delete_by_ids(PDO $pdo, string $table, string $column, array $ids): int +{ + $ids = array_values(array_unique(array_filter($ids, static fn($id) => $id !== '' && $id !== null))); + if ($ids === []) { + return 0; + } + $total = 0; + foreach (array_chunk($ids, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + $stmt = $pdo->prepare("DELETE FROM $table WHERE $column IN ($ph)"); + $stmt->execute($chunk); + $total += $stmt->rowCount(); + } + return $total; +} + +/** + * Remove dev-prefixed test users, clients, and their answers (does not touch other accounts). + * + * @return array{deleted: array} + */ +function qdb_remove_dev_test_data(PDO $pdo, array $options = []): array +{ + $usernamePrefix = trim((string)($options['usernamePrefix'] ?? 'dev_')); + $clientPrefix = trim((string)($options['clientCodePrefix'] ?? 'DEV-CL-')); + if ($usernamePrefix === '' || stripos($usernamePrefix, 'dev') !== 0) { + json_error('INVALID_FIELD', 'usernamePrefix must start with "dev"', 400); + } + if ($clientPrefix === '' || stripos($clientPrefix, 'DEV') !== 0) { + json_error('INVALID_FIELD', 'clientCodePrefix must start with "DEV"', 400); + } + + $userLike = $usernamePrefix . '%'; + $clientLike = $clientPrefix . '%'; + + $deleted = [ + 'client_answers' => 0, + 'completed_questionnaires' => 0, + 'clients' => 0, + 'sessions' => 0, + 'users' => 0, + 'coaches' => 0, + 'supervisors' => 0, + 'admins' => 0, + ]; + + $coachIds = []; + $stmt = $pdo->prepare('SELECT coachID FROM coach WHERE username LIKE :pfx'); + $stmt->execute([':pfx' => $userLike]); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) { + $coachIds[$id] = true; + } + + $supervisorIds = []; + $stmt = $pdo->prepare('SELECT supervisorID FROM supervisor WHERE username LIKE :pfx'); + $stmt->execute([':pfx' => $userLike]); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) { + $supervisorIds[$id] = true; + } + + $adminIds = []; + $stmt = $pdo->prepare('SELECT adminID FROM admin WHERE username LIKE :pfx'); + $stmt->execute([':pfx' => $userLike]); + foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $id) { + $adminIds[$id] = true; + } + + $userIds = []; + $userRows = $pdo->prepare('SELECT userID, role, entityID FROM users WHERE username LIKE :pfx'); + $userRows->execute([':pfx' => $userLike]); + foreach ($userRows->fetchAll(PDO::FETCH_ASSOC) as $u) { + $userIds[$u['userID']] = true; + switch ($u['role']) { + case 'coach': + $coachIds[$u['entityID']] = true; + break; + case 'supervisor': + $supervisorIds[$u['entityID']] = true; + break; + case 'admin': + $adminIds[$u['entityID']] = true; + break; + } + } + + $coachIdList = array_keys($coachIds); + $clientCodes = []; + $ccStmt = $pdo->prepare('SELECT clientCode FROM client WHERE clientCode LIKE :pfx'); + $ccStmt->execute([':pfx' => $clientLike]); + foreach ($ccStmt->fetchAll(PDO::FETCH_COLUMN) as $code) { + $clientCodes[$code] = true; + } + if ($coachIdList !== []) { + foreach (array_chunk($coachIdList, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + $byCoach = $pdo->prepare("SELECT clientCode FROM client WHERE coachID IN ($ph)"); + $byCoach->execute($chunk); + foreach ($byCoach->fetchAll(PDO::FETCH_COLUMN) as $code) { + $clientCodes[$code] = true; + } + } + } + $clientCodeList = array_keys($clientCodes); + + $pdo->beginTransaction(); + try { + $deleted['client_answers'] = qdb_dev_delete_by_ids($pdo, 'client_answer', 'clientCode', $clientCodeList); + + if ($clientCodeList !== []) { + foreach (array_chunk($clientCodeList, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + $delCq = $pdo->prepare("DELETE FROM completed_questionnaire WHERE clientCode IN ($ph)"); + $delCq->execute($chunk); + $deleted['completed_questionnaires'] += $delCq->rowCount(); + } + } + if ($coachIdList !== []) { + $deleted['completed_questionnaires'] += qdb_dev_delete_by_ids( + $pdo, + 'completed_questionnaire', + 'assignedByCoach', + $coachIdList + ); + } + + if ($clientCodeList !== []) { + $deleted['clients'] += qdb_dev_delete_by_ids($pdo, 'client', 'clientCode', $clientCodeList); + } + if ($coachIdList !== []) { + foreach (array_chunk($coachIdList, 200) as $chunk) { + $ph = implode(',', array_fill(0, count($chunk), '?')); + $delCl = $pdo->prepare("DELETE FROM client WHERE coachID IN ($ph)"); + $delCl->execute($chunk); + $deleted['clients'] += $delCl->rowCount(); + } + } + $delClPrefix = $pdo->prepare('DELETE FROM client WHERE clientCode LIKE :pfx'); + $delClPrefix->execute([':pfx' => $clientLike]); + $deleted['clients'] += $delClPrefix->rowCount(); + + $deleted['sessions'] = qdb_dev_delete_by_ids($pdo, 'session', 'userID', array_keys($userIds)); + + $deleted['users'] = qdb_dev_delete_by_ids($pdo, 'users', 'userID', array_keys($userIds)); + $delUsersLike = $pdo->prepare('DELETE FROM users WHERE username LIKE :pfx'); + $delUsersLike->execute([':pfx' => $userLike]); + $deleted['users'] += $delUsersLike->rowCount(); + + $deleted['coaches'] = qdb_dev_delete_by_ids($pdo, 'coach', 'coachID', $coachIdList); + $delCoachLike = $pdo->prepare('DELETE FROM coach WHERE username LIKE :pfx'); + $delCoachLike->execute([':pfx' => $userLike]); + $deleted['coaches'] += $delCoachLike->rowCount(); + + $deleted['supervisors'] = qdb_dev_delete_by_ids($pdo, 'supervisor', 'supervisorID', array_keys($supervisorIds)); + $delSvLike = $pdo->prepare('DELETE FROM supervisor WHERE username LIKE :pfx'); + $delSvLike->execute([':pfx' => $userLike]); + $deleted['supervisors'] += $delSvLike->rowCount(); + + $deleted['admins'] = qdb_dev_delete_by_ids($pdo, 'admin', 'adminID', array_keys($adminIds)); + $delAdLike = $pdo->prepare('DELETE FROM admin WHERE username LIKE :pfx'); + $delAdLike->execute([':pfx' => $userLike]); + $deleted['admins'] += $delAdLike->rowCount(); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + return ['deleted' => $deleted]; +} + +/** + * Remove all clients, completions, coaches, and supervisors. Keeps admin accounts + * and questionnaire definitions (questions, translations, etc.). + * + * @return array{deleted: array, kept: array} + */ +function qdb_wipe_all_data_except_admins(PDO $pdo): array +{ + $deleted = [ + 'client_answers' => 0, + 'completed_questionnaires' => 0, + 'clients' => 0, + 'sessions' => 0, + 'users' => 0, + 'coaches' => 0, + 'supervisors' => 0, + ]; + + $keptStmt = $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'admin'"); + $keptAdmins = (int)$keptStmt->fetchColumn(); + + $pdo->beginTransaction(); + try { + $deleted['client_answers'] = (int)$pdo->exec('DELETE FROM client_answer'); + $deleted['completed_questionnaires'] = (int)$pdo->exec('DELETE FROM completed_questionnaire'); + $deleted['clients'] = (int)$pdo->exec('DELETE FROM client'); + + $sess = $pdo->prepare( + "DELETE FROM session WHERE userID IN (SELECT userID FROM users WHERE role != 'admin')" + ); + $sess->execute(); + $deleted['sessions'] = $sess->rowCount(); + + $users = $pdo->prepare("DELETE FROM users WHERE role != 'admin'"); + $users->execute(); + $deleted['users'] = $users->rowCount(); + + $deleted['coaches'] = (int)$pdo->exec('DELETE FROM coach'); + $deleted['supervisors'] = (int)$pdo->exec('DELETE FROM supervisor'); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $e; + } + + return [ + 'deleted' => $deleted, + 'kept' => ['admins' => $keptAdmins], + ]; +} diff --git a/scripts/generate_dev_fixture.py b/scripts/generate_dev_fixture.py new file mode 100644 index 0000000..52cad3f --- /dev/null +++ b/scripts/generate_dev_fixture.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Generate dev-test-fixture.json for admin Dev tab import.""" +import json +from pathlib import Path + +PREFIX = "dev_" +PASSWORD = "socialvrlab" +CLIENT_PREFIX = "DEV-CL-" + +QUESTIONNAIRE_IDS = [ + "questionnaire_1_demographic_information", + "questionnaire_2_rhs", + "questionnaire_3_integration_index", + "questionnaire_4_consultation_results", + "questionnaire_5_final_interview", + "questionnaire_6_follow_up_survey", +] + +PER_QUESTIONNAIRE = 40 +NUM_ADMINS = 2 +NUM_SUPERVISORS = 3 +NUM_COACHES = 20 +NUM_CLIENTS = 100 +CLIENTS_WITHOUT_COMPLETIONS = 15 # DEV-CL-0086 .. 0100 + + +def main(): + admins = [ + {"username": f"{PREFIX}admin_{i}", "location": f"Dev Admin Standort {i}"} + for i in range(1, NUM_ADMINS + 1) + ] + supervisors = [ + {"username": f"{PREFIX}supervisor_{i}", "location": f"Dev Supervisor Region {i}"} + for i in range(1, NUM_SUPERVISORS + 1) + ] + coaches = [] + for i in range(1, NUM_COACHES + 1): + sv = (i - 1) % NUM_SUPERVISORS + 1 + coaches.append({ + "username": f"{PREFIX}coach_{i:02d}", + "supervisor": f"{PREFIX}supervisor_{sv}", + }) + + clients = [] + for i in range(1, NUM_CLIENTS + 1): + coach_idx = (i - 1) // 5 + 1 + if coach_idx > NUM_COACHES: + coach_idx = NUM_COACHES + clients.append({ + "clientCode": f"{CLIENT_PREFIX}{i:04d}", + "coach": f"{PREFIX}coach_{coach_idx:02d}", + }) + + clients_with_data = [ + c["clientCode"] + for c in clients + if int(c["clientCode"].split("-")[-1]) <= NUM_CLIENTS - CLIENTS_WITHOUT_COMPLETIONS + ] + + completions = [] + slot = 0 + for qn_id in QUESTIONNAIRE_IDS: + for _ in range(PER_QUESTIONNAIRE): + client_code = clients_with_data[slot % len(clients_with_data)] + completions.append({ + "clientCode": client_code, + "questionnaireID": qn_id, + }) + slot += 1 + + fixture = { + "fixtureVersion": 1, + "description": "Dev/test users, clients, and completed questionnaires. Skip-on-conflict import; password socialvrlab.", + "prefix": PREFIX, + "defaultPassword": PASSWORD, + "admins": admins, + "supervisors": supervisors, + "coaches": coaches, + "clients": clients, + "completions": completions, + "stats": { + "admins": len(admins), + "supervisors": len(supervisors), + "coaches": len(coaches), + "clients": len(clients), + "clientsWithoutCompletions": CLIENTS_WITHOUT_COMPLETIONS, + "completions": len(completions), + "perQuestionnaire": PER_QUESTIONNAIRE, + }, + } + + out = Path(__file__).resolve().parents[2] / "dev-test-fixture.json" + out.write_text(json.dumps(fixture, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {out}") + print(json.dumps(fixture["stats"], indent=2)) + + +if __name__ == "__main__": + main() diff --git a/website/index.html b/website/index.html index 65f49b4..3463926 100644 --- a/website/index.html +++ b/website/index.html @@ -51,6 +51,12 @@ Export Data +
  • + + + Dev import + +