blocker phase 12

This commit is contained in:
hamid
2026-08-02 23:24:28 +03:30
parent 66a60ce874
commit 184b202f00
6 changed files with 74 additions and 62 deletions
@@ -170,14 +170,19 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car
} }
const data = record.data; 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<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => { const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(patch, { update.mutate(
onSuccess: () => { { medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
enqueueSnackbar(t('saved'), { variant: 'success' }); {
onDone(); onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
}, },
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), );
});
}; };
if (tab === 'medications') { if (tab === 'medications') {
@@ -23,6 +23,7 @@ interface CareRecordWire {
nurseProfileId: number; nurseProfileId: number;
nurseName: string | null; nurseName: string | null;
body: string; body: string;
taskResults: TaskResult[];
recordedAt: string; recordedAt: string;
} }
@@ -33,42 +34,27 @@ function toVisitNote(w: CareRecordWire): VisitNote {
nurseProfileId: w.nurseProfileId, nurseProfileId: w.nurseProfileId,
nurseDisplayName: w.nurseName, nurseDisplayName: w.nurseName,
body: w.body, body: w.body,
// The wire body carries only free text; the structured checklist is composed into it on write (see below). taskResults: w.taskResults,
taskResults: [],
recordedAt: w.recordedAt, recordedAt: w.recordedAt,
}; };
} }
/** /**
* Folds the nurse's ticked task checklist into the free-text note body, because the wire * Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Every method maps a real route:
* `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:
* - `getPatientHistory` → `GET patients/{id}/care_records` (the patient-scoped, newest-first note history; * - `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). * 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). * - `createVisitNote` → `POST patients/{id}/care_records` (a nurse appends one encrypted note).
* * - `getRecordAccess` → `GET patients/{id}/record_access`.
* The family-record + access methods target contract gaps the frontend filed (**REQ-027**) — no wire * - `getFamilyRecord`/`updateFamilyRecord` → `GET`/`PUT patients/{id}/care_record` — the routes exist, but
* endpoint exists — which is why the domain stays mock-primary (see `constants.ts`). * `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`). * NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
*/ */
export const patientRecordsClientApi: PatientRecordsApi = { 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<FamilyCareRecord> => getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> =>
unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`)), unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${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<RecordAccess> => getRecordAccess: async (patientId: number): Promise<RecordAccess> =>
unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)), unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)),
@@ -84,7 +70,6 @@ export const patientRecordsClientApi: PatientRecordsApi = {
return { ...page, items: page.items.map(toVisitNote) }; return { ...page, items: page.items.map(toVisitNote) };
}, },
// REQ-027: proposed customer edit of the family record.
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> => updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
unwrap( unwrap(
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, { await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, {
@@ -99,7 +84,8 @@ export const patientRecordsClientApi: PatientRecordsApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
bookingId: body.bookingId ?? null, bookingId: body.bookingId ?? null,
body: composeVisitNoteBody(body.body, body.taskResults), body: body.body.trim(),
taskResults: body.taskResults ?? [],
}), }),
}), }),
), ),
@@ -15,9 +15,9 @@ import type {
} from '../types'; } from '../types';
/** /**
* In-memory `PatientRecordsApi` — **the primary implementation this phase** (the nurse-authored visit-note * In-memory `PatientRecordsApi` — **the primary implementation this phase**. Every route this seam needs
* history/append are real b14, but the family-owned medications/routine/tasks record + the access check are * exists on the server; the domain stays mock-primary because the family-owned medications/routine/tasks
* REQ-027 gaps — see `constants.ts`). * 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 * 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: * every E2 record viewer has content and every state is demoable:
@@ -149,7 +149,7 @@ function paginate<T>(all: T[], params: PageParams): Paginated<T> {
function assertAccess(patientId: number): void { function assertAccess(patientId: number): void {
// The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient. // The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
if (patientId === MOCK_FOREIGN_PATIENT_ID) { 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<RecordAccess> => { getRecordAccess: async (patientId: number): Promise<RecordAccess> => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
if (patientId === MOCK_FOREIGN_PATIENT_ID) { 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 // 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). // shell) decides which affordances to render — the nurse view never wires the edit path (append-only).
@@ -2,13 +2,14 @@
* When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the * When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PatientRecordsApi` seam. * `PatientRecordsApi` seam.
* *
* **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records` * **Mock is primary — the backend is fully built, but the shapes don't match yet.** `PatientCareRecordsController`
* GET/POST) — those two methods are real — but the **family-owned editable record** (medications/routine/ * implements every route this seam needs: `care_records` GET/POST (visit-note history/append), `care_record`
* tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has * GET/PUT (the family care plan), and `record_access` GET. The blocker is a genuine schema mismatch, not a
* them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient, * missing endpoint — see `mvp/fix-plan.md`'s "Patient records" follow-up: the client's medication/routine
* enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the * shape (structured dose amount/unit, frequency preset codes, a multi-select `timeOfDay`) was built after the
* history. Flip to `false` once REQ-027 lands — only `clientApi.ts`'s family-record/access methods flip; the * server shipped a simpler one (one free-text dose, one required frequency string, no `timeOfDay` at all),
* history/append methods already map the real routes. * 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; export const USE_PATIENT_RECORDS_MOCK = true;
+20 -20
View File
@@ -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 * 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 * **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. * with a qualifying booking may **append** a note; nobody edits it.
* 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend * 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — the backend
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it; * route exists (`GET`/`PUT patients/{id}/care_record`) but its medication/routine shape is simpler than
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`). * what ui-phase-9 shipped (structured dose amount/unit, frequency preset codes, multi-select `timeOfDay`
* ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes) * vs. one free-text dose, one required frequency string, no `timeOfDay`) — a product decision on which
* that replaced free-text dose/frequency/routine-time editing — recorded as a REQ-027 addendum, not a * side to change, not yet made (see `mvp/fix-plan.md`). The customer maintains it; it is mocked behind
* new REQ number, in `requests/for-backend.md`. * this seam until that's resolved. The domain is therefore **mock-primary** (see `constants.ts`).
* *
* Load-bearing rules (contract + phase §5): * Load-bearing rules (contract + phase §5):
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record * - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
@@ -112,9 +112,10 @@ export interface VisitNote {
recordedAt: string; 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 * 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; 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 { export interface UpdateFamilyRecordRequest {
medications?: Medication[]; medications?: Medication[];
routine?: RoutineItem[]; routine?: RoutineItem[];
tasks?: CareTask[]; tasks?: CareTask[];
} }
/** /** The nurse append body. Maps 1:1 to the wire `WriteCareRecordBody` (`{ bookingId?, body, taskResults? }`). */
* 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.
*/
export interface CreateVisitNoteRequest { export interface CreateVisitNoteRequest {
bookingId?: number | null; bookingId?: number | null;
body: string; 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 * 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` + * is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. Every method maps a real
* `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked). * 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 { 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<FamilyCareRecord>; getFamilyRecord(patientId: number): Promise<FamilyCareRecord>;
/** 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<RecordAccess>; getRecordAccess(patientId: number): Promise<RecordAccess>;
/** 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<Paginated<VisitNote>>; getPatientHistory(patientId: number, params: PageParams): Promise<Paginated<VisitNote>>;
/** 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<FamilyCareRecord>; updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord>;
/** 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<WriteVisitNoteResult>; createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
} }
+21 -1
View File
@@ -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 | | 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 | | 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 | — | | 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 | — | | 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 | — | | 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 | — | | 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 decision (booking-lifecycle's "today's visits" fix), since both are the same missing
`Asia/Tehran`/UTC-boundary discipline. `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 - **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 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 URL, no more dead `mock-psp.local` host) — the card-payment dead end (blockers.md § "Payments") is closed