frontend phase 13

This commit is contained in:
hamid
2026-07-10 16:58:15 +03:30
parent 6186f54294
commit 85488bc25b
57 changed files with 3283 additions and 105 deletions
@@ -263,6 +263,52 @@ function seed(): void {
},
],
},
// 5005 — a COMPLETED single-visit booking. There is otherwise no completed seed (checkOutVisit only
// reaches `completed` at runtime), so f13's leave-a-review flow needs this: the customer opens 5005 →
// review-eligible; its patient 905 + nurse 1 align with the reviews/records mocks for deep-links.
{
id: 5005,
bookingRequestId: 9005,
status: 'completed',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 905,
patientName: 'بانو حسینی',
variantId: 15,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی — ویزیت', priceUnit: 'per_visit' }),
customerAddressId: 805,
addressSnapshotJson: JSON.stringify({ title: 'خانه', addressLine: 'تهران، خیابان ولیعصر', cityName: 'تهران', latitude: 35.72, longitude: 51.41 }),
grossPriceIrr: '3000000',
balinyaarCommissionIrr: '360000',
nursePayoutAmount: '2640000',
pspFeeAmount: '60000',
platformFeeRate: 0.12,
sessionCount: 1,
scheduledDate: isoDate(-2),
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
confirmedAt: new Date(Date.now() - 4 * 86_400_000).toISOString(),
completedAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: new Date(Date.now() + 2 * 86_400_000).toISOString(),
createdAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
sessions: [
{
...makeSession(70051, 1, -2, '2640000'),
status: 'completed',
payoutEligibleAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
evvStatus: 'completed',
checkInAt: new Date(Date.now() - 1 * 86_400_000 - 4 * 3_600_000).toISOString(),
checkOutAt: new Date(Date.now() - 1 * 86_400_000).toISOString(),
checkInAddressMatch: true,
},
],
},
];
care[5001] = {
@@ -575,6 +621,16 @@ export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
return cloneBooking(findBooking(bookingId));
}
/**
* Mock-only read for the reviews domain (f13): the booking (a safe clone) so the reviews mock can gate
* review-eligibility on a completed/closed booking and read `nurseId`/`patientId` for a submission. Throws
* `404` if unknown. One-way edge INTO bookings (the bookings mock never imports f13 back → no cycle). NOT
* part of the `BookingsApi` seam — only `services/reviews`' mock imports it.
*/
export function mockGetBookingForReview(bookingId: number): BookingDetailDto {
return cloneBooking(findBooking(bookingId));
}
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
export interface CancelBookingSnapshot {
cancelledBy: string;
@@ -0,0 +1,106 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { RECORD_HISTORY_PAGE_SIZE } from '../constants';
import type {
CreateVisitNoteRequest,
FamilyCareRecord,
PatientRecordsApi,
RecordAccess,
TaskResult,
UpdateFamilyRecordRequest,
VisitNote,
WriteVisitNoteResult,
} from '../types';
const API = '/api/v1';
/** Wire `CareRecordDto` — the nurse-authored note (`body` decrypted after the access check). */
interface CareRecordWire {
id: number;
patientId: number;
bookingId: number | null;
nurseProfileId: number;
nurseName: string | null;
body: string;
recordedAt: string;
}
function toVisitNote(w: CareRecordWire): VisitNote {
return {
id: w.id,
bookingId: w.bookingId,
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: [],
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:
* - `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`).
*
* 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`)),
getPatientHistory: async (patientId: number, params: PageParams): Promise<Paginated<VisitNote>> => {
const query = new URLSearchParams();
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? RECORD_HISTORY_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<CareRecordWire>>>(
`${API}/patients/${patientId}/care_records?${query.toString()}`,
),
);
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`, {
method: 'PUT',
body: JSON.stringify(body),
}),
),
createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult> =>
unwrap(
await clientFetch<ApiEnvelope<WriteVisitNoteResult>>(`${API}/patients/${patientId}/care_records`, {
method: 'POST',
body: JSON.stringify({
bookingId: body.bookingId ?? null,
body: composeVisitNoteBody(body.body, body.taskResults),
}),
}),
),
};
@@ -0,0 +1,13 @@
import { USE_PATIENT_RECORDS_MOCK } from '../constants';
import type { PatientRecordsApi } from '../types';
import { patientRecordsClientApi } from './clientApi';
import { patientRecordsMockApi } from './mockApi';
/**
* The selected `PatientRecordsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_PATIENT_RECORDS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (the
* family-owned record + access check are REQ-027 gaps; the visit-note history/append are real b14).
*/
export const patientRecordsApi: PatientRecordsApi = USE_PATIENT_RECORDS_MOCK
? patientRecordsMockApi
: patientRecordsClientApi;
@@ -0,0 +1,195 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { MOCK_FOREIGN_PATIENT_ID } from '../constants';
import type {
CreateVisitNoteRequest,
FamilyCareRecord,
Medication,
PatientRecordsApi,
RecordAccess,
RoutineItem,
UpdateFamilyRecordRequest,
VisitNote,
WriteVisitNoteResult,
} 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`).
*
* 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:
* - a **default family record** (medications/routine/tasks the customer edits);
* - a **multi-nurse continuity history** (two prior notes from *different* nurses — proving the history
* persists across nurse changes; a nurse append prepends to the SAME patient's history);
* - a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID` → `canView: false` + a `403` on
* every read) so the non-leaking access-denied card is demoable.
*
* Money-free domain; clinical text is fixture data (never logged). Patient ids align with the f8 booking
* snapshots (patient 905 on the completed booking 5005) so the nurse note flow lands on a real record.
*/
const MOCK_LATENCY_MS = 300;
/** The seeded nurse authoring a fresh note in the mock (the current session's nurse). */
const MOCK_CURRENT_NURSE_ID = 1;
const MOCK_CURRENT_NURSE_NAME = 'مریم رضایی';
let nextNoteId = 8100;
function isoDaysAgo(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString();
}
/** The default family-owned record seeded for a patient on first access (the customer edits from here). */
function defaultFamilyRecord(patientId: number): FamilyCareRecord {
return {
patientId,
medications: [
{ id: 'm1', name: 'متفورمین ۵۰۰', dosage: '۱ قرص', frequency: 'روزی دو بار', timingNote: 'صبح و شب، بعد از غذا' },
{ id: 'm2', name: 'لوزارتان ۲۵', dosage: '۱ قرص', frequency: 'روزی یک بار', timingNote: 'صبح' },
],
routine: [
{ id: 'r1', label: 'اندازه‌گیری فشار خون', timeOfDay: 'صبح', note: 'پیش از داروی فشار' },
{ id: 'r2', label: 'پیاده‌روی کوتاه', timeOfDay: 'عصر', note: null },
],
tasks: [
{ id: 't1', label: 'دادن متفورمین', done: false },
{ id: 't2', label: 'اندازه‌گیری فشار خون', done: false },
{ id: 't3', label: 'پیاده‌روی کوتاه', done: false },
],
};
}
/** A default continuity history seeded for a patient — two notes from DIFFERENT nurses (newest-first). */
function defaultHistory(): VisitNote[] {
return [
{
id: nextNoteId++,
bookingId: null,
nurseProfileId: 2,
nurseDisplayName: 'سارا محمدی',
body: 'وضعیت بیمار پایدار بود؛ داروها طبق برنامه داده شد و فشار خون در محدودهٔ طبیعی بود.',
taskResults: [
{ label: 'دادن متفورمین', done: true },
{ label: 'اندازه‌گیری فشار خون', done: true },
],
recordedAt: isoDaysAgo(2),
},
{
id: nextNoteId++,
bookingId: null,
nurseProfileId: MOCK_CURRENT_NURSE_ID,
nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
body: 'پیاده‌روی کوتاه انجام شد؛ اشتها خوب بود. توصیه به ادامهٔ روتین.',
taskResults: [{ label: 'پیاده‌روی کوتاه', done: true }],
recordedAt: isoDaysAgo(9),
},
];
}
const familyRecords = new Map<number, FamilyCareRecord>();
const histories = new Map<number, VisitNote[]>();
function ensureFamilyRecord(patientId: number): FamilyCareRecord {
let record = familyRecords.get(patientId);
if (!record) {
record = defaultFamilyRecord(patientId);
familyRecords.set(patientId, record);
}
return record;
}
function ensureHistory(patientId: number): VisitNote[] {
let history = histories.get(patientId);
if (!history) {
history = defaultHistory();
histories.set(patientId, history);
}
return history;
}
/** Deep clone so a reader can't mutate the store by reference. */
function cloneRecord(r: FamilyCareRecord): FamilyCareRecord {
return {
patientId: r.patientId,
medications: r.medications.map((m) => ({ ...m })),
routine: r.routine.map((x) => ({ ...x })),
tasks: r.tasks.map((t) => ({ ...t })),
};
}
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? all.length);
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
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');
}
}
export const patientRecordsMockApi: PatientRecordsApi = {
getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> => {
await sleep(MOCK_LATENCY_MS);
assertAccess(patientId);
return cloneRecord(ensureFamilyRecord(patientId));
},
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' };
}
// 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).
return { canView: true, canEdit: true, canAppendNote: true };
},
getPatientHistory: async (patientId: number, params: PageParams): Promise<Paginated<VisitNote>> => {
await sleep(MOCK_LATENCY_MS);
assertAccess(patientId);
const history = [...ensureHistory(patientId)].sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
return paginate(
history.map((n) => ({ ...n, taskResults: n.taskResults.map((t) => ({ ...t })) })),
params,
);
},
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> => {
await sleep(MOCK_LATENCY_MS);
assertAccess(patientId);
const record = ensureFamilyRecord(patientId);
if (body.medications) record.medications = body.medications.map((m: Medication) => ({ ...m }));
if (body.routine) record.routine = body.routine.map((r: RoutineItem) => ({ ...r }));
if (body.tasks) record.tasks = body.tasks.map((t) => ({ ...t }));
return cloneRecord(record);
},
createVisitNote: async (patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult> => {
await sleep(MOCK_LATENCY_MS);
assertAccess(patientId);
const trimmed = body.body.trim();
if (!trimmed) throw new ApiError(400, 'Note body is required', 'empty_body');
const id = nextNoteId++;
const recordedAt = new Date().toISOString();
const note: VisitNote = {
id,
bookingId: body.bookingId ?? null,
nurseProfileId: MOCK_CURRENT_NURSE_ID,
nurseDisplayName: MOCK_CURRENT_NURSE_NAME,
body: trimmed,
taskResults: (body.taskResults ?? []).map((t) => ({ ...t })),
recordedAt,
};
ensureHistory(patientId).unshift(note);
return { id, patientId, recordedAt };
},
};
@@ -0,0 +1,36 @@
/**
* 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.
*/
export const USE_PATIENT_RECORDS_MOCK = true;
/** Page size for the longitudinal visit-note history (api-conventions `pageSize`). */
export const RECORD_HISTORY_PAGE_SIZE = 10;
/**
* The family record + history move slowly (a customer edit / a per-visit note), so a modest `staleTime` means
* tab-switching never refetches; mutations invalidate the affected key. The access check is effectively
* static per session — keep it warm longer.
*/
export const FAMILY_RECORD_STALE_TIME = 60 * 1000;
export const RECORD_HISTORY_STALE_TIME = 60 * 1000;
export const RECORD_ACCESS_STALE_TIME = 5 * 60 * 1000;
export const PATIENT_RECORDS_GC_TIME = 10 * 60 * 1000;
/** Wire cap on a visit-note body (`WriteCareRecordBody.body` ≤ 8000). */
export const VISIT_NOTE_MAX_LENGTH = 8000;
/**
* A sentinel patient id the mock treats as **not owned** by the caller, so the E2 access-denied card is
* demoable by navigating to `/patients/8888/record`. On the real path this state comes from a `403` on the
* clinical read; there is no such thing as a "foreign patient id" on the wire.
*/
export const MOCK_FOREIGN_PATIENT_ID = 8888;
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { patientRecordsApi } from '../apis';
import { recordKeys } from '../keys';
import type { CreateVisitNoteRequest, WriteVisitNoteResult } from '../types';
/**
* The **nurse** append of a visit note (append-only — the nurse never edits the family record). Bound to one
* patient. On success we invalidate the patient's **history** (every page) so the appended note appears in the
* longitudinal timeline at once. The mutation returns only the write result; the history refetch is the source
* of truth. Domain 4xx (`400` empty body, `403` no clinical access) surface to the caller's `onError`.
*/
export function useCreateVisitNote(patientId: number) {
const queryClient = useQueryClient();
return useMutation<WriteVisitNoteResult, unknown, CreateVisitNoteRequest>({
mutationFn: (body) => patientRecordsApi.createVisitNote(patientId, body),
onSuccess: () => {
// Prefix invalidation: [...histories(), patientId] matches every page of this patient's history.
queryClient.invalidateQueries({ queryKey: [...recordKeys.histories(), patientId] });
},
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { patientRecordsApi } from '../apis';
import { recordKeys } from '../keys';
import { FAMILY_RECORD_STALE_TIME, PATIENT_RECORDS_GC_TIME } from '../constants';
/**
* The family-owned, patient-scoped editable record (medications/routine/tasks). Keyed per patient; a customer
* edit invalidates this key. A `403` (access-denied) surfaces as the query's error — the E2 screen gates on
* `useRecordAccess` first, so this normally only runs for an authorized viewer.
*/
export function usePatientCareRecord(patientId: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: recordKeys.patient(patientId),
queryFn: () => patientRecordsApi.getFamilyRecord(patientId),
enabled: (options?.enabled ?? true) && patientId > 0,
staleTime: FAMILY_RECORD_STALE_TIME,
gcTime: PATIENT_RECORDS_GC_TIME,
});
}
@@ -0,0 +1,21 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { patientRecordsApi } from '../apis';
import { recordKeys } from '../keys';
import { PATIENT_RECORDS_GC_TIME, RECORD_HISTORY_PAGE_SIZE, RECORD_HISTORY_STALE_TIME } from '../constants';
/**
* The patient-scoped longitudinal visit-note history (newest-first, paged) — REAL b14 `care_records` GET.
* Read-only; it **persists across nurse changes** (keyed to the patient, not a booking). The page is part of
* the query key so paging never refetches a page already in cache; `keepPreviousData` avoids an empty flash.
* A nurse note append invalidates these keys so the new note appears at once.
*/
export function usePatientHistory(patientId: number, page: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: recordKeys.history(patientId, page),
queryFn: () => patientRecordsApi.getPatientHistory(patientId, { page, pageSize: RECORD_HISTORY_PAGE_SIZE }),
enabled: (options?.enabled ?? true) && patientId > 0,
staleTime: RECORD_HISTORY_STALE_TIME,
gcTime: PATIENT_RECORDS_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { patientRecordsApi } from '../apis';
import { recordKeys } from '../keys';
import { PATIENT_RECORDS_GC_TIME, RECORD_ACCESS_STALE_TIME } from '../constants';
/**
* Who may view/edit/append this patient's record. The E2 viewer gates on `canView` **before** fetching the
* record/history, so an unauthorized viewer never pulls clinical data (the two-stage-disclosure discipline —
* the access decision lives here, not in the presentational card). Effectively static per session.
*/
export function useRecordAccess(patientId: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: recordKeys.access(patientId),
queryFn: () => patientRecordsApi.getRecordAccess(patientId),
enabled: (options?.enabled ?? true) && patientId > 0,
staleTime: RECORD_ACCESS_STALE_TIME,
gcTime: PATIENT_RECORDS_GC_TIME,
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { patientRecordsApi } from '../apis';
import { recordKeys } from '../keys';
import type { FamilyCareRecord, UpdateFamilyRecordRequest } from '../types';
/**
* The **customer** edit of the family-owned record (medications/routine/tasks). Bound to one patient. On
* success we write the returned record straight into the cache (`setQueryData`) so the edit shows without a
* refetch flash. This hook is **customer-only** — it must **never** be wired into the nurse view (the nurse is
* append-only).
*/
export function useUpdateCareRecord(patientId: number) {
const queryClient = useQueryClient();
return useMutation<FamilyCareRecord, unknown, UpdateFamilyRecordRequest>({
mutationFn: (body) => patientRecordsApi.updateFamilyRecord(patientId, body),
onSuccess: (updated) => {
queryClient.setQueryData(recordKeys.patient(patientId), updated);
},
});
}
@@ -0,0 +1,12 @@
/**
* Patient-records domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
* types/keys/apis directly from their files when needed.
*
* NB `useUpdateCareRecord` is **customer-only** and `useCreateVisitNote` is **nurse-only** — the nurse view
* must never import the former (append-only is a hard boundary, not a hidden button).
*/
export { usePatientCareRecord } from './hooks/usePatientCareRecord';
export { useRecordAccess } from './hooks/useRecordAccess';
export { usePatientHistory } from './hooks/usePatientHistory';
export { useUpdateCareRecord } from './hooks/useUpdateCareRecord';
export { useCreateVisitNote } from './hooks/useCreateVisitNote';
@@ -0,0 +1,20 @@
/**
* React Query key factory for the patient-records domain (hierarchical, per the `services/{domain}` pattern).
*
* Every key is **patient-scoped** (the record is keyed to the patient, not a booking). The family record,
* the access check, and each history page key independently so revisiting a tab never refetches. A customer
* edit invalidates `patient(patientId)`; a nurse note append invalidates the patient's `history` **only** —
* the append is append-only and does not mutate the family record, so `patient` stays validly cached.
*/
export const recordKeys = {
all: ['patient_records'] as const,
patients: () => [...recordKeys.all, 'patient'] as const,
/** The family-owned medications/routine/tasks record. */
patient: (patientId: number) => [...recordKeys.patients(), patientId] as const,
access: (patientId: number) => [...recordKeys.all, 'access', patientId] as const,
histories: () => [...recordKeys.all, 'history'] as const,
history: (patientId: number, page: number) => [...recordKeys.histories(), patientId, page] as const,
};
+145
View File
@@ -0,0 +1,145 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Patient care-records domain — the **continuity-of-care** surface (b14). Two very different things share
* one screen (the E2 record viewer):
*
* 1. **The nurse-authored, patient-scoped visit-note history** (سوابق) — **REAL** (b14 `care_records`
* 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`).
*
* Load-bearing rules (contract + phase §5):
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
* persists across nurse changes (keyed to the patient, not the booking).
* - **Nurse is append-only.** The nurse view exposes the task checklist + a note composer + the read-only
* history — it must **never** wire `updateFamilyRecord` or any medication/routine/task editing.
* - **Strict access.** Owning customer / nurse with a confirmed booking / admin only. A `403` on a read →
* render a clear, non-leaking access-denied card (never partial clinical data).
* - **Clinical fields are sensitive** — never logged, never in `localStorage`, never in a query string.
*
* Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` + swagger); the
* family-record shapes are the client's own (REQ-027).
*/
/** The four tabs of the E2 record viewer (client display model). */
export type CareRecordTab = 'medications' | 'routine' | 'history' | 'tasks';
export const CARE_RECORD_TABS: readonly CareRecordTab[] = ['medications', 'routine', 'history', 'tasks'] as const;
// ── Family-owned editable record (REQ-027 — customer-maintained, no backend) ──────────────────────────────
/** A medication the family tracks. `id` is a stable client id (the record has no server identity yet). */
export interface Medication {
id: string;
name: string;
dosage: string | null;
frequency: string;
timingNote: string | null;
}
/** A daily-routine item the family tracks (e.g. "measure blood pressure — morning"). */
export interface RoutineItem {
id: string;
label: string;
timeOfDay: string | null;
note: string | null;
}
/** A care task (the checklist the nurse ticks during a visit; the family authors the list). */
export interface CareTask {
id: string;
label: string;
done: boolean;
}
/** The family-owned, patient-scoped editable record (REQ-027). */
export interface FamilyCareRecord {
patientId: number;
medications: Medication[];
routine: RoutineItem[];
tasks: CareTask[];
}
// ── Nurse-authored visit-note history (REAL — b14 care_records) ───────────────────────────────────────────
/** A structured "task X done/not-done" line the nurse ticked — **client-only** (the wire body is free text). */
export interface TaskResult {
label: string;
done: boolean;
}
/**
* One nurse-authored visit note = one `CareRecordDto` (patient-scoped, newest-first). Read-only/append-only
* from the client. `taskResults` is a client-only structured summary of the checklist the nurse ticked — the
* wire `body` carries only free text, so on the real path the checklist is folded into `body`.
*/
export interface VisitNote {
id: number;
bookingId: number | null;
nurseProfileId: number;
nurseDisplayName: string | null;
body: string;
taskResults: TaskResult[];
/** UTC ISO-8601 — Shamsi display is the client's job. */
recordedAt: string;
}
// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ─────────────────
export type RecordAccessDeniedReason = 'no_access' | 'not_found';
/**
* Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a
* nurse with a qualifying booking only. A denied read surfaces `canView: false` + a `deniedReason`.
*/
export interface RecordAccess {
canView: boolean;
canEdit: boolean;
canAppendNote: boolean;
deniedReason?: RecordAccessDeniedReason;
}
/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */
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.
*/
export interface CreateVisitNoteRequest {
bookingId?: number | null;
body: string;
taskResults?: TaskResult[];
}
/** `WriteCareRecordResult` — the append result. */
export interface WriteVisitNoteResult {
id: number;
patientId: number;
recordedAt: string;
}
/**
* 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).
*/
export interface PatientRecordsApi {
/** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */
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). */
getRecordAccess(patientId: number): Promise<RecordAccess>;
/** REAL — 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. */
updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord>;
/** REAL — a nurse appends a visit note (append-only; never edits the record). */
createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { patientsApi } from '../apis';
import { patientKeys } from '../keys';
import { PATIENTS_STALE_TIME } from '../constants';
/**
* A single patient by id — the identity header for the E2 record viewer (name, age/gender, conditions).
* Keyed on `patientKeys.detail(id)` so it shares/warms the same cache the list primes. A missing/cross-tenant
* id `404`s (surfaced as the query error); the caller renders a not-found state. `enabled` lets the E2 viewer
* defer the fetch until the clinical-access check passes.
*/
export function usePatient(id: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: patientKeys.detail(id),
queryFn: () => patientsApi.get(id),
enabled: (options?.enabled ?? true) && id > 0,
staleTime: PATIENTS_STALE_TIME,
});
}
+1
View File
@@ -1,4 +1,5 @@
export { usePatients } from './hooks/usePatients';
export { usePatient } from './hooks/usePatient';
export { useCreatePatient } from './hooks/useCreatePatient';
export { useUpdatePatient } from './hooks/useUpdatePatient';
export { useArchivePatient } from './hooks/useArchivePatient';
@@ -0,0 +1,66 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { REVIEWS_PAGE_SIZE } from '../constants';
import type {
CreateReviewRequest,
MyReviewState,
NurseReviews,
ReviewEligibility,
ReviewListItem,
ReviewsApi,
SubmitReviewResult,
} from '../types';
const API = '/api/v1';
/** Wire `NurseReviewsResult` — `reviews` is a `PagedResult<ReviewListItemDto>` (camelCase, per api-conventions). */
interface NurseReviewsWire {
aggregate: { averageRating: number; publishedCount: number };
reviews: Paginated<ReviewListItem>;
}
/**
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes:
* - `getNurseReviews` → `GET nurses/{id}/reviews` (aggregate + published page; server filters to published).
* - `createReview` → `POST bookings/{id}/review` (the one review per completed booking; `409` if reviewed).
*
* The other two target contract gaps the frontend filed (**REQ-026**), which is why the domain stays
* mock-primary (see `constants.ts`):
* - `getReviewEligibility` → whether this booking can still be reviewed (no wire read; proposed slug below).
* - `getMyReviewForBooking` → the caller's own review + its moderation state (no wire read; proposed slug).
*
* NOT the primary implementation this phase (`USE_REVIEWS_MOCK = true`). `clientFetch` returns the raw
* envelope so we `unwrap()`; ids come from the route; list params are camelCase (`page`/`pageSize`).
*/
export const reviewsClientApi: ReviewsApi = {
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
const query = new URLSearchParams();
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE));
const wire = unwrap(
await clientFetch<ApiEnvelope<NurseReviewsWire>>(
`${API}/nurses/${nurseProfileId}/reviews?${query.toString()}`,
),
);
return { aggregate: wire.aggregate, reviews: wire.reviews };
},
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip.
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> =>
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)),
// REQ-026: proposed owner-scoped read of the caller's own review for this booking.
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> =>
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)),
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
unwrap(
await clientFetch<ApiEnvelope<SubmitReviewResult>>(`${API}/bookings/${bookingId}/review`, {
method: 'POST',
body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }),
}),
),
};
+11
View File
@@ -0,0 +1,11 @@
import { USE_REVIEWS_MOCK } from '../constants';
import type { ReviewsApi } from '../types';
import { reviewsClientApi } from './clientApi';
import { reviewsMockApi } from './mockApi';
/**
* The selected `ReviewsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_REVIEWS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-026 gaps +
* admin-only moderation).
*/
export const reviewsApi: ReviewsApi = USE_REVIEWS_MOCK ? reviewsMockApi : reviewsClientApi;
+181
View File
@@ -0,0 +1,181 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi';
import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants';
import type {
CreateReviewRequest,
ModerationStatus,
MyReviewState,
NurseReviews,
ReviewEligibility,
ReviewListItem,
ReviewsApi,
SubmitReviewResult,
} from '../types';
/**
* In-memory `ReviewsApi` — **the primary implementation this phase** (b14 serves submit + the public list,
* but review-eligibility and my-review-for-booking are REQ-026 gaps and moderation is admin-only/f15 — see
* `constants.ts`).
*
* It is engineered to demo the whole trust loop end-to-end:
* - **Published seed** — a per-nurse published-review list (nurse 1 has 7 so the profile tab paginates;
* nurses 5/6 have none → the empty state). The aggregate is recomputed **from the published list**, never
* stored, so hiding/publishing a review re-derives the average (the server's job, mirrored here).
* - **Eligibility** reads a single booking from the shared **f8 bookings store** (`mockGetBookingForReview`)
* — a booking is reviewable only when `completed`/`closed` AND not already reviewed (the 1:1 rule).
* - **Submission tracking** — `createReview` records the customer's review as `pending_moderation` (it does
* **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking`
* returns the persistent "under review" state.
* - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in for the deferred (f15) admin
* moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse
* profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen.
*
* Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a
* completed booking deep-links correctly.
*/
const MOCK_LATENCY_MS = 300;
/** A submitted review the mock is tracking (client-side stand-in for the missing my-review read, REQ-026). */
interface SubmittedReview {
id: number;
bookingId: number;
nurseProfileId: number;
rating: number;
body: string | null;
tagCodes: string[];
status: ModerationStatus;
createdAt: string;
}
/** ISO instant `days` in the past — seeded review timestamps (rendered Shamsi client-side). */
function isoDaysAgo(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString();
}
let nextReviewId = 9100;
// ── Published reviews per nurse (the public profile tab reads these) ──────────────────────────────────────
const PUBLISHED: Record<number, ReviewListItem[]> = {
1: [
{ id: 9001, rating: 5, body: 'بسیار دقیق و مهربان بود؛ سر وقت رسید و همه‌چیز را توضیح داد.', tagCodes: ['punctual', 'kind', 'professional'], createdAt: isoDaysAgo(3) },
{ id: 9002, rating: 5, body: 'مراقبت حرفه‌ای و تمیز. خیالمان راحت بود.', tagCodes: ['professional', 'clean'], createdAt: isoDaysAgo(9) },
{ id: 9003, rating: 4, body: 'ارتباط خوبی با بیمار برقرار کرد.', tagCodes: ['communicative', 'kind'], createdAt: isoDaysAgo(14) },
{ id: 9004, rating: 5, body: 'واقعاً منظم و قابل‌اعتماد.', tagCodes: ['punctual', 'professional'], createdAt: isoDaysAgo(21) },
{ id: 9005, rating: 4, body: null, tagCodes: ['clean'], createdAt: isoDaysAgo(28) },
{ id: 9006, rating: 5, body: 'از پرستاری‌اش بسیار راضی بودیم.', tagCodes: ['kind', 'communicative'], createdAt: isoDaysAgo(35) },
{ id: 9007, rating: 3, body: 'خوب بود ولی کمی دیر رسید.', tagCodes: ['professional'], createdAt: isoDaysAgo(44) },
],
2: [
{ id: 9021, rating: 5, body: 'با حوصله و مسلط.', tagCodes: ['professional', 'kind'], createdAt: isoDaysAgo(6) },
{ id: 9022, rating: 4, body: 'مراقبت خوبی داشت.', tagCodes: ['communicative'], createdAt: isoDaysAgo(18) },
],
3: [{ id: 9031, rating: 5, body: 'عالی بود، پیشنهاد می‌کنم.', tagCodes: ['punctual', 'clean', 'kind'], createdAt: isoDaysAgo(11) }],
4: [
{ id: 9041, rating: 4, body: 'قابل اعتماد و آرام.', tagCodes: ['kind'], createdAt: isoDaysAgo(4) },
{ id: 9042, rating: 5, body: 'خیلی حرفه‌ای برخورد کرد.', tagCodes: ['professional', 'communicative'], createdAt: isoDaysAgo(20) },
],
// nurses 5 & 6: no published reviews → the empty state on their profile tab.
};
const submissions = new Map<number, SubmittedReview>();
/** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */
function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } {
const list = PUBLISHED[nurseProfileId] ?? [];
if (list.length === 0) return { averageRating: 0, publishedCount: 0 };
const sum = list.reduce((acc, r) => acc + r.rating, 0);
return { averageRating: Math.round((sum / list.length) * 100) / 100, publishedCount: list.length };
}
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? all.length);
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
/** Is this booking (from the shared f8 store) in a review-eligible terminal state? */
function isReviewableStatus(status: string): boolean {
return status === 'completed' || status === 'closed';
}
export const reviewsMockApi: ReviewsApi = {
getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise<NurseReviews> => {
await sleep(MOCK_LATENCY_MS);
// Newest-first, published only — the mock never returns a submission (it is pending_moderation).
const list = [...(PUBLISHED[nurseProfileId] ?? [])].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
return { aggregate: aggregateFor(nurseProfileId), reviews: paginate(list, params) };
},
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
await sleep(MOCK_LATENCY_MS);
if (submissions.has(bookingId)) return { canReview: false, reason: 'already_reviewed' };
let booking;
try {
booking = mockGetBookingForReview(bookingId);
} catch {
return { canReview: false, reason: 'not_found' };
}
if (!isReviewableStatus(booking.status)) return { canReview: false, reason: 'not_completed' };
return { canReview: true };
},
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => {
await sleep(MOCK_LATENCY_MS);
const sub = submissions.get(bookingId);
if (!sub) return { status: 'none', rating: null, body: null, tagCodes: [], createdAt: null };
return { status: sub.status, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt };
},
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> => {
await sleep(MOCK_LATENCY_MS);
if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) {
throw new ApiError(400, 'Rating must be 15', 'rating_out_of_range');
}
// 1:1 — a second review for the same booking is a 409 (contract).
if (submissions.has(bookingId)) throw new ApiError(409, 'Booking already reviewed', 'already_reviewed');
// Reviews are only for completed/closed bookings — read the shared f8 store (404 → not found).
const booking = mockGetBookingForReview(bookingId);
if (!isReviewableStatus(booking.status)) {
throw new ApiError(409, 'Booking is not completed', 'booking_not_completed');
}
const id = nextReviewId++;
const submission: SubmittedReview = {
id,
bookingId,
nurseProfileId: booking.nurseId,
rating: body.rating,
body: body.body?.trim() || null,
tagCodes: body.tagCodes ?? [],
// The AI pre-screen keeps clean text pending; it is NOT public until an admin publishes it (f15).
status: 'pending_moderation',
createdAt: new Date().toISOString(),
};
submissions.set(bookingId, submission);
return {
id,
moderationStatus: 'pending_moderation',
lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT,
};
},
};
/**
* DEV-ONLY: publish a submitted review, standing in for the deferred (f15) admin moderation queue. Moves the
* customer's `pending_moderation` submission to `published` and prepends it to the nurse's public list so a
* human can watch it appear on the profile (aggregate + count updating on the next fetch). Not wired into any
* customer/nurse screen — call it from the console (or a later admin surface). No-op if unknown.
*/
export function __mockPublishSubmittedReview(bookingId: number): void {
const sub = submissions.get(bookingId);
if (!sub) return;
sub.status = 'published';
const list = PUBLISHED[sub.nurseProfileId] ?? (PUBLISHED[sub.nurseProfileId] = []);
list.unshift({ id: sub.id, rating: sub.rating, body: sub.body, tagCodes: sub.tagCodes, createdAt: sub.createdAt });
}
+33
View File
@@ -0,0 +1,33 @@
/**
* When true, the reviews domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `ReviewsApi`
* seam.
*
* **Mock is primary this phase.** b14 serves the review **submit**, the public **nurse reviews** page, and
* the tag rollup — but there is **no** review-eligibility read and **no** my-review-for-booking read
* (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is admin-only (f15).
* The mock reads the shared **f8 bookings store** to gate eligibility on a completed booking, tracks the
* customer's submission so the "under review" state persists, seeds a small published-review list per nurse
* for the profile tab, and exposes a dev-only `__mockPublishSubmittedReview` so a human can watch a submitted
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
*/
export const USE_REVIEWS_MOCK = true;
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
export const REVIEWS_PAGE_SIZE = 5;
/**
* The published-review list moves slowly (a moderation transition is a rare admin action) — a generous
* `staleTime` means revisiting a profile never needlessly refetches. Eligibility/my-review are per-booking
* and are **invalidated on submit**, so their staleness is short (a submit must flip the CTA immediately).
*/
export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000;
export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000;
export const REVIEWS_GC_TIME = 10 * 60 * 1000;
/**
* The low-rating support-alert threshold (server config, default ≤ 2). The mock echoes it on
* `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** — it exists only so the mock's
* result shape matches the wire.
*/
export const MIN_RATING_FOR_SUPPORT_ALERT = 2;
@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import type { CreateReviewRequest, SubmitReviewResult } from '../types';
interface CreateReviewVars {
bookingId: number;
body: CreateReviewRequest;
}
/**
* Submit the one review for a completed booking. On success we invalidate **only** the booking's
* `eligibility` + `myReviewForBooking` keys — so the CTA flips to the "under review" state at once — and
* deliberately do **NOT** touch any public nurse-reviews list or aggregate: the new review is
* `pending_moderation` and must never appear publicly until an admin publishes it. Domain 4xx (`409`
* already-reviewed, `400` bad rating) surface to the caller's `onError`, which keeps the draft.
*/
export function useCreateReview() {
const queryClient = useQueryClient();
return useMutation<SubmitReviewResult, unknown, CreateReviewVars>({
mutationFn: ({ bookingId, body }) => reviewsApi.createReview(bookingId, body),
onSuccess: (_result, { bookingId }) => {
queryClient.invalidateQueries({ queryKey: reviewKeys.eligibility(bookingId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.myReviewForBooking(bookingId) });
},
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
/**
* The customer's **own** review for this booking + its current moderation state (`none` when not yet
* reviewed). Drives the persistent "under review" / "published" state on the leave-a-review CTA so a returning
* customer never sees a second form. Keyed per booking; **invalidated on submit**.
*/
export function useMyReviewForBooking(bookingId: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: reviewKeys.myReviewForBooking(bookingId),
queryFn: () => reviewsApi.getMyReviewForBooking(bookingId),
enabled: (options?.enabled ?? true) && bookingId > 0,
staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
gcTime: REVIEWS_GC_TIME,
});
}
@@ -0,0 +1,27 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import { NURSE_REVIEWS_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants';
/**
* The nurse's **public** reviews list — the rating aggregate + an infinite, load-more list of **published**
* reviews. Infinite paging keeps every loaded page in one cache entry keyed on the nurse id, so revisiting a
* profile never refetches and "load more" appends without a setState-in-effect. The aggregate rides on every
* page (same value) — read it off the first page. Never returns/renders non-published content (server
* invariant); a submitted `pending_moderation` review is **never** injected here.
*/
export function useNurseReviews(nurseProfileId: number | undefined) {
return useInfiniteQuery({
queryKey: reviewKeys.nurse(nurseProfileId ?? -1),
queryFn: ({ pageParam }) =>
reviewsApi.getNurseReviews(nurseProfileId as number, { page: pageParam, pageSize: REVIEWS_PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const { page, pageSize, total } = lastPage.reviews;
return page * pageSize < total ? page + 1 : undefined;
},
enabled: nurseProfileId != null,
staleTime: NURSE_REVIEWS_STALE_TIME,
gcTime: REVIEWS_GC_TIME,
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { reviewsApi } from '../apis';
import { reviewKeys } from '../keys';
import { REVIEW_ELIGIBILITY_STALE_TIME, REVIEWS_GC_TIME } from '../constants';
/**
* Whether *this* booking can still be reviewed (completed/closed AND not already reviewed). Keyed per booking;
* short `staleTime` and **invalidated on submit** so the leave-a-review CTA flips to the "under review" state
* immediately. `enabled` lets the caller defer until it holds a real booking id.
*/
export function useReviewEligibility(bookingId: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: reviewKeys.eligibility(bookingId),
queryFn: () => reviewsApi.getReviewEligibility(bookingId),
enabled: (options?.enabled ?? true) && bookingId > 0,
staleTime: REVIEW_ELIGIBILITY_STALE_TIME,
gcTime: REVIEWS_GC_TIME,
});
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Reviews domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
* types/keys/apis directly from their files when needed.
*/
export { useNurseReviews } from './hooks/useNurseReviews';
export { useReviewEligibility } from './hooks/useReviewEligibility';
export { useMyReviewForBooking } from './hooks/useMyReviewForBooking';
export { useCreateReview } from './hooks/useCreateReview';
+18
View File
@@ -0,0 +1,18 @@
/**
* React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern).
*
* The **nurse id + page** key the public list so paging/revisiting a profile never refetches data already in
* cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state
* independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the
* booking — the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public).
*/
export const reviewKeys = {
all: ['reviews'] as const,
nurseLists: () => [...reviewKeys.all, 'nurse'] as const,
/** The public reviews list for a nurse. Pages are managed by `useInfiniteQuery`, so no page in the key. */
nurse: (nurseProfileId: number) => [...reviewKeys.nurseLists(), nurseProfileId] as const,
eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const,
myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const,
};
+119
View File
@@ -0,0 +1,119 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Reviews domain — the **moderated trust signal** (b14). A customer leaves **one review per completed
* booking**; it is born `pending_moderation` and is **never public / never counted** until an admin (or the
* AI pre-screen) publishes it; the public read only ever returns `published` reviews + the recomputed
* aggregate. Shapes are derived from the b14 contract (`dev/contracts/domains/reviews-records.md` +
* `dev/contracts/openapi/swagger.v1.json`), mirroring the wire exactly.
*
* **What the contract serves (real):** submit a review (`POST bookings/{id}/review`), the public nurse
* reviews page (`GET nurses/{id}/reviews` — aggregate + published page), the per-nurse tag rollup, and the
* admin moderation transition (admin-only → f15). **What it does NOT serve (client gaps, REQ-026):** a
* **review-eligibility** read (whether *this* booking can still be reviewed) and a **my-review-for-booking**
* read (the customer's own submitted review + its moderation state, so the CTA can show the persistent
* "under review" state). Those two are derived/mocked behind this seam and the domain is **mock-primary**
* (see `constants.ts`); when REQ-026 lands only `apis/clientApi.ts`'s two gap methods flip.
*
* Load-bearing rules (contract + phase §5):
* - **Never render `pending_moderation`/`hidden`/`rejected` publicly.** `getNurseReviews` returns published
* only (server-filtered); after a submit we show a **local** "under review" state and **never**
* optimistically inject the new review into any public list or aggregate.
* - **1:1** — one review per booking; a second submit is a `409`.
* - Review-eligible = the booking is **completed/closed** AND not already reviewed.
* - Tag chip labels are **i18n keys keyed off the code**, never a label off the wire.
*
* Enums cross the wire as stable string codes — mirrored here as string-literal unions.
*/
/** `moderationStatus` (contract enum). Born `pending_moderation`; only `published` is ever public/counted. */
export type ModerationStatus = 'pending_moderation' | 'published' | 'hidden' | 'rejected';
/**
* The seeded review-tag vocabulary (contract "Review tag codes"). The chip **label** is the client's i18n
* key (`reviews.tag_{code}`), never a label off the wire.
*/
export const REVIEW_TAG_CODES = ['punctual', 'professional', 'clean', 'kind', 'communicative'] as const;
export type ReviewTagCode = (typeof REVIEW_TAG_CODES)[number];
/** A public review row (`ReviewListItemDto`) — **never** carries moderation internals or a customer name. */
export interface ReviewListItem {
id: number;
/** 15. */
rating: number;
body: string | null;
tagCodes: string[];
/** UTC ISO-8601 — Shamsi display is the client's job. */
createdAt: string;
}
/** The nurse rating aggregate (`NurseReviewAggregateDto`) — computed **from published reviews only**, server-side. */
export interface NurseReviewAggregate {
/** 2-dp decimal (e.g. `4.5`). */
averageRating: number;
publishedCount: number;
}
/** `NurseReviewsResult` — the public reviews page for a nurse: the aggregate + a page of published reviews. */
export interface NurseReviews {
aggregate: NurseReviewAggregate;
reviews: Paginated<ReviewListItem>;
}
/** The submit body (`SubmitReviewBody`; the booking id comes from the route). */
export interface CreateReviewRequest {
/** 15, required. */
rating: number;
/** ≤ 2000, optional. */
body?: string | null;
/** Validated against the active vocabulary; optional. */
tagCodes?: string[];
}
/** `SubmitReviewResult`. `moderationStatus` is `pending_moderation` by default (clean text stays pending). */
export interface SubmitReviewResult {
id: number;
moderationStatus: ModerationStatus;
/** Internal (rating ≤ threshold raised a support alert) — never surfaced to the user; we ignore it in the UI. */
lowRatingAlertRaised: boolean;
}
/** Why a booking cannot be reviewed (client-derived; drives the not-eligible copy). */
export type ReviewIneligibilityReason = 'not_completed' | 'already_reviewed' | 'not_owner' | 'not_found';
/**
* Whether *this* booking can still be reviewed (**REQ-026 — no wire endpoint**). Derived from the booking
* status (completed/closed) + the 1:1 rule. Mocked behind the seam; the real client targets a proposed slug.
*/
export interface ReviewEligibility {
canReview: boolean;
reason?: ReviewIneligibilityReason;
}
/**
* The customer's **own** review for a booking + its current moderation state (**REQ-026 — no wire
* endpoint**). `status: 'none'` = not yet reviewed. Lets the CTA render the persistent "under review" /
* "published" state across sessions without leaking anything public.
*/
export interface MyReviewState {
status: ModerationStatus | 'none';
rating: number | null;
body: string | null;
tagCodes: string[];
createdAt: string | null;
}
/**
* The reviews API seam — the real HTTP client and the in-memory mock both implement this; selection is by
* config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks.
*/
export interface ReviewsApi {
/** Public — published reviews + aggregate for a nurse (server filters to published). */
getNurseReviews(nurseProfileId: number, params: PageParams): Promise<NurseReviews>;
/** REQ-026 — can this booking still be reviewed? */
getReviewEligibility(bookingId: number): Promise<ReviewEligibility>;
/** REQ-026 — the customer's own review for this booking + its moderation state. */
getMyReviewForBooking(bookingId: number): Promise<MyReviewState>;
/** Submit the one review for a completed booking (`409` if already reviewed). */
createReview(bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult>;
}