enhanced submission handling with deccision trees
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-06-05 11:57:28 +02:00
parent 6883654d7c
commit c37940c9a9
4 changed files with 157 additions and 13 deletions

View File

@ -128,6 +128,7 @@ if ($method === 'POST') {
$sumPoints = 0; $sumPoints = 0;
$glassByParent = []; $glassByParent = [];
$submittedQuestionIds = [];
$answerInsert = $pdo->prepare(" $answerInsert = $pdo->prepare("
INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt)
VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at)
@ -137,6 +138,9 @@ if ($method === 'POST') {
numericValue = excluded.numericValue, numericValue = excluded.numericValue,
answeredAt = excluded.answeredAt answeredAt = excluded.answeredAt
"); ");
$answerDelete = $pdo->prepare(
'DELETE FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
require_once __DIR__ . '/../lib/app_answers.php'; require_once __DIR__ . '/../lib/app_answers.php';
$answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap); $answers = qdb_group_app_submit_answers($answers, $shortIdToType, $symptomParentMap);
@ -173,6 +177,8 @@ if ($method === 'POST') {
$maxLen = $freeTextMaxLen[$fullQID] ?? 2000; $maxLen = $freeTextMaxLen[$fullQID] ?? 2000;
$freeTextValue = sanitize_free_text($freeTextValue, $maxLen); $freeTextValue = sanitize_free_text($freeTextValue, $maxLen);
if ($freeTextValue === '') { if ($freeTextValue === '') {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue; continue;
} }
} }
@ -192,6 +198,15 @@ if ($method === 'POST') {
} }
} }
$hasValue = $answerOptionID !== null
|| ($freeTextValue !== null && $freeTextValue !== '')
|| $numericValue !== null;
if (!$hasValue) {
$answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]);
$submittedQuestionIds[$fullQID] = true;
continue;
}
$answerInsert->execute([ $answerInsert->execute([
':cc' => $clientCode, ':cc' => $clientCode,
':qid' => $fullQID, ':qid' => $fullQID,
@ -200,23 +215,35 @@ if ($method === 'POST') {
':nv' => $numericValue, ':nv' => $numericValue,
':at' => $answeredAt, ':at' => $answeredAt,
]); ]);
$submittedQuestionIds[$fullQID] = true;
} }
foreach ($glassByParent as $parentQID => $symptomLabels) { foreach ($glassByParent as $parentQID => $symptomLabels) {
$existing = $pdo->prepare( $json = qdb_build_glass_symptom_json($symptomLabels);
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid' 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)"
); );
$existing->execute([':cc' => $clientCode, ':qid' => $parentQID]); $staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds)));
$prevJson = $existing->fetchColumn();
$merged = qdb_merge_glass_symptom_json($prevJson !== false ? (string)$prevJson : null, $symptomLabels);
$answerInsert->execute([
':cc' => $clientCode,
':qid' => $parentQID,
':aoid' => null,
':ftv' => $merged,
':nv' => null,
':at' => $completedAt ?? $startedAt,
]);
} }
// Upsert completed_questionnaire record // Upsert completed_questionnaire record

View File

@ -48,6 +48,26 @@ function qdb_decode_multi_check_values(?string $raw): array {
return [$trimmed]; return [$trimmed];
} }
/**
* Build glass-scale symptom JSON from the current submit only (no merge with prior rows).
*
* @param array<string, string> $symptomLabels symptom key => selected label
*/
function qdb_build_glass_symptom_json(array $symptomLabels): ?string {
$data = [];
foreach ($symptomLabels as $key => $label) {
$k = trim((string)$key);
$v = trim((string)$label);
if ($k !== '' && $v !== '') {
$data[$k] = $v;
}
}
if ($data === []) {
return null;
}
return json_encode($data, JSON_UNESCAPED_UNICODE);
}
/** /**
* Group raw POST answer rows by question short id; merge multi_check option keys. * Group raw POST answer rows by question short id; merge multi_check option keys.
* *

View File

@ -9,6 +9,94 @@ use Tests\Support\QdbTestCase;
final class AppSubmitExtendedTest extends QdbTestCase final class AppSubmitExtendedTest extends QdbTestCase
{ {
public function testResubmitClearsOmittedOptionalAnswers(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->freeTextShortId, 'freeTextValue' => 'Old notes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$ft = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$ft->execute([':cc' => $f->clientCode, ':qid' => $f->freeTextQuestionId]);
$this->assertFalse($ft->fetchColumn());
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$this->assertFalse($glass->fetchColumn());
$sub = $pdo->prepare(
'SELECT submissionID FROM questionnaire_submission
WHERE clientCode = :cc AND questionnaireID = :qn ORDER BY version DESC LIMIT 1'
);
$sub->execute([':cc' => $f->clientCode, ':qn' => $f->questionnaireId]);
$submissionId = (string)$sub->fetchColumn();
$archived = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archived->execute([':sid' => $submissionId, ':qid' => $f->freeTextQuestionId]);
$this->assertFalse($archived->fetchColumn());
$archivedGlass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer_submission
WHERE submissionID = :sid AND questionID = :qid'
);
$archivedGlass->execute([':sid' => $submissionId, ':qid' => $f->glassQuestionId]);
$this->assertFalse($archivedGlass->fetchColumn());
qdb_discard($tmpDb, $lockFp);
}
public function testResubmitReplacesGlassSymptomsInsteadOfMerging(): void
{
$login = $this->api()->loginMobileAndGetToken(
DatabaseSeeder::COACH_USERNAME,
DatabaseSeeder::PASSWORD
);
$f = $this->fixture();
$this->assertApiOk($this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'moderate'],
['questionID' => 'fatigue', 'freeTextValue' => 'severe'],
]));
$res = $this->submitQuestionnaireAs($login['token'], $f->questionnaireId, $f->clientCode, [
['questionID' => $f->questionShortId, 'answerOptionKey' => 'yes'],
['questionID' => $f->glassSymptomKey, 'freeTextValue' => 'mild'],
]);
$this->assertApiOk($res);
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$glass = $pdo->prepare(
'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid'
);
$glass->execute([':cc' => $f->clientCode, ':qid' => $f->glassQuestionId]);
$decoded = json_decode((string)$glass->fetchColumn(), true);
$this->assertSame(['pain' => 'mild'], $decoded);
qdb_discard($tmpDb, $lockFp);
}
public function testResubmitCreatesSecondSubmissionVersion(): void public function testResubmitCreatesSecondSubmissionVersion(): void
{ {
$login = $this->api()->loginMobileAndGetToken( $login = $this->api()->loginMobileAndGetToken(

View File

@ -30,6 +30,15 @@ final class AppAnswersTest extends TestCase
$this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two')); $this->assertSame(['one', 'two'], qdb_decode_multi_check_values('one, two'));
} }
public function testBuildGlassSymptomJsonOmitsEmptyAndReturnsNullForNoSymptoms(): void
{
require_once dirname(__DIR__, 2) . '/lib/app_answers.php';
$this->assertNull(qdb_build_glass_symptom_json([]));
$this->assertNull(qdb_build_glass_symptom_json(['pain' => '']));
$json = qdb_build_glass_symptom_json(['pain' => 'mild', 'fatigue' => 'severe']);
$this->assertSame(['pain' => 'mild', 'fatigue' => 'severe'], json_decode((string)$json, true));
}
public function testGroupAppSubmitAnswersMergesMultiCheck(): void public function testGroupAppSubmitAnswersMergesMultiCheck(): void
{ {
$answers = [ $answers = [