12 KiB
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. This is the
atlas's largest product-doc hole.
Integration: docs/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 thessilently 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 — 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 mounts the panel under the EVV surface |
| E3 nurse visit note | same route | 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
→ mockApi, because USE_PATIENT_RECORDS_MOCK = true
(constants.ts:13). The table is the real half
(clientApi.ts) that is compiled but not selected.
Shapes: docs/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 |
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: one controller,
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),
plus constants.ts:6-8 and
types.ts:11-13, 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 |
✅ 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 |
✅ 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) | — | ❌ VIOLATED — see gaps |
| Nurse view never wires plan editing | 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
- Log in as
09120000010(سارا محمدی, customer) — see testing-setup.md. - 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 ismockApi.ts:47defaultFamilyRecord, and it resets on every page reload. - Navigate to
/fa/patients/8888/record. Expect: the access-denied card.8888isMOCK_FOREIGN_PATIENT_ID(constants.ts:36) — a mock-only sentinel with no real-path equivalent. - 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 «زهرا عزیزی» — andtaskResultsentries withlabel: null, done: false(a seed defect, see gaps).GET /patients/1/care_record→200with three empty arrays — the seeded world has no care plan at all. - 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_accesswith the nurse token returnscanAppendNote: 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) vs clientMedication { id: string; doseAmount; doseUnit; frequencyCode; frequencyText; timeOfDay: TimeOfDayCode[] }(types.ts:56-66). Onlyname/timingNotesurvive. 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.idarestring(types.ts:57,70,78, mock-seeded'm1'/'r1'/'t1'); the wire islong Id.updateFamilyRecordPUTs the record whole, ids included, so a flip sends"m1"where the server bindslong— the write is unsafe, not just lossy. RoutineItem.timeOfDayis an array client-side, a singlestring?server-side (RoutineItemDto,CarePlanDtos.cs:13) — multi-slot routine items cannot round-trip.deniedReasonvocabulary disagrees. Server emits"not_authorized"(PatientAccess.cs:20); the client union is'no_access' | 'not_found'(types.ts:117) and the integration doc documentsno_access. A real denial would land on an unmapped code.- Structured
taskResultsare 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), butclientApi.ts:37hardcodestaskResults: []andclientApi.ts:100-103posts only{bookingId, body}, folding the checklist into free text viacomposeVisitNoteBody. The:36/:45comments saying the wire has no structured field are wrong. - Seeded visit notes return
label: null, done: false.DemoLifecycleSeeder.Social.cs:323,330,337writes camelCase JSON ([{"label":…,"done":true}]) whileGetPatientHistoryQuery.Handler.cs:78deserializesList<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
09120000011onGET /patients/1/care_recordsgot403 "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-25all returnForbiddenResult. Violates server/CLAUDE.md hard rule 20 and INV-7. - No care plan is seeded for any patient —
GET /patients/1/care_recordreturns 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/ is now only an index.)