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;
// 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) => {
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') {
@@ -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<FamilyCareRecord> =>
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> =>
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) };
},
// REQ-027: proposed customer edit of the family record.
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
unwrap(
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${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 ?? [],
}),
}),
),
@@ -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<T>(all: T[], params: PageParams): Paginated<T> {
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<RecordAccess> => {
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).
@@ -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;
+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
* **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<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>;
/** 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>>;
/** 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>;
/** 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>;
}