This commit is contained in:
@ -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)",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 === '') {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
Reference in New Issue
Block a user