diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index eabcb1b..fb05756 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -232,6 +232,7 @@ Question fields: - `options[].nextQuestionId`: optional conditional navigation target. - `pointsMap`: optional map from answer option key to point value. - `textKey`, `textKey1`, `textKey2`, `hint`, `hint1`, `hint2`, `symptoms`, `range`, `constraints`, `precision`, `dateRange`, `minSelection`: optional layout-specific config fields. +- `noteBeforeKey`, `noteAfterKey`: optional translation keys for text shown above / below the question title (editor “note before/after”). Present only when notes are configured; resolve via the questionnaire `translations` map. - `dateRange` (date_spinner): `"past"` (default) or `"future"` — which years/dates the app offers. Important ID rule: the fetch response returns short question IDs such as `q1`. The server resolves these back to full database IDs during upload, so the Android app should upload the same short IDs it received. diff --git a/handlers/questions.php b/handlers/questions.php index 28073ea..5ada392 100644 --- a/handlers/questions.php +++ b/handlers/questions.php @@ -197,10 +197,17 @@ case 'PUT': $oldKey = qdb_question_key($oldCfg, $row['defaultText']); $oldSymptoms = json_encode($oldCfg['symptoms'] ?? [], JSON_UNESCAPED_UNICODE); $newSymptoms = json_encode($config['symptoms'] ?? [], JSON_UNESCAPED_UNICODE); + // Notes live in configJson and are exported as noteBeforeKey/noteAfterKey + translations. + // App sync skips detail re-download when structureRevision is unchanged, so note-only + // (and other app-visible config) edits must bump the revision. + $oldNotes = trim((string)($oldCfg['noteBefore'] ?? '')) . "\0" . trim((string)($oldCfg['noteAfter'] ?? '')); + $newNotes = trim((string)($config['noteBefore'] ?? '')) . "\0" . trim((string)($config['noteAfter'] ?? '')); $structuralChange = ($type !== (string)$row['type']) || ($req !== (int)$row['isRequired']) || ($questionKey !== $oldKey) - || ($newSymptoms !== $oldSymptoms); + || ($newSymptoms !== $oldSymptoms) + || ($oldNotes !== $newNotes) + || (json_encode($config, JSON_UNESCAPED_UNICODE) !== json_encode($oldCfg, JSON_UNESCAPED_UNICODE)); if ((int)($row['retiredAt'] ?? 0) > 0) { qdb_discard($tmpDb, $lockFp); json_error('INVALID_FIELD', 'Cannot edit a retired question', 400); diff --git a/tests/Integration/QuestionnaireEditingTest.php b/tests/Integration/QuestionnaireEditingTest.php index 238df1e..fb6c3bf 100644 --- a/tests/Integration/QuestionnaireEditingTest.php +++ b/tests/Integration/QuestionnaireEditingTest.php @@ -54,4 +54,58 @@ final class QuestionnaireEditingTest extends QdbTestCase $res = $this->api()->withToken($token, 'GET', 'answer_options'); $this->assertApiError($res, 'MISSING_PARAM', 400); } + + public function testSavingNoteBeforeBumpsStructureRevisionAndExportsKey(): void + { + $adminToken = $this->api()->loginWebAndGetToken( + DatabaseSeeder::ADMIN_USERNAME, + DatabaseSeeder::PASSWORD + )['token']; + $coachLogin = $this->api()->loginMobileAndGetToken( + DatabaseSeeder::COACH_USERNAME, + DatabaseSeeder::PASSWORD + ); + $f = $this->fixture(); + + $before = $this->api()->withMobileToken($coachLogin['token'], 'GET', 'app_questionnaires', null, [ + 'id' => $f->questionnaireId, + ]); + $this->assertApiOk($before); + $revBefore = (int)($before['data']['structureRevision'] ?? 1); + + $put = $this->api()->withToken($adminToken, 'PUT', 'questions', [ + 'questionID' => $f->questionId, + 'questionKey' => 'consent_q', + 'defaultText' => 'Consent?', + 'type' => 'single_choice_question', + 'isRequired' => 1, + 'configJson' => json_encode(['questionKey' => 'consent_q'], JSON_UNESCAPED_UNICODE), + 'noteBefore' => 'Bitte dem Klienten deutlich machen: Ohne Einwilligung keine Teilnahme.', + 'noteAfter' => '', + ]); + $this->assertApiOk($put); + $revAfter = (int)($put['data']['structureRevision'] ?? 0); + $this->assertGreaterThan($revBefore, $revAfter, 'note-only edit must bump structureRevision for app sync'); + + $detail = $this->api()->withMobileToken($coachLogin['token'], 'GET', 'app_questionnaires', null, [ + 'id' => $f->questionnaireId, + ]); + $this->assertApiOk($detail); + $questions = $detail['data']['questions'] ?? []; + $this->assertIsArray($questions); + $matched = null; + foreach ($questions as $q) { + if (($q['question'] ?? '') === 'consent_q' || ($q['id'] ?? '') === 'q1') { + $matched = $q; + break; + } + } + $this->assertNotNull($matched, 'expected consent question in app export'); + $this->assertSame('consent_q_note_before', $matched['noteBeforeKey'] ?? null); + $de = $detail['data']['translations']['de'] ?? []; + $this->assertSame( + 'Bitte dem Klienten deutlich machen: Ohne Einwilligung keine Teilnahme.', + $de['consent_q_note_before'] ?? null + ); + } }