ordered questionnaire list with conditions * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> global app UI strings only (not questionnaire content) * GET ?clients=1 -> list of clients assigned to the authenticated coach * POST -> submit interview answers for a client (coach / supervisor / admin) */ $tokenRec = require_valid_token(); if ($method === 'POST') { require_role(['admin', 'supervisor', 'coach'], $tokenRec); $body = read_json_body(); 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 { [$pdo, $tmpDb, $lockFp] = qdb_open(true); // 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 $freeTextMaxLen = []; // fullQuestionID -> maxLength 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)); } } // 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'], ]; } $pdo->beginTransaction(); $sumPoints = 0; $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; // Resolve short ID to full questionID $fullQID = $shortIdMap[$shortId] ?? null; if ($fullQID === null) continue; // skip unknown questions $answerOptionID = null; $freeTextValue = isset($a['freeTextValue']) ? (string)$a['freeTextValue'] : null; $numericValue = isset($a['numericValue']) ? (float)$a['numericValue'] : null; $answeredAt = isset($a['answeredAt']) ? (int)$a['answeredAt'] : null; if ($freeTextValue !== null && $freeTextValue !== '') { $maxLen = $freeTextMaxLen[$fullQID] ?? 2000; $freeTextValue = sanitize_free_text($freeTextValue, $maxLen); if ($freeTextValue === '') { continue; } } // Resolve option key to answerOptionID and accumulate points $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, ]); } // Upsert completed_questionnaire record $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, ]); $pdo->commit(); qdb_save($tmpDb, $lockFp); json_success(['submitted' => true, 'sumPoints' => $sumPoints]); } catch (Throwable $e) { if (isset($pdo) && $pdo->inTransaction()) $pdo->rollBack(); if (isset($tmpDb, $lockFp)) qdb_discard($tmpDb, $lockFp); error_log($e->getMessage()); json_error('SERVER_ERROR', 'Internal server error', 500); } } if ($method !== 'GET') { json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); } [$pdo, $tmpDb, $lockFp] = qdb_open(false); $qnID = $_GET['id'] ?? ''; $translations = $_GET['translations'] ?? ''; $fetchClients = $_GET['clients'] ?? ''; 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(['coachID' => $coachID, 'clients' => $clients]); } if ($translations) { qdb_discard($tmpDb, $lockFp); json_success(['translations' => qdb_build_app_translations_map($pdo)]); } 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 = json_decode($dbQ['configJson'], true) ?: []; $q = [ 'id' => $shortId, 'layout' => $layout, 'question' => $dbQ['defaultText'], ]; 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 (isset($config['range'])) $q['range'] = $config['range']; if (isset($config['constraints'])) $q['constraints'] = $config['constraints']; if (isset($config['minSelection'])) $q['minSelection'] = $config['minSelection']; if (isset($config['maxLength'])) $q['maxLength'] = (int)$config['maxLength']; if (!empty($config['multiline'])) $q['multiline'] = true; 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' && isset($config['valueOptions'])) { $q['options'] = $config['valueOptions']; } $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']; } $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 FROM questionnaire WHERE state = 'active' ORDER BY orderIndex ")->fetchAll(PDO::FETCH_ASSOC); $list = []; foreach ($rows as $r) { $list[] = [ 'id' => $r['questionnaireID'], 'name' => $r['name'], 'showPoints' => (bool)(int)$r['showPoints'], 'condition' => json_decode($r['conditionJson'], true) ?: new stdClass(), ]; } qdb_discard($tmpDb, $lockFp); json_success($list);