diff --git a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx index e9ad35a..3473e0b 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/patients/[id]/record/page.tsx @@ -170,14 +170,19 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car } const data = record.data; + // The real endpoint fully replaces all three lists from the request (no server-side merge), so every save + // must resend the whole current plan — not just the tab being edited — or the other two tabs get wiped. const save = (patch: Partial>, onDone: () => void) => { - update.mutate(patch, { - onSuccess: () => { - enqueueSnackbar(t('saved'), { variant: 'success' }); - onDone(); + update.mutate( + { medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch }, + { + onSuccess: () => { + enqueueSnackbar(t('saved'), { variant: 'success' }); + onDone(); + }, + onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), }, - onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), - }); + ); }; if (tab === 'medications') { diff --git a/client/src/services/patientRecords/apis/clientApi.ts b/client/src/services/patientRecords/apis/clientApi.ts index d7e7c86..7262601 100644 --- a/client/src/services/patientRecords/apis/clientApi.ts +++ b/client/src/services/patientRecords/apis/clientApi.ts @@ -23,6 +23,7 @@ interface CareRecordWire { nurseProfileId: number; nurseName: string | null; body: string; + taskResults: TaskResult[]; recordedAt: string; } @@ -33,42 +34,27 @@ function toVisitNote(w: CareRecordWire): VisitNote { nurseProfileId: w.nurseProfileId, nurseDisplayName: w.nurseName, body: w.body, - // The wire body carries only free text; the structured checklist is composed into it on write (see below). - taskResults: [], + taskResults: w.taskResults, recordedAt: w.recordedAt, }; } /** - * Folds the nurse's ticked task checklist into the free-text note body, because the wire - * `WriteCareRecordBody` has only `{ bookingId?, body }` — there is no structured task field (REQ-027 would - * add one). The mock keeps `taskResults` structured; the real path serialises them as a leading summary line. - */ -export function composeVisitNoteBody(body: string, taskResults: TaskResult[] | undefined): string { - const trimmed = body.trim(); - if (!taskResults || taskResults.length === 0) return trimmed; - const summary = taskResults.map((t) => `${t.done ? '✓' : '✗'} ${t.label}`).join(' · '); - return trimmed ? `${summary}\n\n${trimmed}` : summary; -} - -/** - * Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Two methods map **published** - * b14 routes: + * Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Every method maps a real route: * - `getPatientHistory` → `GET patients/{id}/care_records` (the patient-scoped, newest-first note history; * a `403` from the envelope surfaces as an `ApiError` the E2 screen renders as access-denied). * - `createVisitNote` → `POST patients/{id}/care_records` (a nurse appends one encrypted note). - * - * The family-record + access methods target contract gaps the frontend filed (**REQ-027**) — no wire - * endpoint exists — which is why the domain stays mock-primary (see `constants.ts`). + * - `getRecordAccess` → `GET patients/{id}/record_access`. + * - `getFamilyRecord`/`updateFamilyRecord` → `GET`/`PUT patients/{id}/care_record` — the routes exist, but + * `FamilyCareRecord`'s medication/routine fields are richer than the wire's (see `constants.ts`'s care-plan + * schema note), so these two are **not safe to use as written** until that's resolved. * * NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`). */ export const patientRecordsClientApi: PatientRecordsApi = { - // REQ-027: proposed owner/nurse-scoped read of the family-owned record (no wire endpoint yet). getFamilyRecord: async (patientId: number): Promise => unwrap(await clientFetch>(`${API}/patients/${patientId}/care_record`)), - // REQ-027: proposed access check. On the real path the 403 on the history read is the true access signal. getRecordAccess: async (patientId: number): Promise => unwrap(await clientFetch>(`${API}/patients/${patientId}/record_access`)), @@ -84,7 +70,6 @@ export const patientRecordsClientApi: PatientRecordsApi = { return { ...page, items: page.items.map(toVisitNote) }; }, - // REQ-027: proposed customer edit of the family record. updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise => unwrap( await clientFetch>(`${API}/patients/${patientId}/care_record`, { @@ -99,7 +84,8 @@ export const patientRecordsClientApi: PatientRecordsApi = { method: 'POST', body: JSON.stringify({ bookingId: body.bookingId ?? null, - body: composeVisitNoteBody(body.body, body.taskResults), + body: body.body.trim(), + taskResults: body.taskResults ?? [], }), }), ), diff --git a/client/src/services/patientRecords/apis/mockApi.ts b/client/src/services/patientRecords/apis/mockApi.ts index e6eae7a..57b6a28 100644 --- a/client/src/services/patientRecords/apis/mockApi.ts +++ b/client/src/services/patientRecords/apis/mockApi.ts @@ -15,9 +15,9 @@ import type { } from '../types'; /** - * In-memory `PatientRecordsApi` — **the primary implementation this phase** (the nurse-authored visit-note - * history/append are real b14, but the family-owned medications/routine/tasks record + the access check are - * REQ-027 gaps — see `constants.ts`). + * In-memory `PatientRecordsApi` — **the primary implementation this phase**. Every route this seam needs + * exists on the server; the domain stays mock-primary because the family-owned medications/routine/tasks + * shape doesn't match yet (an unresolved product decision — see `constants.ts`). * * The store is **patient-scoped** and lazily seeds a coherent default the first time any patient is read, so * every E2 record viewer has content and every state is demoable: @@ -149,7 +149,7 @@ function paginate(all: T[], params: PageParams): Paginated { function assertAccess(patientId: number): void { // The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient. if (patientId === MOCK_FOREIGN_PATIENT_ID) { - throw new ApiError(403, 'No clinical access to this patient', 'no_access'); + throw new ApiError(403, 'No clinical access to this patient', 'not_authorized'); } } @@ -163,7 +163,7 @@ export const patientRecordsMockApi: PatientRecordsApi = { getRecordAccess: async (patientId: number): Promise => { await sleep(MOCK_LATENCY_MS); if (patientId === MOCK_FOREIGN_PATIENT_ID) { - return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'no_access' }; + return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'not_authorized' }; } // In the single-session mock, an authorized viewer can do everything; the SCREEN (customer vs nurse // shell) decides which affordances to render — the nurse view never wires the edit path (append-only). diff --git a/client/src/services/patientRecords/constants.ts b/client/src/services/patientRecords/constants.ts index 682029a..b3caad7 100644 --- a/client/src/services/patientRecords/constants.ts +++ b/client/src/services/patientRecords/constants.ts @@ -2,13 +2,14 @@ * When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the * `PatientRecordsApi` seam. * - * **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records` - * GET/POST) — those two methods are real — but the **family-owned editable record** (medications/routine/ - * tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has - * them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient, - * enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the - * history. Flip to `false` once REQ-027 lands — only `clientApi.ts`'s family-record/access methods flip; the - * history/append methods already map the real routes. + * **Mock is primary — the backend is fully built, but the shapes don't match yet.** `PatientCareRecordsController` + * implements every route this seam needs: `care_records` GET/POST (visit-note history/append), `care_record` + * GET/PUT (the family care plan), and `record_access` GET. The blocker is a genuine schema mismatch, not a + * missing endpoint — see `mvp/fix-plan.md`'s "Patient records" follow-up: the client's medication/routine + * shape (structured dose amount/unit, frequency preset codes, a multi-select `timeOfDay`) was built after the + * server shipped a simpler one (one free-text dose, one required frequency string, no `timeOfDay` at all), + * and which side to change is a product decision, not something to guess in code. Flip to `false` once that's + * resolved and `clientApi.ts`'s family-record methods are updated to match. */ export const USE_PATIENT_RECORDS_MOCK = true; diff --git a/client/src/services/patientRecords/types.ts b/client/src/services/patientRecords/types.ts index 7c1b87e..34bccbe 100644 --- a/client/src/services/patientRecords/types.ts +++ b/client/src/services/patientRecords/types.ts @@ -8,12 +8,12 @@ import type { PageParams, Paginated } from '@/lib/api/types'; * GET/POST). Encrypted at rest, returned decrypted only after the clinical-access check passes; it is * **patient-scoped, not booking-scoped**, so a new nurse taking over reads the whole history. A nurse * with a qualifying booking may **append** a note; nobody edits it. - * 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend - * exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it; - * it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`). - * ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes) - * that replaced free-text dose/frequency/routine-time editing — recorded as a REQ-027 addendum, not a - * new REQ number, in `requests/for-backend.md`. + * 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — the backend + * route exists (`GET`/`PUT patients/{id}/care_record`) but its medication/routine shape is simpler than + * what ui-phase-9 shipped (structured dose amount/unit, frequency preset codes, multi-select `timeOfDay` + * vs. one free-text dose, one required frequency string, no `timeOfDay`) — a product decision on which + * side to change, not yet made (see `mvp/fix-plan.md`). The customer maintains it; it is mocked behind + * this seam until that's resolved. The domain is therefore **mock-primary** (see `constants.ts`). * * Load-bearing rules (contract + phase §5): * - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record @@ -112,9 +112,10 @@ export interface VisitNote { recordedAt: string; } -// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ───────────────── +// ── Access (`GET record_access` exists; matches this shape exactly — mocked only because the domain switches +// as one seam, and the care-plan schema decision below still gates the flip) ─────────────────────────────── -export type RecordAccessDeniedReason = 'no_access' | 'not_found'; +export type RecordAccessDeniedReason = 'not_authorized' | 'not_found'; /** * Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a @@ -127,17 +128,15 @@ export interface RecordAccess { deniedReason?: RecordAccessDeniedReason; } -/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */ +/** The customer edit body — replaces the provided sections of the family record (maps to `UpsertCarePlanBody`, + * pending the care-plan schema decision — see `constants.ts`). */ export interface UpdateFamilyRecordRequest { medications?: Medication[]; routine?: RoutineItem[]; tasks?: CareTask[]; } -/** - * The nurse append body. Maps to the wire `WriteCareRecordBody` (`{ bookingId?, body }`): on the real path - * `taskResults` is composed into `body` (the wire has no structured task field); the mock keeps it structured. - */ +/** The nurse append body. Maps 1:1 to the wire `WriteCareRecordBody` (`{ bookingId?, body, taskResults? }`). */ export interface CreateVisitNoteRequest { bookingId?: number | null; body: string; @@ -153,18 +152,19 @@ export interface WriteVisitNoteResult { /** * The patient-records API seam — the real HTTP client and the in-memory mock both implement this; selection - * is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. `getPatientHistory` + - * `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked). + * is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. Every method maps a real + * route; the whole domain still runs on the mock because the family-record methods need the care-plan schema + * decision resolved first (see `constants.ts`) before `clientApi.ts` can implement them correctly. */ export interface PatientRecordsApi { - /** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */ + /** `GET care_record` exists; blocked on the care-plan schema decision. */ getFamilyRecord(patientId: number): Promise; - /** REQ-027 — who may view/edit/append for this patient (derived from the 403 on a read + the caller role). */ + /** `GET record_access` — who may view/edit/append for this patient. */ getRecordAccess(patientId: number): Promise; - /** REAL — the patient-scoped longitudinal visit-note history, newest-first, paged. */ + /** `GET care_records` — the patient-scoped longitudinal visit-note history, newest-first, paged. */ getPatientHistory(patientId: number, params: PageParams): Promise>; - /** REQ-027 — the customer replaces sections of the family record. */ + /** `PUT care_record` exists; blocked on the care-plan schema decision. */ updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise; - /** REAL — a nurse appends a visit note (append-only; never edits the record). */ + /** `POST care_records` — a nurse appends a visit note (append-only; never edits the record). */ createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise; } diff --git a/mvp/fix-plan.md b/mvp/fix-plan.md index f02641c..917132a 100644 --- a/mvp/fix-plan.md +++ b/mvp/fix-plan.md @@ -27,7 +27,7 @@ whatever order you prefer. | 09 | [nurse-verification-badge](blocker-phases/09-nurse-verification-badge.md) | Verification badge doesn't reflect reality | pairs with 10 | ✅ Done | | 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | ✅ Done | | 11 | [nurse-payouts](blocker-phases/11-nurse-payouts.md) | Nurse pay/payouts are fake, no "process" action | benefits from 01 | — | -| 12 | [patient-records](blocker-phases/12-patient-records.md) | Patient records & visit notes are fake demo data | needs a product decision first | — | +| 12 | [patient-records](blocker-phases/12-patient-records.md) | Patient records & visit notes are fake demo data | needs a product decision first | 🟡 Partial (see follow-up below) | | 13 | [booking-lifecycle](blocker-phases/13-booking-lifecycle.md) | Stuck bookings; "today's visits" unfiltered | pairs with 04 | — | | 14 | [partner-center](blocker-phases/14-partner-center.md) | Partner/business-center accounts are fake | benefits from 01 | — | | 15 | [debug-mode-production](blocker-phases/15-debug-mode-production.md) | Turn off dev mode on the live site (§B.2) | do last, deliberately | — | @@ -65,6 +65,26 @@ whatever order you prefer. decision (booking-lifecycle's "today's visits" fix), since both are the same missing `Asia/Tehran`/UTC-boundary discipline. +- **Phase 12 closed everything except the medication/routine schema decision, which stays deliberately + unresolved.** Fixed: the silent data-wipe (`patients/[id]/record/page.tsx`'s `EditableTabs.save` now always + resends the full `medications`/`routine`/`tasks` triple, not just the tab being edited — the real + `UpsertCarePlanCommand` fully replaces all three from the request, no server-side merge); the + `RecordAccess.deniedReason` enum (client now matches the server's real `'not_authorized' | 'not_found'`, + was `'no_access' | 'not_found'`); and the visit-note `taskResults` mapper bug (`clientApi.ts` was hardcoding + `taskResults: []` on read and folding the checklist into free text on write, even though `CareRecordDto` + already serves a structured `TaskResults` array and `WriteCareRecordBody` already accepts one — both sides + now pass the array through directly). Also corrected the stale "no backend at all" comments in + `constants.ts`/`types.ts`/`clientApi.ts`/`mockApi.ts` — `PatientCareRecordsController` implements every + route this domain needs. + **Still open, on purpose:** the client's medication/routine shape (structured dose amount/unit, frequency + preset codes, a multi-select `timeOfDay`) doesn't match the server's (one free-text dose, one required + frequency string, no `timeOfDay` on either medications or routine items) — see phase 12's items #3/#4. Asked + which side should change; the answer was to leave both as they are for now and just keep this note so it + isn't lost. `USE_PATIENT_RECORDS_MOCK` therefore stays `true`, and the id-type mismatch (client `string` ids + vs. the wire's `long`, phase 12 item #2) is left unfixed too — it can't be finished without first knowing + the target field shape, since `getFamilyRecord`/`updateFamilyRecord` would need a real mapping function + between the two, not just an id conversion. + - **ZarinPal-callback translator, latent until a real gateway is switched on.** Phase 07 restored the mock-gateway harness and pointed `MockPaymentProvider.InitPaymentAsync`'s `redirectUrl` at it (relative URL, no more dead `mock-psp.local` host) — the card-payment dead end (blockers.md § "Payments") is closed