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), 'availableLanguages' => qdb_available_language_codes($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; require_once __DIR__ . '/../lib/questionnaire_structure.php'; $qnStmt = $pdo->prepare( 'SELECT questionnaireID, COALESCE(structureRevision, 1) AS structureRevision FROM questionnaire WHERE questionnaireID = :id' ); $qnStmt->execute([':id' => $qnID]); $qnRow = $qnStmt->fetch(PDO::FETCH_ASSOC); if (!$qnRow) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Questionnaire not found', 404); } $currentRev = max(1, (int)$qnRow['structureRevision']); $submitRev = max(1, (int)($body['structureRevision'] ?? 1)); if ($submitRev > $currentRev) { qdb_discard($tmpDb, $lockFp); json_error('STRUCTURE_REVISION_NEWER', 'App structure revision is newer than server', 400); } [$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'] ?? ''); $isLegacySubmit = $submitRev < $currentRev; if ($isLegacySubmit) { $submitManifest = qdb_load_structure_manifest($pdo, $qnID, $submitRev); if ($submitManifest === null) { qdb_discard($tmpDb, $lockFp); json_error('STRUCTURE_REVISION_UNKNOWN', 'Unknown structure revision', 400); } } else { $submitManifest = qdb_build_structure_manifest($pdo, $qnID, false); $submitManifest['structureRevision'] = $currentRev; } $maps = qdb_submit_maps_from_manifest($pdo, $qnID, $submitManifest); $shortIdMap = $maps['shortIdMap']; $shortIdToType = $maps['shortIdToType']; $optionMap = $maps['optionMap']; $symptomParentMap = $maps['symptomParentMap']; $freeTextMaxLen = $maps['freeTextMaxLen']; require_once __DIR__ . '/../lib/app_submit_validate.php'; $validationErrors = qdb_validate_app_submit_payload( $pdo, $qnID, $answers, $shortIdMap, $shortIdToType, $symptomParentMap, $optionMap, $submitManifest ); 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) ' . qdb_upsert_update( ['clientCode', 'questionID'], [ '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; } if (!$isLegacySubmit) { $activeMaps = qdb_submit_maps_from_active_questions($pdo, $qnID); $staleQuestionIds = array_diff( array_values($activeMaps['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))); } } require_once __DIR__ . '/../lib/scoring.php'; $sumPoints = $isLegacySubmit ? qdb_compute_questionnaire_score_from_manifest($pdo, $clientCode, $submitManifest) : 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) ' . qdb_upsert_update( ['clientCode', 'questionnaireID'], [ '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, $submitRev, $submitManifest, array_keys($submittedQuestionIds) ); qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID); $pdo->commit(); qdb_save($tmpDb, $lockFp); json_success([ 'submitted' => true, 'sumPoints' => $sumPoints, 'structureRevision' => $submitRev, 'legacySubmit' => $isLegacySubmit, ]); } 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 AND COALESCE(archived, 0) = 0 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 AND COALESCE(archived, 0) = 0 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) { require_once __DIR__ . '/../lib/questionnaire_structure.php'; $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); } $structureRevision = qdb_questionnaire_structure_revision($pdo, $qnID); $stmt = $pdo->prepare( 'SELECT questionID, defaultText, type, orderIndex, configJson FROM question WHERE questionnaireID = :id AND ' . qdb_active_questions_clause('question') . ' 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 AND ' . qdb_active_options_clause('answer_option') . ' 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], 'structureRevision' => $structureRevision, 'questions' => $questions, 'translations' => qdb_build_questionnaire_translations_map($pdo, $qnID), 'availableLanguages' => qdb_available_language_codes($pdo, $qnID), ]); } // Default: ordered questionnaire list require_once __DIR__ . '/../lib/questionnaire_structure.php'; $rows = $pdo->query(" SELECT questionnaireID, name, showPoints, conditionJson, categoryKey, COALESCE(structureRevision, 1) AS structureRevision 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'], 'structureRevision' => max(1, (int)$r['structureRevision']), '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);