adding client notes
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-17 17:10:04 +02:00
parent feae470fba
commit 6b3cd0da17
8 changed files with 178 additions and 7 deletions

View File

@ -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)",

View File

@ -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

View File

@ -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();

View File

@ -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);

View File

@ -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 === '') {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -218,7 +218,7 @@ function followupRowHTML(c) {
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
<td class="followup-note-cell">
<textarea class="followup-note-input" rows="2" data-client="${code}"
placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
maxlength="200" placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
<div class="followup-note-actions">
<button type="button" class="btn btn-sm btn-primary save-note-btn" data-client="${code}">Save note</button>
</div>
@ -282,10 +282,17 @@ async function archiveFromFollowup(clientCode) {
async function saveNote(clientCode, ta) {
if (!ta) return;
const note = ta.value.trim();
if (note.length > 200) {
showToast('Note must be at most 200 characters', 'error');
return;
}
try {
await apiPut('analytics.php', { clientCode, note: ta.value.trim() });
const res = await apiPut('analytics.php', { clientCode, note });
const saved = res?.note ?? note;
ta.value = saved;
const row = staleClients.find(c => c.clientCode === clientCode);
if (row) row.note = ta.value.trim();
if (row) row.note = saved;
showToast('Note saved', 'success');
} catch (e) {
showToast(e.message, 'error');