implemented scoring system
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-08 19:10:23 +02:00
parent 4589be119c
commit 63adef79df
19 changed files with 2873 additions and 36 deletions

View File

@ -7,6 +7,9 @@
* 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).
@ -27,6 +30,58 @@ 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']);
@ -126,7 +181,6 @@ if ($method === 'POST') {
$pdo->beginTransaction();
$sumPoints = 0;
$glassByParent = [];
$submittedQuestionIds = [];
$answerInsert = $pdo->prepare("
@ -183,19 +237,11 @@ if ($method === 'POST') {
}
}
// Resolve option key to answerOptionID and accumulate points
// 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'];
$sumPoints += $opt['points'];
} elseif (($shortIdToType[$shortId] ?? '') === 'multi_check_box_question'
&& $freeTextValue !== null && $freeTextValue !== '') {
foreach (qdb_decode_multi_check_values($freeTextValue) as $mk) {
if (isset($optionMap[$fullQID][$mk])) {
$sumPoints += $optionMap[$fullQID][$mk]['points'];
}
}
}
$hasValue = $answerOptionID !== null
@ -247,6 +293,9 @@ if ($method === 'POST') {
}
// 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)
@ -277,6 +326,8 @@ if ($method === 'POST') {
$assignedByCoach
);
qdb_recompute_profile_scores_for_client($pdo, $clientCode, $qnID);
$pdo->commit();
qdb_save($tmpDb, $lockFp);
@ -298,8 +349,50 @@ $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';
@ -441,6 +534,14 @@ if ($qnID) {
$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

View File

@ -36,8 +36,21 @@ case 'GET':
$stmt->execute();
$clients = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once __DIR__ . '/../lib/submissions.php';
$scoringSummary = qdb_clients_scoring_summary_for_list($pdo, $tokenRec);
foreach ($clients as &$client) {
$client['scoringProfiles'] = qdb_client_scoring_dots_for_client(
$client['clientCode'],
$scoringSummary
);
}
unset($client);
qdb_discard($tmpDb, $lockFp);
json_success(['clients' => $clients]);
json_success([
'clients' => $clients,
'scoringProfiles' => $scoringSummary['profiles'],
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'clients', null, $tmpDb ?? null, $lockFp ?? null);
}

View File

@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/../lib/scoring.php';
$tokenRec = require_valid_token_web();
switch ($method) {
@ -39,7 +41,9 @@ case 'GET':
}
unset($opt);
if (($q['type'] ?? '') === 'glass_scale_question') {
$q['glassSymptoms'] = qdb_glass_symptoms_with_labels($pdo, $cfg);
$q['glassSymptoms'] = qdb_glass_symptoms_with_score_rules($pdo, $q['questionID'], $cfg);
} elseif (in_array($q['type'] ?? '', ['slider_question', 'value_spinner'], true)) {
$q['scoreRules'] = qdb_get_score_rules_for_question($pdo, $q['questionID']);
}
$tr = $pdo->prepare("SELECT languageCode, text FROM question_translation WHERE questionID = :qid");
$tr->execute([':qid' => $q['questionID']]);
@ -111,6 +115,10 @@ case 'POST':
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
qdb_save($tmpDb, $lockFp);
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $qnID, 'defaultText' => $text,
@ -118,7 +126,9 @@ case 'POST':
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson, 'answerOptions' => [], 'translations' => [],
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
@ -172,6 +182,10 @@ case 'PUT':
if ($type === 'glass_scale_question') {
qdb_sync_glass_symptom_strings($pdo, $glassSymptoms);
}
$scoreRules = qdb_parse_score_rules_body($body, $type, $config);
if ($scoreRules !== [] || in_array($type, ['slider_question', 'value_spinner', 'glass_scale_question'], true)) {
qdb_sync_question_score_rules($pdo, $id, $scoreRules);
}
qdb_save($tmpDb, $lockFp);
json_success(['question' => [
'questionID' => $id, 'questionnaireID' => $row['questionnaireID'],
@ -179,7 +193,9 @@ case 'PUT':
'type' => $type, 'orderIndex' => $order, 'isRequired' => $req,
'configJson' => $configJson,
'glassSymptoms' => $type === 'glass_scale_question'
? qdb_glass_symptoms_with_labels($pdo, $config) : [],
? qdb_glass_symptoms_with_score_rules($pdo, $id, $config) : [],
'scoreRules' => in_array($type, ['slider_question', 'value_spinner'], true)
? qdb_get_score_rules_for_question($pdo, $id) : [],
]]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Questions', null, $tmpDb ?? null, $lockFp ?? null);
@ -203,6 +219,7 @@ case 'DELETE':
$pdo->prepare("DELETE FROM answer_option_translation WHERE answerOptionID = :id")->execute([':id' => $aoid]);
}
$pdo->prepare("DELETE FROM answer_option WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_score_rule WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question_translation WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM client_answer WHERE questionID = :id")->execute([':id' => $id]);
$pdo->prepare("DELETE FROM question WHERE questionID = :id")->execute([':id' => $id]);

View File

@ -0,0 +1,203 @@
<?php
/**
* Scoring profiles CRUD (weighted multi-questionnaire Green/Yellow/Red bands).
*/
require_once __DIR__ . '/../lib/scoring.php';
$tokenRec = require_valid_token_web();
switch ($method) {
case 'GET':
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
$profileID = trim($_GET['id'] ?? '');
if ($profileID !== '') {
$profile = qdb_get_scoring_profile($pdo, $profileID);
qdb_discard($tmpDb, $lockFp);
if (!$profile) {
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
json_success(['profile' => $profile]);
}
json_success(['profiles' => qdb_list_scoring_profiles($pdo)]);
qdb_discard($tmpDb, $lockFp);
} catch (Throwable $e) {
qdb_handler_fail($e, 'List scoring profiles', null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'POST':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$name = trim($body['name'] ?? '');
if ($name === '') {
json_error('BAD_REQUEST', 'name is required', 400);
}
$bands = qdb_parse_scoring_bands_body($body);
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = qdb_parse_profile_questionnaires($body['questionnaires'] ?? []);
if ($members === []) {
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$profileID = bin2hex(random_bytes(16));
$now = time();
$pdo->prepare(
'INSERT INTO scoring_profile (profileID, name, description, isActive,
greenMin, greenMax, yellowMin, yellowMax, redMin, createdAt, updatedAt)
VALUES (:id, :name, :desc, :act, :gmin, :gmax, :ymin, :ymax, :rmin, :ca, :ua)'
)->execute([
':id' => $profileID,
':name' => $name,
':desc' => trim($body['description'] ?? ''),
':act' => !empty($body['isActive']) ? 1 : 0,
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ca' => $now,
':ua' => $now,
]);
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
qdb_recompute_all_profiles_after_change($pdo);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Create scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'PUT':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$existing = qdb_get_scoring_profile($pdo, $profileID);
if (!$existing) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Scoring profile not found', 404);
}
$name = trim($body['name'] ?? $existing['name']);
$bands = qdb_normalize_scoring_bands(array_merge($existing, $body));
$bandErr = qdb_validate_scoring_bands($bands);
if ($bandErr !== null) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', $bandErr, 400);
}
$members = isset($body['questionnaires'])
? qdb_parse_profile_questionnaires($body['questionnaires'])
: null;
if ($members !== null && $members === []) {
qdb_discard($tmpDb, $lockFp);
json_error('INVALID_FIELD', 'At least one questionnaire is required', 400);
}
$pdo->prepare(
'UPDATE scoring_profile SET name = :name, description = :desc, isActive = :act,
greenMin = :gmin, greenMax = :gmax, yellowMin = :ymin, yellowMax = :ymax, redMin = :rmin,
updatedAt = :ua WHERE profileID = :id'
)->execute([
':name' => $name,
':desc' => trim($body['description'] ?? $existing['description']),
':act' => isset($body['isActive']) ? (!empty($body['isActive']) ? 1 : 0) : $existing['isActive'],
':gmin' => $bands['greenMin'],
':gmax' => $bands['greenMax'],
':ymin' => $bands['yellowMin'],
':ymax' => $bands['yellowMax'],
':rmin' => $bands['redMin'],
':ua' => time(),
':id' => $profileID,
]);
if ($members !== null) {
qdb_sync_profile_questionnaires($pdo, $profileID, $members);
}
qdb_recompute_all_profiles_after_change($pdo, $profileID);
qdb_save($tmpDb, $lockFp);
json_success(['profile' => qdb_get_scoring_profile($pdo, $profileID)]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Update scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
case 'DELETE':
require_role(['admin', 'supervisor'], $tokenRec);
$body = read_json_body();
$profileID = trim($body['profileID'] ?? '');
if ($profileID === '') {
json_error('BAD_REQUEST', 'profileID is required', 400);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
$pdo->prepare('DELETE FROM scoring_profile WHERE profileID = :id')->execute([':id' => $profileID]);
qdb_save($tmpDb, $lockFp);
json_success(['deleted' => true]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Delete scoring profile', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
}
break;
default:
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
/**
* @return list<array{questionnaireID: string, weight: float, orderIndex: int}>
*/
function qdb_parse_profile_questionnaires(array $rows): array {
$out = [];
$idx = 0;
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$qnID = trim((string)($row['questionnaireID'] ?? ''));
if ($qnID === '') {
continue;
}
$weight = (float)($row['weight'] ?? 1.0);
if ($weight <= 0) {
$weight = 1.0;
}
$out[] = [
'questionnaireID' => $qnID,
'weight' => $weight,
'orderIndex' => (int)($row['orderIndex'] ?? $idx),
];
$idx++;
}
return $out;
}
function qdb_sync_profile_questionnaires(PDO $pdo, string $profileID, array $members): void {
$pdo->prepare('DELETE FROM scoring_profile_questionnaire WHERE profileID = :pid')
->execute([':pid' => $profileID]);
$ins = $pdo->prepare(
'INSERT INTO scoring_profile_questionnaire (profileID, questionnaireID, weight, orderIndex)
VALUES (:pid, :qn, :w, :o)'
);
foreach ($members as $m) {
$ins->execute([
':pid' => $profileID,
':qn' => $m['questionnaireID'],
':w' => $m['weight'],
':o' => $m['orderIndex'],
]);
}
}
function qdb_recompute_all_profiles_after_change(PDO $pdo, ?string $profileID = null): void {
$clients = $pdo->query('SELECT DISTINCT clientCode FROM completed_questionnaire WHERE status = \'completed\'')
->fetchAll(PDO::FETCH_COLUMN);
foreach ($clients as $clientCode) {
qdb_recompute_profile_scores_for_client($pdo, (string)$clientCode);
}
}