added prototype dev settings with test import and db wipe
This commit is contained in:
@ -54,7 +54,7 @@ if (!$questionnaire) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Questions ordered
|
// 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]);
|
$qStmt->execute([':id' => $qnID]);
|
||||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
$questionIDs = array_column($questions, 'questionID');
|
$questionIDs = array_column($questions, 'questionID');
|
||||||
@ -117,20 +117,8 @@ foreach ($clients as $c) {
|
|||||||
|
|
||||||
foreach ($questions as $q) {
|
foreach ($questions as $q) {
|
||||||
$qid = $q['questionID'];
|
$qid = $q['questionID'];
|
||||||
if (isset($answerMap[$qid])) {
|
$a = $answerMap[$qid] ?? null;
|
||||||
$a = $answerMap[$qid];
|
$row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap);
|
||||||
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']] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,8 @@ $routes = [
|
|||||||
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
'auth/login' => __DIR__ . '/../handlers/auth.php',
|
||||||
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
'auth/change-password' => __DIR__ . '/../handlers/auth.php',
|
||||||
'backup' => __DIR__ . '/../handlers/backup.php',
|
'backup' => __DIR__ . '/../handlers/backup.php',
|
||||||
|
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||||
|
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||||
'download' => __DIR__ . '/../handlers/download.php',
|
'download' => __DIR__ . '/../handlers/download.php',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
87
common.php
87
common.php
@ -467,6 +467,93 @@ function qdb_option_key(array $aoRow): string {
|
|||||||
return qdb_is_stable_key($dt) ? $dt : '';
|
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<string, string> 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 {
|
function qdb_note_before_key(string $questionKey): string {
|
||||||
return $questionKey . '_note_before';
|
return $questionKey . '_note_before';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,6 +60,7 @@ if ($method === 'POST') {
|
|||||||
$qRows->execute([':qn' => $qnID]);
|
$qRows->execute([':qn' => $qnID]);
|
||||||
$shortIdMap = []; // shortId -> fullQuestionID
|
$shortIdMap = []; // shortId -> fullQuestionID
|
||||||
$freeTextMaxLen = []; // fullQuestionID -> maxLength
|
$freeTextMaxLen = []; // fullQuestionID -> maxLength
|
||||||
|
$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);
|
||||||
@ -89,6 +90,7 @@ if ($method === 'POST') {
|
|||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
$sumPoints = 0;
|
$sumPoints = 0;
|
||||||
|
$glassByParent = [];
|
||||||
$answerInsert = $pdo->prepare("
|
$answerInsert = $pdo->prepare("
|
||||||
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
|
||||||
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
|
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
|
||||||
@ -103,14 +105,25 @@ if ($method === 'POST') {
|
|||||||
$shortId = trim($a['questionID'] ?? '');
|
$shortId = trim($a['questionID'] ?? '');
|
||||||
if ($shortId === '') continue;
|
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
|
// Resolve short ID to full questionID
|
||||||
$fullQID = $shortIdMap[$shortId] ?? null;
|
$fullQID = $shortIdMap[$shortId] ?? null;
|
||||||
if ($fullQID === null) continue; // skip unknown questions
|
if ($fullQID === null) continue;
|
||||||
|
|
||||||
$answerOptionID = null;
|
$answerOptionID = null;
|
||||||
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
|
$freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null;
|
||||||
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
$numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null;
|
||||||
$answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null;
|
|
||||||
|
|
||||||
if ($freeTextValue !== null && $freeTextValue !== '') {
|
if ($freeTextValue !== null && $freeTextValue !== '') {
|
||||||
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
|
$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
|
// Upsert completed_questionnaire record
|
||||||
$pdo->prepare("
|
$pdo->prepare("
|
||||||
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
INSERT INTO completed_questionnaire (clientCode, questionnaireID, assignedByCoach, status, startedAt, completedAt, sumPoints)
|
||||||
|
|||||||
57
handlers/dev.php
Normal file
57
handlers/dev.php
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Admin-only dev fixture import (local / test environments).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../lib/dev_fixture.php';
|
||||||
|
|
||||||
|
$tokenRec = require_valid_token_web();
|
||||||
|
require_role(['admin'], $tokenRec);
|
||||||
|
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = read_json_body();
|
||||||
|
$action = trim((string)($body['action'] ?? 'import'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
|
|
||||||
|
if ($action === 'remove') {
|
||||||
|
$result = qdb_remove_dev_test_data($pdo, [
|
||||||
|
'usernamePrefix' => $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);
|
||||||
|
}
|
||||||
@ -34,7 +34,7 @@ if (!$questionnaire) {
|
|||||||
json_error('NOT_FOUND', 'Questionnaire not found', 404);
|
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]);
|
$qStmt->execute([':id' => $qnID]);
|
||||||
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
$questions = $qStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
$questionIDs = array_column($questions, 'questionID');
|
$questionIDs = array_column($questions, 'questionID');
|
||||||
@ -94,20 +94,8 @@ foreach ($clients as $c) {
|
|||||||
|
|
||||||
foreach ($questions as $q) {
|
foreach ($questions as $q) {
|
||||||
$qid = $q['questionID'];
|
$qid = $q['questionID'];
|
||||||
if (isset($answerMap[$qid])) {
|
$a = $answerMap[$qid] ?? null;
|
||||||
$a = $answerMap[$qid];
|
$row[$q['defaultText']] = qdb_format_client_answer_display($q, $a, $optionTextMap);
|
||||||
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']] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ if (!$questionnaire) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$qStmt = $pdo->prepare("
|
$qStmt = $pdo->prepare("
|
||||||
SELECT questionID, defaultText, type, orderIndex, isRequired
|
SELECT questionID, defaultText, type, orderIndex, isRequired, configJson
|
||||||
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
FROM question WHERE questionnaireID = :id ORDER BY orderIndex
|
||||||
");
|
");
|
||||||
$qStmt->execute([':id' => $qnID]);
|
$qStmt->execute([':id' => $qnID]);
|
||||||
|
|||||||
748
lib/dev_fixture.php
Normal file
748
lib/dev_fixture.php
Normal file
@ -0,0 +1,748 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Dev-only fixture import: users, clients, completed questionnaires.
|
||||||
|
* All entity usernames / client codes must use the fixture prefix (default dev_).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const QDB_DEV_GLASS_LABELS = ['never_glass', 'little_glass', 'moderate_glass', 'much_glass', 'extreme_glass'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{imported: array, skipped: array, errors: string[]}
|
||||||
|
*/
|
||||||
|
function qdb_import_dev_fixture(PDO $pdo, array $fixture): array
|
||||||
|
{
|
||||||
|
$prefix = qdb_dev_validate_fixture($fixture);
|
||||||
|
$password = (string)($fixture['defaultPassword'] ?? 'socialvrlab');
|
||||||
|
if (strlen($password) < 6) {
|
||||||
|
json_error('INVALID_FIELD', 'defaultPassword must be at least 6 characters', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$imported = [
|
||||||
|
'admins' => 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<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}>
|
||||||
|
*/
|
||||||
|
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<array{questionID: string, answerOptionKey?: string, freeTextValue?: string, numericValue?: float}> $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<string> $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<string, int>}
|
||||||
|
*/
|
||||||
|
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<string, int>, kept: array<string, int>}
|
||||||
|
*/
|
||||||
|
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],
|
||||||
|
];
|
||||||
|
}
|
||||||
99
scripts/generate_dev_fixture.py
Normal file
99
scripts/generate_dev_fixture.py
Normal file
@ -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()
|
||||||
@ -51,6 +51,12 @@
|
|||||||
Export Data
|
Export Data
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li data-nav-roles="admin">
|
||||||
|
<a href="#/dev">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>
|
||||||
|
Dev import
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-info" id="userInfo"></div>
|
<div class="user-info" id="userInfo"></div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { usersPage } from './pages/users.js';
|
|||||||
import { assignmentsPage } from './pages/assignments.js';
|
import { assignmentsPage } from './pages/assignments.js';
|
||||||
import { clientsPage } from './pages/clients.js';
|
import { clientsPage } from './pages/clients.js';
|
||||||
import { translationsPage } from './pages/translations.js';
|
import { translationsPage } from './pages/translations.js';
|
||||||
|
import { devPage } from './pages/dev.js';
|
||||||
|
|
||||||
// Auth state
|
// Auth state
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
@ -108,6 +109,7 @@ addRoute('/users', roleGuard(['admin', 'supervisor'], usersPage));
|
|||||||
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
addRoute('/assignments', roleGuard(['admin', 'supervisor'], assignmentsPage));
|
||||||
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
addRoute('/clients', roleGuard(['admin', 'supervisor'], clientsPage));
|
||||||
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
addRoute('/translations', roleGuard(['admin', 'supervisor'], translationsPage));
|
||||||
|
addRoute('/dev', roleGuard(['admin'], devPage));
|
||||||
|
|
||||||
// Nav link handling & logout
|
// Nav link handling & logout
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|||||||
186
website/js/pages/dev.js
Normal file
186
website/js/pages/dev.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { apiPost } from '../api.js';
|
||||||
|
import { getRole, showToast } from '../app.js';
|
||||||
|
import { navigate } from '../router.js';
|
||||||
|
|
||||||
|
export function devPage() {
|
||||||
|
if (getRole() !== 'admin') {
|
||||||
|
navigate('#/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
app.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Dev import</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:720px">
|
||||||
|
<p class="field-hint" style="margin-bottom:16px;padding:12px;background:#fff8e1;border-radius:8px;color:#7a5d00">
|
||||||
|
<strong>Local / test only.</strong> Imports prefixed dev users (<code>dev_*</code>),
|
||||||
|
clients (<code>DEV-CL-*</code>), and completed questionnaire runs.
|
||||||
|
Existing real users are not modified. Conflicts are skipped (usernames, client codes, completions).
|
||||||
|
Default password: <code>socialvrlab</code>. Questionnaires must already exist in the database.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="devFixtureFile">Fixture JSON</label>
|
||||||
|
<input type="file" id="devFixtureFile" accept=".json,application/json">
|
||||||
|
<span class="field-hint">Use <code>dev-test-fixture.json</code> from the project root (generate via <code>scripts/generate_dev_fixture.py</code>).</span>
|
||||||
|
</div>
|
||||||
|
<div id="devFixtureSummary" class="field-hint" style="margin:12px 0;display:none"></div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button type="button" class="btn btn-primary" id="devImportBtn" disabled>Import fixture</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="devRemoveBtn">Remove test data</button>
|
||||||
|
</div>
|
||||||
|
<pre id="devImportResult" style="margin-top:16px;font-size:.8rem;max-height:320px;overflow:auto;display:none"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="card" style="max-width:720px;margin-top:16px">
|
||||||
|
<h2 style="margin:0 0 8px;font-size:1.1rem">Reset database</h2>
|
||||||
|
<p class="field-hint" style="margin-bottom:12px;padding:12px;background:#fdecea;border-radius:8px;color:#8a1f17">
|
||||||
|
<strong>Destructive.</strong> Deletes every client, completion, coach, and supervisor
|
||||||
|
(including non-dev accounts). Admin logins and questionnaire definitions are kept.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn-danger" id="devWipeBtn">Remove all data (keep admins)</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let parsedFixture = null;
|
||||||
|
|
||||||
|
document.getElementById('devFixtureFile').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
const summary = document.getElementById('devFixtureSummary');
|
||||||
|
const btn = document.getElementById('devImportBtn');
|
||||||
|
parsedFixture = null;
|
||||||
|
btn.disabled = true;
|
||||||
|
summary.style.display = 'none';
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
parsedFixture = JSON.parse(text);
|
||||||
|
const st = parsedFixture.stats || {};
|
||||||
|
summary.innerHTML = `
|
||||||
|
<strong>Preview:</strong>
|
||||||
|
${parsedFixture.admins?.length ?? st.admins ?? 0} admins,
|
||||||
|
${parsedFixture.supervisors?.length ?? st.supervisors ?? 0} supervisors,
|
||||||
|
${parsedFixture.coaches?.length ?? st.coaches ?? 0} coaches,
|
||||||
|
${parsedFixture.clients?.length ?? st.clients ?? 0} clients,
|
||||||
|
${parsedFixture.completions?.length ?? st.completions ?? 0} completions
|
||||||
|
`;
|
||||||
|
summary.style.display = '';
|
||||||
|
btn.disabled = false;
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Invalid JSON: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm(
|
||||||
|
'Remove all dev test data?\n\n'
|
||||||
|
+ 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n'
|
||||||
|
+ 'Real accounts are not affected.'
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('devRemoveBtn');
|
||||||
|
const pre = document.getElementById('devImportResult');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Removing…';
|
||||||
|
pre.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiPost('dev', {
|
||||||
|
action: 'remove',
|
||||||
|
usernamePrefix: 'dev_',
|
||||||
|
clientCodePrefix: 'DEV-CL-',
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
showToast(res.error || res.message || 'Remove failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pre.textContent = JSON.stringify(res, null, 2);
|
||||||
|
pre.style.display = '';
|
||||||
|
const d = res.deleted || {};
|
||||||
|
showToast(
|
||||||
|
`Removed ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
||||||
|
+ `${d.completed_questionnaires ?? 0} completions`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Remove test data';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('devWipeBtn').addEventListener('click', async () => {
|
||||||
|
if (!confirm(
|
||||||
|
'Remove ALL clients, completions, coaches, and supervisors?\n\n'
|
||||||
|
+ 'Admin accounts and questionnaires will NOT be deleted.\n'
|
||||||
|
+ 'This cannot be undone.'
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = document.getElementById('devWipeBtn');
|
||||||
|
const pre = document.getElementById('devImportResult');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Removing…';
|
||||||
|
pre.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiPost('dev', { action: 'wipeExceptAdmins' });
|
||||||
|
if (!res.success) {
|
||||||
|
showToast(res.error || res.message || 'Wipe failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pre.textContent = JSON.stringify(res, null, 2);
|
||||||
|
pre.style.display = '';
|
||||||
|
const d = res.deleted || {};
|
||||||
|
const kept = res.kept?.admins ?? '?';
|
||||||
|
showToast(
|
||||||
|
`Wiped ${d.clients ?? 0} clients, ${d.users ?? 0} users, `
|
||||||
|
+ `${d.completed_questionnaires ?? 0} completions (${kept} admins kept)`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Remove all data (keep admins)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||||
|
if (!parsedFixture) return;
|
||||||
|
const btn = document.getElementById('devImportBtn');
|
||||||
|
const pre = document.getElementById('devImportResult');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Importing…';
|
||||||
|
pre.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiPost('dev', { fixture: parsedFixture });
|
||||||
|
if (!res.success) {
|
||||||
|
showToast(res.error || 'Import failed', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pre.textContent = JSON.stringify(res, null, 2);
|
||||||
|
pre.style.display = '';
|
||||||
|
const imp = res.imported || {};
|
||||||
|
const sk = res.skipped || {};
|
||||||
|
showToast(
|
||||||
|
`Imported: ${imp.completions ?? 0} completions, ${imp.clients ?? 0} clients, `
|
||||||
|
+ `${imp.coaches ?? 0} coaches (skipped ${sk.completions ?? 0} existing)`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
if (res.errors?.length) {
|
||||||
|
showToast(`${res.errors.length} warning(s) — see log below`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import fixture';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -257,6 +257,10 @@ function renderTableRows() {
|
|||||||
const questionCells = questionsDef.map(q => {
|
const questionCells = questionsDef.map(q => {
|
||||||
const ans = c.answers && c.answers[q.questionID];
|
const ans = c.answers && c.answers[q.questionID];
|
||||||
if (!ans) return '<td>—</td>';
|
if (!ans) return '<td>—</td>';
|
||||||
|
if (q.type === 'glass_scale_question') {
|
||||||
|
const text = formatGlassAnswer(ans.freeTextValue);
|
||||||
|
return `<td>${esc(text || '—')}</td>`;
|
||||||
|
}
|
||||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
|
if (ans.answerOptionID && optionMap[ans.answerOptionID]) {
|
||||||
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
|
return `<td>${esc(optionMap[ans.answerOptionID])}</td>`;
|
||||||
}
|
}
|
||||||
@ -286,8 +290,12 @@ function getCellValue(client, key) {
|
|||||||
if (key === 'completedAt') return client.completedAt;
|
if (key === 'completedAt') return client.completedAt;
|
||||||
if (key.startsWith('q_')) {
|
if (key.startsWith('q_')) {
|
||||||
const qid = key.substring(2);
|
const qid = key.substring(2);
|
||||||
|
const q = questionsDef.find(qq => qq.questionID === qid);
|
||||||
const ans = client.answers && client.answers[qid];
|
const ans = client.answers && client.answers[qid];
|
||||||
if (!ans) return null;
|
if (!ans) return null;
|
||||||
|
if (q?.type === 'glass_scale_question') {
|
||||||
|
return formatGlassAnswer(ans.freeTextValue) || null;
|
||||||
|
}
|
||||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||||
if (ans.freeTextValue) return ans.freeTextValue;
|
if (ans.freeTextValue) return ans.freeTextValue;
|
||||||
return ans.answerOptionID || null;
|
return ans.answerOptionID || null;
|
||||||
@ -315,6 +323,9 @@ function exportCSV() {
|
|||||||
const answerCols = questionsDef.map(q => {
|
const answerCols = questionsDef.map(q => {
|
||||||
const ans = c.answers && c.answers[q.questionID];
|
const ans = c.answers && c.answers[q.questionID];
|
||||||
if (!ans) return '';
|
if (!ans) return '';
|
||||||
|
if (q.type === 'glass_scale_question') {
|
||||||
|
return formatGlassAnswer(ans.freeTextValue);
|
||||||
|
}
|
||||||
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
|
if (ans.answerOptionID && optionMap[ans.answerOptionID]) return optionMap[ans.answerOptionID];
|
||||||
if (ans.freeTextValue) return ans.freeTextValue;
|
if (ans.freeTextValue) return ans.freeTextValue;
|
||||||
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
if (ans.numericValue !== null && ans.numericValue !== undefined) return ans.numericValue;
|
||||||
@ -345,6 +356,22 @@ function csvEsc(val) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatGlassAnswer(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(raw);
|
||||||
|
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||||
|
return Object.entries(o)
|
||||||
|
.filter(([, v]) => v != null && String(v).trim() !== '')
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* plain text fallback */
|
||||||
|
}
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = s ?? '';
|
d.textContent = s ?? '';
|
||||||
|
|||||||
Reference in New Issue
Block a user