ordered questionnaire list with conditions * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> global app UI strings only (not questionnaire content); **no auth** (login screen). * GET ?clients=1 -> list of clients assigned to the authenticated coach * GET ?clientCode=X&answers=1 -> all completed questionnaire answers for one client (encrypted) * GET ?answersBulk=1 -> all assigned clients' answers in one payload (coach, encrypted) * GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted) * GET ?scoringReview=1 -> coach review state (coachBand only; computed on device) * POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted) * POST -> submit interview answers for a client (coach / supervisor / admin). * Re-posting the same clientCode + questionnaireID updates answers in place (re-edit). * Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token). */ // Public app UI catalog for the login screen (no token, no PII). if ($method === 'GET' && !empty($_GET['translations'])) { $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; json_success(['translations' => qdb_build_app_translations_map($pdo)]); qdb_discard($tmpDb, $lockFp); return; } $tokenRec = require_valid_token(); if ($method === 'POST') { require_role(['admin', 'supervisor', 'coach'], $tokenRec); $body = read_encrypted_json_body($tokenRec['_token']); if (($body['action'] ?? '') === 'coachScoringReview') { require_fields($body, ['clientCode', 'profileID', 'coachBand']); $clientCode = trim((string)$body['clientCode']); $profileID = trim((string)$body['profileID']); $coachBand = trim((string)($body['coachBand'] ?? '')); $calculatedBand = trim((string)($body['calculatedBand'] ?? '')); $weightedTotal = isset($body['weightedTotal']) ? (float)$body['weightedTotal'] : null; try { $opened = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $clStmt = $pdo->prepare( "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" ); $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); if (!$clStmt->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Client not found or not authorized', 404); } require_once __DIR__ . '/../lib/scoring.php'; if (!qdb_is_valid_scoring_band($coachBand)) { qdb_discard($tmpDb, $lockFp); json_error('INVALID_FIELD', 'coachBand must be green, yellow, or red', 400); } $profile = qdb_save_coach_scoring_review( $pdo, $clientCode, $profileID, $coachBand, (string)($tokenRec['userID'] ?? ''), $weightedTotal, $calculatedBand !== '' ? $calculatedBand : null, ); qdb_save($tmpDb, $lockFp); json_success(['profile' => $profile]); } catch (InvalidArgumentException $e) { qdb_discard($tmpDb ?? null, $lockFp ?? null); json_error('INVALID_FIELD', $e->getMessage(), 400); } catch (RuntimeException $e) { qdb_discard($tmpDb ?? null, $lockFp ?? null); json_error('NOT_FOUND', $e->getMessage(), 404); } catch (Throwable $e) { qdb_handler_fail($e, 'Coach scoring review', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } } require_fields($body, ['questionnaireID', 'clientCode', 'answers']); $qnID = trim($body['questionnaireID']); $clientCode = trim($body['clientCode']); $answers = $body['answers']; if (!is_array($answers)) { json_error('INVALID_FIELD', 'answers must be an array', 400); } $startedAt = isset($body['startedAt']) ? (int)$body['startedAt'] : null; $completedAt = isset($body['completedAt']) ? (int)$body['completedAt'] : null; try { $opened = qdb_open_write_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; // Verify questionnaire exists $qnStmt = $pdo->prepare("SELECT questionnaireID FROM questionnaire WHERE questionnaireID = :id"); $qnStmt->execute([':id' => $qnID]); if (!$qnStmt->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Questionnaire not found', 404); } [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $clStmt = $pdo->prepare( "SELECT cl.clientCode, cl.coachID FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" ); $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); $clientRow = $clStmt->fetch(PDO::FETCH_ASSOC); if (!$clientRow) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Client not found or not authorized', 404); } $assignedByCoach = ($tokenRec['role'] === 'coach') ? ($tokenRec['entityID'] ?? '') : ($clientRow['coachID'] ?? ''); // Load all questions for this questionnaire: full questionID keyed by short ID $qRows = $pdo->prepare( "SELECT questionID, type, configJson FROM question WHERE questionnaireID = :qn" ); $qRows->execute([':qn' => $qnID]); $shortIdMap = []; // shortId -> fullQuestionID $shortIdToType = []; // shortId -> layout type $freeTextMaxLen = []; // fullQuestionID -> maxLength $symptomParentMap = qdb_glass_symptom_parent_map($pdo, $qnID); foreach ($qRows->fetchAll(PDO::FETCH_ASSOC) as $qRow) { $fullId = $qRow['questionID']; $parts = explode('__', $fullId); $short = end($parts); $shortIdMap[$short] = $fullId; $shortIdToType[$short] = $qRow['type'] ?? ''; if (($qRow['type'] ?? '') === 'free_text') { $cfg = json_decode($qRow['configJson'] ?? '{}', true) ?: []; $freeTextMaxLen[$fullId] = max(1, min((int)($cfg['maxLength'] ?? 500), 10000)); } } // Load all answer options for this questionnaire: keyed by [questionID][defaultText] $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 = []; // fullQuestionID -> [defaultText -> {answerOptionID, points}] foreach ($aoRows->fetchAll(PDO::FETCH_ASSOC) as $ao) { $optionMap[$ao['questionID']][$ao['defaultText']] = [ 'answerOptionID' => $ao['answerOptionID'], 'points' => (int)$ao['points'], ]; } require_once __DIR__ . '/../lib/app_submit_validate.php'; $validationErrors = qdb_validate_app_submit_payload( $pdo, $qnID, $answers, $shortIdMap, $shortIdToType, $symptomParentMap, $optionMap ); if ($validationErrors !== []) { qdb_discard($tmpDb, $lockFp); json_error( 'VALIDATION_FAILED', 'Some answers are invalid or missing', 400, ['errors' => $validationErrors] ); } $pdo->beginTransaction(); $glassByParent = []; $submittedQuestionIds = []; $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 "); $answerDelete = $pdo->prepare( 'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid' ); require_once __DIR__ . '/../lib/app_answers.php'; $answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap); foreach ($answers as $a) { $shortId = trim($a['questionID'] ?? ''); if ($shortId === '') { continue; } $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; } $answerOptionID = null; $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 === '') { $answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]); $submittedQuestionIds[$fullQID] = true; continue; } } // Resolve option key to answerOptionID (points computed by scoring engine after save) $optionKey = $a['answerOptionKey'] ?? null; if ($optionKey !== null && isset($optionMap[$fullQID][(string)$optionKey])) { $opt = $optionMap[$fullQID][(string)$optionKey]; $answerOptionID = $opt['answerOptionID']; } $hasValue = $answerOptionID !== null || ($freeTextValue !== null && $freeTextValue !== '') || $numericValue !== null; if (!$hasValue) { $answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]); $submittedQuestionIds[$fullQID] = true; continue; } $answerInsert->execute([ ':cc' => $clientCode, ':qid' => $fullQID, ':aoid' => $answerOptionID, ':ftv' => $freeTextValue, ':nv' => $numericValue, ':at' => $answeredAt, ]); $submittedQuestionIds[$fullQID] = true; } foreach ($glassByParent as $parentQID => $symptomLabels) { $json = qdb_build_glass_symptom_json($symptomLabels); if ($json === null) { $answerDelete->execute([':cc' => $clientCode, ':qid' => $parentQID]); } else { $answerInsert->execute([ ':cc' => $clientCode, ':qid' => $parentQID, ':aoid' => null, ':ftv' => $json, ':nv' => null, ':at' => $completedAt ?? $startedAt, ]); } $submittedQuestionIds[$parentQID] = true; } // Re-edit uploads omit answers pruned on the device (e.g. branching changes). // Drop live rows for questions not present in this payload. $staleQuestionIds = array_diff(array_values($shortIdMap), array_keys($submittedQuestionIds)); if ($staleQuestionIds !== []) { $placeholders = implode(',', array_fill(0, count($staleQuestionIds), '?')); $staleDelete = $pdo->prepare( "DELETE FROM client_answer WHERE clientCode = ? AND questionID IN ($placeholders)" ); $staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds))); } // Upsert completed_questionnaire record require_once __DIR__ . '/../lib/scoring.php'; $sumPoints = qdb_compute_questionnaire_score($pdo, $clientCode, $qnID); $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, ]); require_once __DIR__ . '/../lib/submissions.php'; qdb_record_submission_after_submit( $pdo, $clientCode, $qnID, $tokenRec, $startedAt, $completedAt, $sumPoints, $assignedByCoach ); qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); $pdo->commit(); qdb_save($tmpDb, $lockFp); json_success(['submitted' => true, 'sumPoints' => $sumPoints]); } catch (Throwable $e) { qdb_handler_fail($e, 'Submit questionnaire', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } } if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } $opened = qdb_open_read_or_fail(); [$pdo, $tmpDb, $lockFp] = $opened; $qnID = $_GET['id'] ?? ''; $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; $fetchAnswers = $_GET['answers'] ?? ''; $fetchAnswersBulk = $_GET['answersBulk'] ?? ''; $fetchScoringProfiles = $_GET['scoringProfiles'] ?? ''; $fetchScoringReview = $_GET['scoringReview'] ?? ''; $clientCode = trim($_GET['clientCode'] ?? ''); if ($fetchScoringProfiles) { require_role(['coach'], $tokenRec); require_once __DIR__ . '/../lib/scoring.php'; $profiles = array_values(array_filter( qdb_list_scoring_profiles($pdo), static fn(array $p): bool => (int)($p['isActive'] ?? 0) === 1 )); qdb_discard($tmpDb, $lockFp); json_success_sensitive(['profiles' => $profiles], $tokenRec['_token']); } if ($fetchScoringReview) { require_role(['coach'], $tokenRec); require_once __DIR__ . '/../lib/scoring.php'; $coachID = $tokenRec['entityID'] ?? ''; if ($clientCode !== '') { [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $clStmt = $pdo->prepare( "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" ); $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); if (!$clStmt->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Client not found or not authorized', 404); } $clientCodes = [$clientCode]; } else { $stmt = $pdo->prepare( 'SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode' ); $stmt->execute([':cid' => $coachID]); $clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN); } $clients = qdb_app_scoring_review_for_clients($pdo, $clientCodes); qdb_discard($tmpDb, $lockFp); json_success_sensitive(['clients' => $clients], $tokenRec['_token']); } if ($fetchAnswersBulk) { require_role(['coach'], $tokenRec); require_once __DIR__ . '/../lib/app_answers.php'; $clients = qdb_export_app_bulk_answers_for_coach($pdo, $tokenRec); qdb_discard($tmpDb, $lockFp); json_success_sensitive(['clients' => $clients], $tokenRec['_token']); } if ($fetchAnswers) { require_role(['admin', 'supervisor', 'coach'], $tokenRec); if ($clientCode === '') { qdb_discard($tmpDb, $lockFp); json_error('MISSING_PARAM', 'clientCode query param required', 400); } [$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl'); $clStmt = $pdo->prepare( "SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)" ); $clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams)); if (!$clStmt->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Client not found or not authorized', 404); } require_once __DIR__ . '/../lib/app_answers.php'; $questionnaires = qdb_export_app_client_answers_bundle($pdo, $clientCode); qdb_discard($tmpDb, $lockFp); json_success_sensitive([ 'clientCode' => $clientCode, 'questionnaires' => $questionnaires, ], $tokenRec['_token']); } if ($fetchClients) { require_role(['coach'], $tokenRec); $coachID = $tokenRec['entityID'] ?? ''; $stmt = $pdo->prepare(" SELECT clientCode FROM client WHERE coachID = :cid ORDER BY clientCode "); $stmt->execute([':cid' => $coachID]); $clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN); $completions = []; if (!empty($clientCodes)) { $placeholders = implode(',', array_fill(0, count($clientCodes), '?')); $stmt2 = $pdo->prepare(" SELECT clientCode, questionnaireID, sumPoints, completedAt FROM completed_questionnaire WHERE clientCode IN ($placeholders) "); $stmt2->execute($clientCodes); foreach ($stmt2->fetchAll(PDO::FETCH_ASSOC) as $row) { $completions[$row['clientCode']][] = [ 'questionnaireID' => $row['questionnaireID'], 'sumPoints' => (int) $row['sumPoints'], 'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null, ]; } } $clients = array_map(function ($code) use ($completions) { return [ 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], ]; }, $clientCodes); qdb_discard($tmpDb, $lockFp); json_success_sensitive(['coachID' => $coachID, 'clients' => $clients], $tokenRec['_token']); } if ($qnID) { $qn = $pdo->prepare("SELECT * FROM questionnaire WHERE questionnaireID = :id"); $qn->execute([':id' => $qnID]); $qnRow = $qn->fetch(PDO::FETCH_ASSOC); if (!$qnRow) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Questionnaire not found', 404); } $stmt = $pdo->prepare(" SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id ORDER BY orderIndex "); $stmt->execute([':id' => $qnID]); $dbQuestions = $stmt->fetchAll(PDO::FETCH_ASSOC); $questions = []; foreach ($dbQuestions as $dbQ) { $localId = $dbQ['questionID']; $parts = explode('__', $localId); $shortId = end($parts); $layout = $dbQ['type']; $config = qdb_parse_config_json($dbQ['configJson']); $qKey = qdb_question_key($config, $dbQ['defaultText']); $q = [ 'id' => $shortId, 'layout' => $layout, 'question' => $qKey !== '' ? $qKey : $dbQ['defaultText'], ]; if ($qKey !== '' && !empty($config['noteBefore'])) { $q['noteBeforeKey'] = qdb_note_before_key($qKey); } if ($qKey !== '' && !empty($config['noteAfter'])) { $q['noteAfterKey'] = qdb_note_after_key($qKey); } if (isset($config['textKey'])) $q['textKey'] = $config['textKey']; if (isset($config['textKey1'])) $q['textKey1'] = $config['textKey1']; if (isset($config['textKey2'])) $q['textKey2'] = $config['textKey2']; if (isset($config['hint'])) $q['hint'] = $config['hint']; if (isset($config['hint1'])) $q['hint1'] = $config['hint1']; if (isset($config['hint2'])) $q['hint2'] = $config['hint2']; if (isset($config['symptoms'])) $q['symptoms'] = $config['symptoms']; if ($layout === 'glass_scale_question' && !empty($config['symptoms'])) { $q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $config); } if (isset($config['scaleType'])) $q['scaleType'] = $config['scaleType']; if (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['step'])) $q['step'] = (int)$config['step']; if (isset($config['unitLabel'])) $q['unitLabel'] = $config['unitLabel']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['precision'])) $q['precision'] = $config['precision']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; if (isset($config['nextQuestionId'])) $q['nextQuestionId'] = $config['nextQuestionId']; if (isset($config['otherNextQuestionId'])) $q['otherNextQuestionId'] = $config['otherNextQuestionId']; if (isset($config['otherOptionKey'])) $q['otherOptionKey'] = $config['otherOptionKey']; if ($layout === 'string_spinner' && isset($config['options'])) { $q['options'] = $config['options']; } if (($layout === 'value_spinner' || $layout === 'slider_question') && isset($config['valueOptions'])) { $q['options'] = $config['valueOptions']; } if (in_array($layout, ['glass_scale_question', 'slider_question', 'value_spinner'], true)) { require_once __DIR__ . '/../lib/scoring.php'; $ruleMap = qdb_score_rule_map_for_question($pdo, $dbQ['questionID']); if ($ruleMap !== []) { $q['scoreRuleMap'] = $ruleMap; } } $ao = $pdo->prepare(" SELECT defaultText, points, nextQuestionId FROM answer_option WHERE questionID = :qid ORDER BY orderIndex "); $ao->execute([':qid' => $dbQ['questionID']]); $opts = $ao->fetchAll(PDO::FETCH_ASSOC); if ($opts) { $optionsArr = []; $pointsMap = []; foreach ($opts as $opt) { $o = ['key' => $opt['defaultText']]; if ($opt['nextQuestionId'] !== '') { $o['nextQuestionId'] = $opt['nextQuestionId']; } $optionsArr[] = $o; $pointsMap[$opt['defaultText']] = (int)$opt['points']; } // DB answer_option rows are for choice layouts only — do not overwrite string/value spinners. if (in_array($layout, ['radio_question', 'multi_check_box_question', 'glass_scale_question'], true)) { $q['options'] = $optionsArr; $q['pointsMap'] = $pointsMap; } } $questions[] = $q; } qdb_discard($tmpDb, $lockFp); json_success([ 'meta' => ['id' => $qnID], 'questions' => $questions, 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), ]); } // Default: ordered questionnaire list $rows = $pdo->query(" SELECT questionnaireID, name, showPoints, conditionJson, categoryKey FROM questionnaire WHERE state = 'active' ORDER BY orderIndex ")->fetchAll(PDO::FETCH_ASSOC); $list = []; foreach ($rows as $r) { $item = [ 'id' => $r['questionnaireID'], 'name' => $r['name'], 'showPoints' => (bool)(int)$r['showPoints'], 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), ]; $cat = trim($r['categoryKey'] ?? ''); if ($cat !== '') { $item['categoryKey'] = $cat; } $list[] = $item; } qdb_discard($tmpDb, $lockFp); json_success($list);