From c37940c9a97b86d2535ec690a58914097f4786eb Mon Sep 17 00:00:00 2001 From: Tom Hempel Date: Fri, 5 Jun 2026 11:57:28 +0200 Subject: [PATCH] enhanced submission handling with deccision trees --- handlers/app_questionnaires.php | 53 ++++++++++--- lib/app_answers.php | 20 +++++ tests/Integration/AppSubmitExtendedTest.php | 88 +++++++++++++++++++++ tests/Unit/AppAnswersTest.php | 9 +++ 4 files changed, 157 insertions(+), 13 deletions(-) diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index 3196cc3..6c3740a 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -128,6 +128,7 @@ if ($method === 'POST') { $sumPoints = 0; $glassByParent = []; + $submittedQuestionIds = []; $answerInsert = $pdo->prepare(" INSERT INTO client_answer (clientCode, questionID, answerOptionID, freeTextValue, numericValue, answeredAt) VALUES (:cc, :qid, :aoid, :ftv, :nv, :at) @@ -137,6 +138,9 @@ if ($method === 'POST') { 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); @@ -173,6 +177,8 @@ if ($method === 'POST') { $maxLen = $freeTextMaxLen[$fullQID] ?? 2000; $freeTextValue = sanitize_free_text($freeTextValue, $maxLen); if ($freeTextValue === '') { + $answerDelete->execute([':cc' => $clientCode, ':qid' => $fullQID]); + $submittedQuestionIds[$fullQID] = true; 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([ ':cc' => $clientCode, ':qid' => $fullQID, @@ -200,23 +215,35 @@ if ($method === 'POST') { ':nv' => $numericValue, ':at' => $answeredAt, ]); + $submittedQuestionIds[$fullQID] = true; } foreach ($glassByParent as $parentQID => $symptomLabels) { - $existing = $pdo->prepare( - 'SELECT freeTextValue FROM client_answer WHERE clientCode = :cc AND questionID = :qid' + $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; + } + + // 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]); - $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, - ]); + $staleDelete->execute(array_merge([$clientCode], array_values($staleQuestionIds))); } // Upsert completed_questionnaire record diff --git a/lib/app_answers.php b/lib/app_answers.php index b698b66..6737a56 100644 --- a/lib/app_answers.php +++ b/lib/app_answers.php @@ -48,6 +48,26 @@ function qdb_decode_multi_check_values(?string $raw): array { return [$trimmed]; } +/** + * Build glass-scale symptom JSON from the current submit only (no merge with prior rows). + * + * @param array $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. * diff --git a/tests/Integration/AppSubmitExtendedTest.php b/tests/Integration/AppSubmitExtendedTest.php index 11eaa75..7c21459 100644 --- a/tests/Integration/AppSubmitExtendedTest.php +++ b/tests/Integration/AppSubmitExtendedTest.php @@ -9,6 +9,94 @@ use Tests\Support\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 { $login = $this->api()->loginMobileAndGetToken( diff --git a/tests/Unit/AppAnswersTest.php b/tests/Unit/AppAnswersTest.php index 3ddd7be..8609e72 100644 --- a/tests/Unit/AppAnswersTest.php +++ b/tests/Unit/AppAnswersTest.php @@ -30,6 +30,15 @@ final class AppAnswersTest extends TestCase $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 { $answers = [