cleanup phases 3

This commit is contained in:
hamid
2026-08-02 17:18:36 +03:30
parent c841bded26
commit b876490246
31 changed files with 3779 additions and 35 deletions
+108
View File
@@ -0,0 +1,108 @@
# Flow — patient-care-records
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (owns the care plan) · nurse (appends visit notes) · admin (read-only) · **Status:** mocked
**Client:** mock · **Server:** real
**Business source:** none — **no `product/business/` area covers clinical records.** The only product source is
[product/data-model/10-reviews-and-records.md](../../product/data-model/10-reviews-and-records.md). This is the
atlas's largest product-doc hole.
**Integration:** [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md)
## What it does
A family keeps a **care plan** for a patient — medications, daily routine, a task checklist — that outlives any
one booking. A nurse on a confirmed booking reads that plan, ticks the checklist during a visit, and **appends**
one visit note. Nobody edits or deletes a note; the next nurse reads the whole patient history for continuity.
> ⚠ **Two endpoints one character apart.** `care_record` (singular) is the **family-owned plan**
> (`GET`/`PUT`). `care_records` (plural) is the **append-only visit history** (`GET`/`POST`). Different
> resources, different owners, different write verbs. Misreading the `s` silently targets the wrong resource.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Customer opens the care circle | `/fa/patients` | E1 list → row links to the record |
| **E2 care record** | `/fa/patients/[id]/record` | [`record/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/patients/%5Bid%5D/record/page.tsx) — 4 tabs داروها / روتین / سوابق / وظایف; per-item bottom-sheet editing; `canView === false` → non-leaking access-denied card **before** any clinical fetch |
| Nurse opens today's visit | `/fa/nurse/visits/[id]` | [`page.tsx:24`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/page.tsx) mounts the panel under the EVV surface |
| **E3 nurse visit note** | same route | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) — checklist + free-text composer + read-only history. Panel renders only when the booking is confirmed-or-beyond (`:36`); composer hides unless `canAppendNote` (`:72`) |
## API
All five ops go hook → [`services/patientRecords/apis/index.ts`](../../client/src/services/patientRecords/apis/index.ts)
**`mockApi`**, because `USE_PATIENT_RECORDS_MOCK = true`
([`constants.ts:13`](../../client/src/services/patientRecords/constants.ts)). The table is the **real half**
([`clientApi.ts`](../../client/src/services/patientRecords/apis/clientApi.ts)) that is compiled but not selected.
Shapes: [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md).
| Call | Endpoint | Live probe (2026-08-02) |
| --- | --- | --- |
| `getRecordAccess` | `GET /patients/{id}/record_access` | **200** — owner `{canView:t, canEdit:t, canAppendNote:f}`; nurse 1 `{t, f, t}`; patient 9999 `{f,f,f, deniedReason:"not_found"}` |
| `getFamilyRecord` | `GET /patients/{id}/care_record` | **200** — but patient 1 returns `{medications:[],routine:[],tasks:[]}` (nothing seeded) |
| `updateFamilyRecord` | `PUT /patients/{id}/care_record` | not probed (write). Owner-only guard at [`UpsertCarePlanCommand.Handler.cs:24`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/UpsertCarePlan/UpsertCarePlanCommand.Handler.cs) |
| `getPatientHistory` | `GET /patients/{id}/care_records?page&pageSize` | **200** — 2 notes for patient 1, newest-first, Persian bodies decrypted |
| `createVisitNote` | `POST /patients/{id}/care_records` | not probed (write). Nurse + qualifying-booking guard at [`WritePatientCareRecordCommand.Handler.cs:39-43`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs) |
Server: one controller,
[`PatientCareRecordsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs),
five actions, all `[Authorize]`, all five handlers present.
**REQ-027 is delivered.** Every client comment claiming "no wire endpoint exists"
([`clientApi.ts:61-62`, `:67`, `:71`, `:87`](../../client/src/services/patientRecords/apis/clientApi.ts)),
plus [`constants.ts:6-8`](../../client/src/services/patientRecords/constants.ts) and
[`types.ts:11-13`](../../client/src/services/patientRecords/types.ts), is **stale** — contradicted by a live 200.
## Rules that must hold
| Rule | Where enforced | Verified |
| --- | --- | --- |
| **Append-only.** No edit, no delete of a visit note — there is no such endpoint and none may be added | controller has only `POST` + `GET` on `care_records` | ✅ read |
| **Encrypted at rest.** Bodies leave the repo as ciphertext (`CareRecordCipherRow.BodyEncrypted`) and are decrypted only after the access check | [`GetPatientHistoryQuery.Handler.cs:56-65`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs) | ✅ read |
| **Patient-scoped, not booking-scoped** — a new nurse reads the whole history | `GetPatientHistoryAsync(patientId, …)` | ✅ live (2 notes span bookings 3 and 4) |
| **One access resolver** — edit = owning customer, append = nurse with a confirmed booking, view = either or admin | [`PatientAccess.cs:24-50`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/PatientAccess.cs) | ✅ live |
| `record_access` **never 403s** — always 200 with non-leaking flags (deliberate INV-7 exception) | same | ✅ live (patient 9999 → 200) |
| **Tenancy mismatch is a 404, never a 403** ([server/CLAUDE.md hard rule 20](../../server/CLAUDE.md)) | — | ❌ **VIOLATED — see gaps** |
| Nurse view never wires plan editing | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) imports no `useUpdateCareRecord` | ✅ read |
| Client hard rule 19 — never leak clinical data; gate access **before** any clinical fetch | `useRecordAccess` gates `enabled:` on the other two queries (`:42-43`) | ✅ read |
Note counts are not load-bearing; there are no money or config numbers in this flow.
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Go to `/fa/patients`, open a patient, tap پروندهٔ مراقبت (`/fa/patients/1/record`).
**Expect (today, mocked):** all four tabs populated — «متفورمین ۵۰۰», a routine list, a task list, and a
month-grouped سوابق timeline. **None of it is on the server**; it is
[`mockApi.ts:47`](../../client/src/services/patientRecords/apis/mockApi.ts) `defaultFamilyRecord`, and it
**resets on every page reload**.
3. Navigate to `/fa/patients/8888/record`.
**Expect:** the access-denied card. `8888` is `MOCK_FOREIGN_PATIENT_ID` (`constants.ts:36`) — a mock-only
sentinel with no real-path equivalent.
4. To see the **truth**, bypass the client:
`curl -s --noproxy '*' "http://localhost:5002/api/v1/patients/1/care_records?page=1&pageSize=10" -H "Authorization: Bearer $TOKEN"`.
**Expect:** `200`, `total: 2`, two Persian bodies by «زهرا عزیزی» — and `taskResults` entries with
**`label: null, done: false`** (a seed defect, see gaps).
`GET /patients/1/care_record``200` with **three empty arrays** — the seeded world has no care plan at all.
5. Log in as `09120000001` (زهرا عزیزی, nurse), open `/fa/nurse/visits/3`, scroll past EVV.
**Expect:** the checklist + composer + continuity history — again all mock. `GET /patients/1/record_access`
with the nurse token returns `canAppendNote: true`, so the real path would also permit the append.
**Seeded-world limits:** no care plan row exists for any patient, so the real path's medications/routine/tasks
tabs and the nurse's checklist would all render empty. Only patients 1 and (nurse 2 / customer 011's infant)
have visit notes. Creating a plan requires a real `PUT /patients/1/care_record`.
## Known gaps
- `USE_PATIENT_RECORDS_MOCK = true` (`patientRecords/constants.ts:13`) while **all five server endpoints are live** — the entire clinical surface both actors see is in-browser fiction that resets on reload.
- **Care-plan shape mismatch blocks the flip.** Server `MedicationDto(long Id, string Name, string? Dosage, string Frequency, string? TimingNote)` ([`CarePlanDtos.cs:11`](../../server/src/Core/Baya.Application/Models/Patients/CarePlanDtos.cs)) vs client `Medication { id: string; doseAmount; doseUnit; frequencyCode; frequencyText; timeOfDay: TimeOfDayCode[] }` ([`types.ts:56-66`](../../client/src/services/patientRecords/types.ts)). Only `name`/`timingNote` survive. The ui-phase-9 structured addendum was never delivered server-side.
- **H-17 ID-TYPE MISMATCH — CONFIRMED, not fixed.** Client `Medication.id` / `RoutineItem.id` / `CareTask.id` are `string` (`types.ts:57,70,78`, mock-seeded `'m1'`/`'r1'`/`'t1'`); the wire is `long Id`. `updateFamilyRecord` `PUT`s the record whole, ids included, so a flip sends `"m1"` where the server binds `long` — the write is unsafe, not just lossy.
- **`RoutineItem.timeOfDay` is an array client-side, a single `string?` server-side** (`RoutineItemDto`, `CarePlanDtos.cs:13`) — multi-slot routine items cannot round-trip.
- **`deniedReason` vocabulary disagrees.** Server emits `"not_authorized"` (`PatientAccess.cs:20`); the client union is `'no_access' | 'not_found'` (`types.ts:117`) and [the integration doc](../integration/domains/patient-records.md) documents `no_access`. A real denial would land on an unmapped code.
- **Structured `taskResults` are discarded in both directions on the real path.** The server accepts them on write (`WriteCareRecordBody.TaskResults`, controller `:63`) and returns them on read (`CareRecordDto.TaskResults`), but `clientApi.ts:37` hardcodes `taskResults: []` and `clientApi.ts:100-103` posts only `{bookingId, body}`, folding the checklist into free text via `composeVisitNoteBody`. The `:36`/`:45` comments saying the wire has no structured field are wrong.
- **Seeded visit notes return `label: null, done: false`.** `DemoLifecycleSeeder.Social.cs:323,330,337` writes camelCase JSON (`[{"label":…,"done":true}]`) while `GetPatientHistoryQuery.Handler.cs:78` deserializes `List<TaskResultDto>` with default (PascalCase) options. Live-confirmed: 3 result rows, all null/false. API-written notes round-trip fine; only the demo data is broken.
- **Tenancy leak: a foreign patient returns 403, not 404.** Live-confirmed — customer `09120000011` on `GET /patients/1/care_records` got `403 "You do not have clinical access to this patient's care records."`, confirming patient 1 exists. `GetPatientHistoryQuery.Handler.cs:56-58`, `GetCarePlanQuery.Handler.cs:23-24`, `UpsertCarePlanCommand.Handler.cs:24-25` all return `ForbiddenResult`. Violates server/CLAUDE.md hard rule 20 and INV-7.
- **No care plan is seeded for any patient** — `GET /patients/1/care_record` returns three empty arrays, so on the real path the nurse's task checklist would always be empty and the E2 plan tabs blank.
- **No `product/business/` file covers clinical records** — no owning business area for encryption, the append-only rule, or the clinical access gate. Everything above is derived from code, not from a product decision.
- **`MOCK_FOREIGN_PATIENT_ID = 8888`** (`constants.ts:36`) is the only way to demo access-denied; the real path has no such sentinel, so that screen state is untested against the server.
- Four stale doc-blocks assert "no wire endpoint exists" / "REQ-027 gap": `clientApi.ts:61-62`, `constants.ts:6-11`, `types.ts:11-16`, `apis/index.ts:7-8`. All four are wrong; the integration domain file is right. (`docs/status/mocks-registry.md`, which recorded the correct verdict, no longer exists — [docs/status/](../status/index.md) is now only an index.)