diff --git a/data/app_ui_strings.json b/data/app_ui_strings.json index f32ec71..57ec269 100644 --- a/data/app_ui_strings.json +++ b/data/app_ui_strings.json @@ -19,6 +19,17 @@ "client", "client_code", "client_code_exists", + "client_note", + "client_note_add", + "client_note_add_action", + "client_note_edit", + "client_note_for_client", + "client_note_hint", + "client_note_pending", + "client_note_saved", + "client_note_saved_pending", + "client_note_title", + "client_note_too_long", "coach_code", "confirm_password_hint", "date_after", @@ -62,6 +73,7 @@ "logout_offline_message", "logout_offline_title", "logout_pending_detail_in_progress", + "logout_pending_detail_notes", "logout_pending_detail_questionnaires", "logout_pending_detail_scoring", "logout_pending_message", @@ -148,6 +160,7 @@ "upload_prepare_failed", "upload_scoring_review_summary", "upload_summary", + "upload_success_client_notes", "upload_success_message", "upload_success_questionnaires", "upload_success_scoring_reviews", @@ -174,6 +187,17 @@ "client": "Klient", "client_code": "Klientencode", "client_code_exists": "Dieser Client-Code existiert bereits.", + "client_note": "Klient-Notiz", + "client_note_add": "Noch keine Notiz für diesen Klienten — tippen zum Hinzufügen", + "client_note_add_action": "Hinzufügen", + "client_note_edit": "Bearbeiten", + "client_note_for_client": "Notiz für {code}", + "client_note_hint": "Nur für diesen Klienten · bis zu 200 Zeichen", + "client_note_pending": "Upload ausstehend", + "client_note_saved": "Notiz für diesen Klienten gespeichert", + "client_note_saved_pending": "Notiz für diesen Klienten gespeichert — wird beim Upload gesendet", + "client_note_title": "Notiz für diesen Klienten", + "client_note_too_long": "Notiz darf höchstens 200 Zeichen haben", "coach_code": "Berater-Code", "confirm_password_hint": "Passwort bestätigen", "date_after": "Das Datum muss nach", @@ -217,6 +241,7 @@ "logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.", "logout_offline_title": "Abmelden nicht möglich", "logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten", + "logout_pending_detail_notes": "{count} Notiz(en) warten auf Upload", "logout_pending_detail_questionnaires": "{count} abgeschlossene(r) Fragebogen warten auf Upload", "logout_pending_detail_scoring": "{count} Punkteprüfung(en) warten auf Upload", "logout_pending_message": "Auf diesem Gerät liegen noch nicht hochgeladene Daten vor. Bitte tippen Sie auf „Hochladen“ und warten Sie, bis der Upload abgeschlossen ist. Schließen Sie begonnene Fragebögen zuerst ab.", @@ -302,7 +327,8 @@ "upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.", "upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.", "upload_scoring_review_summary": "Berater: {band} · Summe {total}", - "upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {clients} Klienten", + "upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {notes} Notizen · {clients} Klienten", + "upload_success_client_notes": "{count} Notiz(en)", "upload_success_message": "Hochladen: {count} erledigt.", "upload_success_questionnaires": "{count} Fragebogen/Fragebögen", "upload_success_scoring_reviews": "{count} Punkteprüfung(en)", diff --git a/docs/android-questionnaire-api.md b/docs/android-questionnaire-api.md index b19d7ec..fabd971 100644 --- a/docs/android-questionnaire-api.md +++ b/docs/android-questionnaire-api.md @@ -309,6 +309,7 @@ Success: "clients": [ { "clientCode": "CLIENT-001", + "note": "Call back next week", "completedQuestionnaires": [ { "questionnaireID": "questionnaire_1_demographic_information", @@ -319,6 +320,7 @@ Success: }, { "clientCode": "CLIENT-002", + "note": "", "completedQuestionnaires": [] } ] @@ -330,6 +332,7 @@ Fields: - `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`. - `clients[].clientCode`: the client's code string. +- `clients[].note`: counselor follow-up note for this client (max 200 characters; empty string when none). - `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none. - `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint. - `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire. @@ -444,6 +447,24 @@ Allowed roles: `admin`, `supervisor`, `coach`. The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client. +### Set client note + +Encrypted body action (coach only): + +```json +{ + "action": "setClientNote", + "clientCode": "CLIENT-001", + "note": "Call back next week" +} +``` + +- `note` is optional; omit or send `""` to clear. Max **200** characters after sanitization (same free-text rules as interview answers). +- Response `data` is an encrypted envelope containing `{ "clientCode", "note" }`. +- Notes are stored in `client_followup_note` (shared with the web Insights follow-up list). + +### Submit questionnaire answers + Request: ```json diff --git a/handlers/analytics.php b/handlers/analytics.php index 5c6e205..8a0f27c 100644 --- a/handlers/analytics.php +++ b/handlers/analytics.php @@ -33,7 +33,7 @@ case 'GET': case 'PUT': $body = read_json_body(); $clientCode = trim((string)($body['clientCode'] ?? '')); - $note = (string)($body['note'] ?? ''); + $note = qdb_normalize_client_followup_note($body['note'] ?? ''); try { $opened = qdb_open_write_or_fail(); diff --git a/handlers/app_questionnaires.php b/handlers/app_questionnaires.php index d9a3a6e..13a076c 100644 --- a/handlers/app_questionnaires.php +++ b/handlers/app_questionnaires.php @@ -10,6 +10,7 @@ * 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 action=setClientNote -> upsert counselor note for an assigned client (max 200 chars, 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). @@ -85,6 +86,27 @@ if ($method === 'POST') { } } + if (($body['action'] ?? '') === 'setClientNote') { + require_role(['coach'], $tokenRec); + require_fields($body, ['clientCode']); + $clientCode = trim((string)$body['clientCode']); + require_once __DIR__ . '/../lib/analytics.php'; + $note = qdb_normalize_client_followup_note($body['note'] ?? ''); + + try { + $opened = qdb_open_write_or_fail(); + [$pdo, $tmpDb, $lockFp] = $opened; + qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note); + qdb_save($tmpDb, $lockFp); + json_success_sensitive( + ['clientCode' => $clientCode, 'note' => $note], + $tokenRec['_token'] + ); + } catch (Throwable $e) { + qdb_handler_fail($e, 'Save client note', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); + } + } + require_fields($body, ['questionnaireID', 'clientCode', 'answers']); $qnID = trim($body['questionnaireID']); @@ -462,6 +484,7 @@ if ($fetchClients) { $clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN); $completions = []; + $notesByClient = []; if (!empty($clientCodes)) { $placeholders = implode(',', array_fill(0, count($clientCodes), '?')); $stmt2 = $pdo->prepare(" @@ -477,12 +500,23 @@ if ($fetchClients) { 'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null, ]; } + + if (qdb_table_exists($pdo, 'client_followup_note')) { + $stmtNotes = $pdo->prepare( + "SELECT clientCode, note FROM client_followup_note WHERE clientCode IN ($placeholders)" + ); + $stmtNotes->execute($clientCodes); + foreach ($stmtNotes->fetchAll(PDO::FETCH_ASSOC) as $row) { + $notesByClient[$row['clientCode']] = (string)($row['note'] ?? ''); + } + } } - $clients = array_map(function ($code) use ($completions) { + $clients = array_map(function ($code) use ($completions, $notesByClient) { return [ - 'clientCode' => $code, + 'clientCode' => $code, 'completedQuestionnaires' => $completions[$code] ?? [], + 'note' => $notesByClient[$code] ?? '', ]; }, $clientCodes); diff --git a/lib/analytics.php b/lib/analytics.php index 7faa5fd..6f6e8e3 100644 --- a/lib/analytics.php +++ b/lib/analytics.php @@ -240,6 +240,37 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array { return $out; } +/** Max length for counselor/admin client follow-up notes (characters). */ +const QDB_CLIENT_NOTE_MAX_LENGTH = 200; + +/** + * Sanitize a client follow-up note. Empty string clears the note. + * Rejects input that sanitizes to empty while the raw value was non-empty. + */ +function qdb_normalize_client_followup_note(mixed $note): string { + require_once __DIR__ . '/text_sanitize.php'; + $raw = is_string($note) || is_numeric($note) ? trim((string)$note) : ''; + if ($raw === '') { + return ''; + } + if (mb_strlen($raw) > QDB_CLIENT_NOTE_MAX_LENGTH) { + json_error( + 'INVALID_FIELD', + 'note must be at most ' . QDB_CLIENT_NOTE_MAX_LENGTH . ' characters', + 400 + ); + } + $clean = sanitize_free_text($raw, QDB_CLIENT_NOTE_MAX_LENGTH); + if ($clean === '') { + json_error('INVALID_FIELD', 'note contains disallowed content', 400); + } + return $clean; +} + +/** + * Upsert a follow-up note for a client visible under the caller's RBAC. + * Pass an already-normalized note (see qdb_normalize_client_followup_note). + */ function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void { $clientCode = trim($clientCode); if ($clientCode === '') { diff --git a/tests/Integration/AppQuestionnairesTest.php b/tests/Integration/AppQuestionnairesTest.php index 664c9d3..b8f6b9e 100644 --- a/tests/Integration/AppQuestionnairesTest.php +++ b/tests/Integration/AppQuestionnairesTest.php @@ -58,4 +58,42 @@ final class AppQuestionnairesTest extends QdbTestCase $codes = array_column($payload['clients'], 'clientCode'); $this->assertContains($this->fixture()->clientCode, $codes); } + + public function testCoachClientNoteRoundTrip(): void + { + $this->submitFixtureQuestionnaire(); + $login = $this->api()->loginMobileAndGetToken( + DatabaseSeeder::COACH_USERNAME, + DatabaseSeeder::PASSWORD + ); + $code = $this->fixture()->clientCode; + $note = 'Bitte Rückruf nächste Woche'; + + $save = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [ + 'action' => 'setClientNote', + 'clientCode' => $code, + 'note' => $note, + ]); + $this->assertApiOk($save); + $this->assertTrue($save['data']['encrypted'] ?? false); + $savedPlain = qdb_decrypt_sensitive_envelope($save['data'], $login['token']); + $savedPayload = json_decode($savedPlain, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame($note, $savedPayload['note']); + + $list = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [ + 'clients' => '1', + ]); + $this->assertApiOk($list); + $listPlain = qdb_decrypt_sensitive_envelope($list['data'], $login['token']); + $listPayload = json_decode($listPlain, true, 512, JSON_THROW_ON_ERROR); + $match = null; + foreach ($listPayload['clients'] as $row) { + if (($row['clientCode'] ?? '') === $code) { + $match = $row; + break; + } + } + $this->assertIsArray($match); + $this->assertSame($note, $match['note'] ?? null); + } } diff --git a/tests/Integration/ResultsAnalyticsTest.php b/tests/Integration/ResultsAnalyticsTest.php index e14484e..dec2e6d 100644 --- a/tests/Integration/ResultsAnalyticsTest.php +++ b/tests/Integration/ResultsAnalyticsTest.php @@ -83,4 +83,18 @@ final class ResultsAnalyticsTest extends QdbTestCase $this->assertIsArray($match); $this->assertSame($note, $match['note']); } + + public function testFollowUpNoteRejectsTooLong(): void + { + $this->submitFixtureQuestionnaire(); + $token = $this->adminToken(); + $code = $this->fixture()->clientCode; + $note = str_repeat('a', 201); + $save = $this->api()->withToken($token, 'PUT', 'analytics', [ + 'clientCode' => $code, + 'note' => $note, + ]); + $this->assertFalse($save['ok'] ?? true); + $this->assertSame('INVALID_FIELD', $save['error']['code'] ?? null); + } } diff --git a/website/js/pages/insights.js b/website/js/pages/insights.js index 790699c..f0fba4e 100644 --- a/website/js/pages/insights.js +++ b/website/js/pages/insights.js @@ -218,7 +218,7 @@ function followupRowHTML(c) {