blocker phase 12
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+21
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user