diff --git a/.gitignore b/.gitignore index 23948ad..08b9cc8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,56 @@ valid_tokens.txt .env # Encrypted database, temp files, backups -uploads/ +uploads/* +!uploads/.gitkeep -# OS / editor +# Database lock files (just in case) +uploads/.qdb_lock +uploads/*.lock + +# Logs / dumps / exports +*.log +*.sqlite +*.db +*.dump +*.bak +*.backup +*.tmp +*.temp + +# Node/npm artifacts (if website/js uses npm) +node_modules/ +npm-debug.log +yarn.lock +package-lock.json + +# PHP composer artifacts (if using composer in future) +vendor/ +composer.lock + +# IDE / editor .DS_Store Thumbs.db *.swp *.swo *~ +.vscode/ +.idea/ +*.code-workspace + +# Misc +*.orig +*.rej + +# Ignore schema if generated +schema.sql + +# Ignore browser build artifacts (if any) +website/js/dist/ +website/js/build/ + +# Ignore backups at project root +*.tar +*.tar.gz +*.zip +*.7z \ No newline at end of file diff --git a/api/app_questionnaires.php b/api/app_questionnaires.php index 004a1e3..55bfdc1 100644 --- a/api/app_questionnaires.php +++ b/api/app_questionnaires.php @@ -5,6 +5,8 @@ * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> all translations merged across all tables + * + * POST (coach interview upload) is handled by api/index.php -> handlers/app_questionnaires.php */ require_once __DIR__ . '/../common.php'; require_once __DIR__ . '/../db_init.php'; diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index a1332b9..97278d7 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -4,10 +4,154 @@ * GET (no params) -> ordered questionnaire list with conditions * GET ?id= -> single questionnaire in app JSON format * GET ?translations=1 -> all translations merged across all tables + * 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 FROM question WHERE questionnaireID = :qn"); + $qRows->execute([':qn' => $qnID]); + $shortIdMap = []; // shortId -> fullQuestionID + foreach ($qRows->fetchAll(PDO::FETCH_COLUMN) as $fullId) { + $parts = explode('__', $fullId); + $shortIdMap[end($parts)] = $fullId; + } + + // 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; + + // 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); } diff --git a/uploads/questionnaire_database b/uploads/questionnaire_database index d20e906..7e96223 100644 Binary files a/uploads/questionnaire_database and b/uploads/questionnaire_database differ