frontend phase 2: onboarding & profiles — customer/patient, nurse profile & bank

Turns a logged-in user into a usable account, consuming the b3 identity-profiles
contract behind the services/{domain} seam.

Services (mock default true; real HTTP clients wired for a one-line flip):
- services/patients: rewritten to b3 PatientDto + client-augmented relation/conditions;
  full CRUD, optimistic soft-archive, cache-splice on create, age<->birthDate helper.
- services/profiles: customer + nurse profile get/upsert + avatar (404->null mapping).
- services/nurse: payout bank accounts + IBAN(Sheba) util + pending-only polling.

Screens: A3->A4 onboarding wizard, E1 patients list/CRUD, A5 home (first-login gate +
nudge), customer profile (no national-ID), nurse profile bootstrap (unverified
placeholder), nurse bank settings (pending/verified/mismatch + make-primary).

Shared composites (each tested): GenderToggle, ConditionChips, RelationSelect,
PatientForm, PatientCard, BankStatusPanel; reuses f0 StepperHeader/StatusChip/PhoneField.
Adds onboarding/home/profile/nurseProfile/bank i18n namespaces (both locales, in sync),
the --bal-primary-soft token, and nurse sidebar Profile + Bank entries.

Contract gaps filed: REQ-005 (patient relation/conditions), REQ-006 (avatar route),
REQ-007 (customer name/language). Gate: check + 112 tests + build all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:04:38 +03:30
parent 82561c4cc6
commit 4b4243c451
70 changed files with 3111 additions and 190 deletions
@@ -0,0 +1,32 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { normalizeSheba, shebaBankName } from '../iban';
import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types';
const BASE = '/api/v1/nurse_bank_accounts';
/**
* Real HTTP implementation of the NurseBankAccountsApi seam (b3 action-style routes). `add`
* runs the ownership inquiry server-side and returns the account with `matchedNationalId`
* already set, so the real path needs no polling. Selected once USE_NURSE_BANK_MOCK is false.
*/
export const nurseBankClientApi: NurseBankAccountsApi = {
list: async () => unwrap(await clientFetch<ApiEnvelope<NurseBankAccountDto[]>>(`${BASE}/list`)),
add: async (input: AddBankAccountInput) => {
const iban = normalizeSheba(input.iban);
return unwrap(
await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/add`, {
method: 'POST',
body: JSON.stringify({ bankName: shebaBankName(iban), accountHolderName: input.accountHolderName, iban }),
}),
);
},
setPrimary: async (id: number) => {
await clientFetch<ApiEnvelope<void>>(`${BASE}/set_primary/${id}`, { method: 'POST' });
},
verifyOwnership: async (id: number) =>
unwrap(await clientFetch<ApiEnvelope<NurseBankAccountDto>>(`${BASE}/verify_ownership/${id}`, { method: 'POST' })),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_NURSE_BANK_MOCK } from '../constants';
import type { NurseBankAccountsApi } from '../types';
import { nurseBankClientApi } from './clientApi';
import { nurseBankMockApi } from './mockApi';
/**
* The selected NurseBankAccountsApi implementation — the single seam hooks import. Selection
* is by config (USE_NURSE_BANK_MOCK), never by scattered `if (mock)` checks.
*/
export const nurseBankApi: NurseBankAccountsApi = USE_NURSE_BANK_MOCK ? nurseBankMockApi : nurseBankClientApi;
+81
View File
@@ -0,0 +1,81 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { KNOWN_MISMATCH_IBAN } from '../constants';
import { normalizeSheba, shebaBankName } from '../iban';
import type { AddBankAccountInput, NurseBankAccountDto, NurseBankAccountsApi } from '../types';
const MOCK_LATENCY_MS = 400;
// The number of list reads the inquiry stays pending before resolving. 2 lets the pending
// panel show on the invalidation-triggered read, then flip on the next poll — so the
// pending→verified/mismatch transition is visible without a manual reload.
const POLLS_BEFORE_RESOLVE = 2;
interface StoredAccount {
dto: NurseBankAccountDto;
iban: string; // normalized full value — mock-only; the real value is encrypted server-side
pollsLeft: number;
}
let store: StoredAccount[] = [];
let nextId = 1;
function maskIban(normalized: string): string {
return `••••${normalized.slice(-4)}`;
}
// Deterministic fake استعلام شبا: every IBAN matches except the configured mismatch IBAN.
function resolveIfDue(entry: StoredAccount): void {
if (entry.dto.matchedNationalId !== null || entry.pollsLeft <= 0) return;
entry.pollsLeft -= 1;
if (entry.pollsLeft > 0) return;
const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN);
entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched };
}
/**
* In-memory mock behind the NurseBankAccountsApi seam. Drives the pending→verified/mismatch
* transition and single-primary enforcement so all three UI states are demonstrable. Mirrors
* the real shapes (masked IBAN, `matchedNationalId` gate) for a one-line swap.
*/
export const nurseBankMockApi: NurseBankAccountsApi = {
list: async (): Promise<NurseBankAccountDto[]> => {
await sleep(MOCK_LATENCY_MS);
store.forEach(resolveIfDue);
return store.map((entry) => entry.dto);
},
add: async (input: AddBankAccountInput): Promise<NurseBankAccountDto> => {
await sleep(MOCK_LATENCY_MS);
const normalized = normalizeSheba(input.iban);
if (store.some((entry) => entry.iban === normalized)) {
throw new ApiError(400, 'Duplicate IBAN', 'iban_duplicate');
}
const dto: NurseBankAccountDto = {
id: nextId++,
bankName: shebaBankName(normalized),
ibanMasked: maskIban(normalized),
isPrimary: store.length === 0,
isVerified: false,
matchedNationalId: null,
};
store = [...store, { dto, iban: normalized, pollsLeft: POLLS_BEFORE_RESOLVE }];
return dto;
},
setPrimary: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
if (!store.some((entry) => entry.dto.id === id)) throw new ApiError(404, 'Account not found');
store = store.map((entry) => ({ ...entry, dto: { ...entry.dto, isPrimary: entry.dto.id === id } }));
},
verifyOwnership: async (id: number): Promise<NurseBankAccountDto> => {
await sleep(MOCK_LATENCY_MS);
const entry = store.find((item) => item.dto.id === id);
if (!entry) throw new ApiError(404, 'Account not found');
const matched = normalizeSheba(entry.iban) !== normalizeSheba(KNOWN_MISMATCH_IBAN);
entry.dto = { ...entry.dto, matchedNationalId: matched, isVerified: matched };
entry.pollsLeft = 0;
return entry.dto;
},
};
+20
View File
@@ -0,0 +1,20 @@
/**
* When true, the nurse bank-account domain is served by the in-memory mock behind the
* NurseBankAccountsApi seam. The b3 endpoints are live, but the استعلام شبا ownership
* inquiry is itself backend-mocked (`IBankAccountOwnershipVerifier`), so this phase drives
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
* the real endpoints — no hook/component changes (mocks-registry.md).
*/
export const USE_NURSE_BANK_MOCK = true;
/** Bank accounts change rarely; keep them warm across screen visits. */
export const BANK_STALE_TIME = 30_000;
/** Poll interval (ms) used only while an account's ownership inquiry is pending. */
export const BANK_POLL_INTERVAL_MS = 2_000;
/**
* The IBAN that the (mock) ownership inquiry resolves to a mismatch, so the mismatch UI
* state is demonstrable end-to-end. Mirrors the backend default `Seams:BankOwnership:MismatchIban`.
*/
export const KNOWN_MISMATCH_IBAN = 'IR000000000000000000000000';
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nurseBankApi } from '../apis';
import { bankKeys } from '../keys';
import type { AddBankAccountInput } from '../types';
/**
* Submits an IBAN + account-holder name; the server kicks off the ownership inquiry and
* returns the account (pending in the mock, resolved on the real path). Invalidates the list
* so the pending state — and its later transition — surfaces on the next read/poll. Domain
* 400s (invalid/duplicate IBAN, no nurse profile) surface via `mutation.error`.
*/
export function useAddNurseBankAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AddBankAccountInput) => nurseBankApi.add(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bankKeys.list() });
},
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { nurseBankApi } from '../apis';
import { bankKeys } from '../keys';
import { BANK_POLL_INTERVAL_MS, BANK_STALE_TIME } from '../constants';
import { deriveBankStatus, type NurseBankAccountDto } from '../types';
/**
* The nurse's bank accounts (usually one primary). Polls **only while an account's ownership
* inquiry is pending** so the pending→verified/mismatch transition appears without a manual
* reload, then stops once every account has resolved.
*/
export function useNurseBankAccounts() {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: bankKeys.list(),
queryFn: () => nurseBankApi.list(),
enabled: isAuthenticated,
staleTime: BANK_STALE_TIME,
refetchInterval: (query) => {
const accounts = (query.state.data ?? []) as NurseBankAccountDto[];
return accounts.some((account) => deriveBankStatus(account) === 'pending') ? BANK_POLL_INTERVAL_MS : false;
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { nurseBankApi } from '../apis';
import { bankKeys } from '../keys';
/**
* Makes an account the payout primary; single-primary enforcement is server-side (the prior
* primary is cleared atomically). Invalidates the list so the cache reflects the switch.
*/
export function useSetPrimaryBankAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => nurseBankApi.setPrimary(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: bankKeys.list() });
},
});
}
+48
View File
@@ -0,0 +1,48 @@
import { digitsOnly } from '@/utils';
/** An Iranian IBAN (شبا) is `IR` + 24 digits. */
export const SHEBA_DIGIT_COUNT = 24;
/**
* Normalizes user input to canonical `IR`+24-digit form: uppercases, strips spaces, drops a
* leading `IR`, keeps ASCII digits (Persian/Arabic normalized), caps at 24. Partial input
* yields fewer digits (so `isValidSheba` still fails).
*/
export function normalizeSheba(input: string): string {
const raw = input.trim().toUpperCase().replace(/\s+/g, '');
const withoutPrefix = raw.startsWith('IR') ? raw.slice(2) : raw;
return `IR${digitsOnly(withoutPrefix).slice(0, SHEBA_DIGIT_COUNT)}`;
}
/** True when the input is a well-formed Sheba (`IR` + exactly 24 digits) after normalization. */
export function isValidSheba(input: string): boolean {
return /^IR\d{24}$/.test(normalizeSheba(input));
}
// Bank identifier = the 3 digits after the 2 check digits (BBAN prefix). Reference data used
// to populate the add-body `bankName`; the returned DTO's bankName is authoritative for display.
const BANK_NAMES: Record<string, string> = {
'011': 'بانک صنعت و معدن',
'012': 'بانک ملت',
'013': 'بانک رفاه کارگران',
'014': 'بانک مسکن',
'015': 'بانک سپه',
'016': 'بانک کشاورزی',
'017': 'بانک ملی ایران',
'018': 'بانک تجارت',
'019': 'بانک صادرات ایران',
'021': 'پست بانک ایران',
'053': 'بانک کارآفرین',
'054': 'بانک پارسیان',
'055': 'بانک اقتصاد نوین',
'057': 'بانک پاسارگاد',
'062': 'بانک آینده',
};
/** Best-effort bank name from the IBAN's 3-digit bank code; empty string when unknown. */
export function shebaBankName(input: string): string {
const normalized = normalizeSheba(input);
if (!/^IR\d{24}$/.test(normalized)) return '';
const bankCode = normalized.slice(4, 7);
return BANK_NAMES[bankCode] ?? '';
}
+3
View File
@@ -0,0 +1,3 @@
export { useNurseBankAccounts } from './hooks/useNurseBankAccounts';
export { useAddNurseBankAccount } from './hooks/useAddNurseBankAccount';
export { useSetPrimaryBankAccount } from './hooks/useSetPrimaryBankAccount';
+5
View File
@@ -0,0 +1,5 @@
/** React Query key factory for the nurse bank-account domain. */
export const bankKeys = {
all: ['nurse-bank-accounts'] as const,
list: () => [...bankKeys.all, 'list'] as const,
};
+42
View File
@@ -0,0 +1,42 @@
/**
* Nurse payout bank-account sub-domain (kept separate from the profile because
* verification/payouts read it independently). Shapes mirror the b3 contract
* (`dev/contracts/domains/identity-profiles.md` → `NurseBankAccountDto`). The full IBAN is
* never returned — the DTO carries `ibanMasked` (last-4 only).
*/
/** `NurseBankAccountDto`. `matchedNationalId` is null until the ownership inquiry runs. */
export interface NurseBankAccountDto {
id: number;
bankName: string;
/** Last-4 only, e.g. `••••3456`. */
ibanMasked: string;
isPrimary: boolean;
isVerified: boolean;
matchedNationalId: boolean | null;
}
/** The form input — bankName is derived from the IBAN in the API impl (see iban.ts). */
export interface AddBankAccountInput {
iban: string;
accountHolderName: string;
}
/**
* The three ownership-inquiry UI states. `matchedNationalId` is the gating field:
* null → pending, false → mismatch, true → verified (the b13 first-payout gate).
*/
export type BankAccountStatus = 'pending' | 'verified' | 'mismatch';
export function deriveBankStatus(account: Pick<NurseBankAccountDto, 'matchedNationalId'>): BankAccountStatus {
if (account.matchedNationalId == null) return 'pending';
return account.matchedNationalId ? 'verified' : 'mismatch';
}
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface NurseBankAccountsApi {
list(): Promise<NurseBankAccountDto[]>;
add(input: AddBankAccountInput): Promise<NurseBankAccountDto>;
setPrimary(id: number): Promise<void>;
verifyOwnership(id: number): Promise<NurseBankAccountDto>;
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Age ↔ birth-date helpers. The A4 form collects a whole-year **age** (per the wireframe)
* while the contract stores a `birthDate` (`YYYY-MM-DD`) — we map between them here. Birth
* date is approximated as 1 January of the birth year; that round-trips back to the same age.
*/
/** Approximate ISO birth date (`YYYY-MM-01-01`) for a whole-year age. */
export function ageToBirthDate(age: number, now: Date = new Date()): string {
const year = now.getUTCFullYear() - Math.max(0, Math.floor(age));
return `${year}-01-01`;
}
/** Whole-year age from an ISO birth date (floored); null for an empty/invalid date. */
export function birthDateToAge(birthDate: string | null | undefined, now: Date = new Date()): number | null {
if (!birthDate) return null;
const date = new Date(birthDate);
if (Number.isNaN(date.getTime())) return null;
let age = now.getUTCFullYear() - date.getUTCFullYear();
const monthDelta = now.getUTCMonth() - date.getUTCMonth();
if (monthDelta < 0 || (monthDelta === 0 && now.getUTCDate() < date.getUTCDate())) age -= 1;
return age < 0 ? null : age;
}
+55 -14
View File
@@ -1,29 +1,70 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types';
const BASE = '/patients';
const BASE = '/api/v1/patients';
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes
// echo the caller's choice onto the returned row so the just-edited card reflects it (not
// yet persisted server-side).
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient {
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
}
// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005).
function toBody(input: CreatePatientInput) {
const { displayName, firstName, lastName, birthDate, gender } = input;
return {
displayName,
firstName,
lastName,
birthDate,
gender,
bloodType: input.bloodType ?? null,
initialMedicalNotes: input.initialMedicalNotes ?? null,
};
}
/**
* Real HTTP implementation of the PatientsApi seam. Wired to `clientFetch`, which
* returns the raw server envelope so each call reads the payload via `unwrap`.
* Not selected until USE_PATIENTS_MOCK is false and the endpoints exist.
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once
* USE_PATIENTS_MOCK is false and the relation/conditions fields land.
*/
export const patientsClientApi: PatientsApi = {
list: async (params) => {
const query = new URLSearchParams();
if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('page_size', String(params.pageSize));
if (params?.pageSize) query.set('pageSize', String(params.pageSize));
const qs = query.toString();
const env = await clientFetch<ApiEnvelope<Paginated<Patient>>>(`${BASE}${qs ? `?${qs}` : ''}`);
return unwrap(env);
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
return { ...page, items: page.items.map((dto) => toPatient(dto)) };
},
create: async (dto: CreatePatientDto) => {
const env = await clientFetch<ApiEnvelope<Patient>>(BASE, {
method: 'POST',
body: JSON.stringify(dto),
});
return unwrap(env);
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))),
create: async (input) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input,
),
update: async (id, input) =>
toPatient(
unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, {
method: 'POST',
body: JSON.stringify(toBody(input)),
}),
),
input,
),
archive: async (id) => {
await clientFetch<ApiEnvelope<void>>(`${BASE}/archive/${id}`, { method: 'POST' });
},
};
+55 -28
View File
@@ -1,45 +1,72 @@
import { sleep } from '@/utils';
import type { Paginated } from '@/lib/api/types';
import type { CreatePatientDto, Patient, PatientsApi } from '../types';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import type { CreatePatientInput, Patient, PatientsApi } from '../types';
const MOCK_LATENCY_MS = 400;
const MOCK_LATENCY_MS = 350;
// In-memory store. Seed timestamps are static strings (not Date.now) so repeated
// renders are stable; `create` stamps a real ISO time on the client.
let store: Patient[] = [
{ id: 2, fullName: 'زهرا محمدی', gender: 'female', createdAtUtc: '2026-05-12T08:30:00Z' },
{ id: 1, fullName: 'علی رضایی', gender: 'male', createdAtUtc: '2026-04-03T11:15:00Z' },
];
let nextId = 3;
// In-memory store, seeded **empty** so a single session can demo both the onboarding flow
// (A3→A4 creates the first patient) and the E1 empty state. Archive is soft (isActive=false)
// and never removes the row — the list simply hides inactive patients.
let store: Patient[] = [];
let nextId = 1;
function build(id: number, input: CreatePatientInput, isActive: boolean): Patient {
return {
id,
displayName: input.displayName,
firstName: input.firstName,
lastName: input.lastName,
birthDate: input.birthDate,
gender: input.gender,
bloodType: input.bloodType ?? null,
initialMedicalNotes: input.initialMedicalNotes ?? null,
isActive,
relation: input.relation,
conditions: input.conditions,
};
}
/**
* In-memory mock behind the PatientsApi seam — the template f1+ follow until the real
* `/patients` endpoints are merged. Mirrors the real shapes so swapping is a one-line
* change in constants.ts.
* In-memory mock behind the PatientsApi seam — the b3 endpoints are live but the wire shape
* lacks relation/conditions (REQ-005), so this drives the UI until those land. Mirrors the
* real shapes so swapping is a one-line change in constants.ts.
*/
export const patientsMockApi: PatientsApi = {
list: async (params): Promise<Paginated<Patient>> => {
list: async (params?: PageParams): Promise<Paginated<Patient>> => {
await sleep(MOCK_LATENCY_MS);
const active = store.filter((patient) => patient.isActive);
const page = params?.page ?? 1;
const pageSize = params?.pageSize ?? 20;
const pageSize = params?.pageSize ?? 50;
const start = (page - 1) * pageSize;
return {
items: store.slice(start, start + pageSize),
total: store.length,
page,
pageSize,
};
return { items: active.slice(start, start + pageSize), total: active.length, page, pageSize };
},
create: async (dto: CreatePatientDto): Promise<Patient> => {
get: async (id: number): Promise<Patient> => {
await sleep(MOCK_LATENCY_MS);
const patient: Patient = {
id: nextId++,
fullName: dto.fullName,
gender: dto.gender,
createdAtUtc: new Date().toISOString(),
};
const found = store.find((patient) => patient.id === id && patient.isActive);
if (!found) throw new ApiError(404, 'Patient not found');
return found;
},
create: async (input: CreatePatientInput): Promise<Patient> => {
await sleep(MOCK_LATENCY_MS);
const patient = build(nextId++, input, true);
store = [patient, ...store];
return patient;
},
update: async (id: number, input: CreatePatientInput): Promise<Patient> => {
await sleep(MOCK_LATENCY_MS);
const existing = store.find((patient) => patient.id === id);
if (!existing) throw new ApiError(404, 'Patient not found');
const updated = build(id, input, existing.isActive);
store = store.map((patient) => (patient.id === id ? updated : patient));
return updated;
},
archive: async (id: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
store = store.map((patient) => (patient.id === id ? { ...patient, isActive: false } : patient));
},
};
+20 -2
View File
@@ -1,8 +1,26 @@
/**
* When true, the domain is served by the in-memory mock (apis/mockApi.ts) behind the
* PatientsApi seam. Flip to false once the real `/patients` endpoints land — no hook or
* component changes are needed (see dev/shared-working-context/reports/mocks-registry.md).
* PatientsApi seam. The b3 `patients/*` endpoints are live, but the wire `PatientDto`
* has no `relation`/`conditions` yet (filed as REQ-005), so this phase demos behind the
* mock. Flip to false once those fields land — no hook/component changes are needed
* (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_PATIENTS_MOCK = true;
export const PATIENTS_STALE_TIME = 60_000;
/** api-conventions default page size; `pageSize` ≤ 100. */
export const PATIENTS_PAGE_SIZE = 50;
/**
* Relation of the care recipient to the signed-in customer (payer ≠ patient). A stable
* enum code, i18n-labelled — never a hardcoded Persian string in logic. Client-augmented:
* not on the wire `PatientDto` yet (REQ-005); carried on create and stored by the mock.
*/
export const RELATION_CODES = ['parent', 'spouse', 'child', 'self'] as const;
/**
* Common patient conditions surfaced as multi-select chips (A4). Client-augmented (REQ-005);
* carried on create and stored by the mock. Codes are stable; labels are i18n keys.
*/
export const CONDITION_CODES = ['elderly', 'post_surgery', 'diabetes', 'mobility', 'dementia'] as const;
@@ -1,20 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { patientsApi } from '../apis';
import { patientKeys } from '../keys';
import type { CreatePatientDto } from '../types';
/**
* Creates a patient and invalidates every patients list so the cache reflects the new
* row without a manual refetch. (setQueryData would also work when the API returns the
* full new list item and pagination is trivial — invalidation is the safe default.)
*/
export function useAddPatient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreatePatientDto) => patientsApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
},
});
}
@@ -0,0 +1,37 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { Paginated } from '@/lib/api/types';
import { patientsApi } from '../apis';
import { patientKeys } from '../keys';
import type { Patient } from '../types';
/**
* Soft-archives a patient (`isActive=false`, never a hard delete — historical bookings must
* survive). Optimistically removes the card from every cached list, then reconciles on
* settle; on error the previous cache is restored.
*/
export function useArchivePatient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => patientsApi.archive(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: patientKeys.lists() });
const previous = queryClient.getQueriesData<Paginated<Patient>>({ queryKey: patientKeys.lists() });
previous.forEach(([key, data]) => {
if (!data) return;
queryClient.setQueryData<Paginated<Patient>>(key, {
...data,
items: data.items.filter((patient) => patient.id !== id),
total: Math.max(0, data.total - 1),
});
});
return { previous };
},
onError: (_error, _id, context) => {
context?.previous?.forEach(([key, data]) => queryClient.setQueryData(key, data));
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
},
});
}
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { Paginated } from '@/lib/api/types';
import { patientsApi } from '../apis';
import { patientKeys } from '../keys';
import type { CreatePatientInput, Patient } from '../types';
/**
* Creates a patient. Splices the new row into every cached list immediately (so the E1 list
* and the Home onboarding-gate reflect it without waiting for a refetch — no transient
* "0 patients" window that would bounce the user back to onboarding), then invalidates to
* reconcile. Domain 400s (missing/invalid gender, future birth date) surface via
* `mutation.error`; the fetch layer owns 401/403/5xx toasts.
*/
export function useCreatePatient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreatePatientInput) => patientsApi.create(input),
onSuccess: (patient) => {
queryClient.setQueriesData<Paginated<Patient>>({ queryKey: patientKeys.lists() }, (old) =>
old ? { ...old, items: [patient, ...old.items], total: old.total + 1 } : old,
);
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { patientsApi } from '../apis';
import { patientKeys } from '../keys';
import type { UpdatePatientInput } from '../types';
/**
* Updates a patient (the A4 form reused for edit) and invalidates the lists so the card
* reflects the change. A cross-tenant id returns 404 server-side (tenancy is enforced there).
*/
export function useUpdatePatient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdatePatientInput }) => patientsApi.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: patientKeys.lists() });
},
});
}
+3 -1
View File
@@ -1,2 +1,4 @@
export { usePatients } from './hooks/usePatients';
export { useAddPatient } from './hooks/useAddPatient';
export { useCreatePatient } from './hooks/useCreatePatient';
export { useUpdatePatient } from './hooks/useUpdatePatient';
export { useArchivePatient } from './hooks/useArchivePatient';
+50 -16
View File
@@ -1,33 +1,67 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import { CONDITION_CODES, RELATION_CODES } from './constants';
/**
* Patients domain — the reference `services/{domain}` implementation every later
* frontend phase copies. Enums cross the wire as stable string codes (money-and-types.md);
* mirror them as string-literal unions and never hardcode a display label off the code.
* Patients domain — the care-recipient (patient) sub-domain, customer-scoped and
* tenancy-enforced server-side. Shapes mirror the b3 contract
* (`dev/contracts/domains/identity-profiles.md` → `PatientDto`) exactly; enums cross the
* wire as stable string codes (money-and-types.md) mirrored here as unions.
*
* Deriving these types from the contract: read the domain's shapes from the published
* `dev/contracts/domains/<domain>.md` + `dev/contracts/openapi/swagger.v1.json`, mirror
* the wire exactly (field names + casing), and map enums to unions here. Until the real
* `/patients` endpoints exist, the shapes below are the agreed target the mock honours.
* `relation` and `conditions` are **client-augmented**: they are not on the wire
* `PatientDto` yet (filed as REQ-005 in requests/for-backend.md). The mock persists them;
* the real client carries them through create/update so the just-edited card reflects the
* choice, but they are not round-tripped by the server until the backend adds the columns.
*/
export type Gender = 'male' | 'female';
export type Relation = (typeof RELATION_CODES)[number];
export type ConditionCode = (typeof CONDITION_CODES)[number];
export interface Patient {
/** The b3 wire shape returned by every `patients/*` endpoint. */
export interface PatientDto {
id: number;
fullName: string;
displayName: string;
firstName: string;
lastName: string;
/** ISO date `YYYY-MM-DD`. */
birthDate: string;
gender: Gender;
/** UTC ISO-8601; display via formatShamsiDate. */
createdAtUtc: string;
bloodType: string | null;
/** Decrypted, owner-only free-text notes (E2 record viewer, deferred). */
initialMedicalNotes: string | null;
isActive: boolean;
}
export interface CreatePatientDto {
fullName: string;
gender: Gender;
/** App-level patient = wire shape + the client-augmented relation/conditions. */
export interface Patient extends PatientDto {
relation: Relation | null;
conditions: ConditionCode[];
}
/** The domain's API seam. A mock and the real client both implement this interface. */
/**
* Create/update input. `firstName`/`lastName`/`displayName` derive from the A4 single
* full-name field (split on the first space); `birthDate` derives from the age field.
* `bloodType`/`initialMedicalNotes` are deferred to the E2 record viewer.
*/
export interface CreatePatientInput {
displayName: string;
firstName: string;
lastName: string;
birthDate: string;
gender: Gender;
bloodType?: string | null;
initialMedicalNotes?: string | null;
relation: Relation | null;
conditions: ConditionCode[];
}
export type UpdatePatientInput = CreatePatientInput;
/** The domain's API seam — a mock and the real client both implement this interface. */
export interface PatientsApi {
list(params?: PageParams): Promise<Paginated<Patient>>;
create(dto: CreatePatientDto): Promise<Patient>;
get(id: number): Promise<Patient>;
create(input: CreatePatientInput): Promise<Patient>;
update(id: number, input: UpdatePatientInput): Promise<Patient>;
archive(id: number): Promise<void>;
}
@@ -0,0 +1,86 @@
import { clientFetch } from '@/lib/api/client';
import { ApiError } from '@/lib/api/errors';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type {
AvatarUploadResult,
CustomerProfile,
CustomerProfileDto,
NurseProfile,
NurseProfileDto,
ProfilesApi,
UpsertCustomerProfileInput,
UpsertNurseProfileInput,
} from '../types';
const BASE = '/api/v1';
// A caller with no profile yet gets a 404 from the GET — map that to `null` (an empty form),
// not an error. Any other status propagates.
async function orNull<T>(promise: Promise<T>): Promise<T | null> {
try {
return await promise;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields.
function toNurseProfile(dto: NurseProfileDto): NurseProfile {
return { ...dto, avatarUrl: null };
}
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile {
return { ...dto, firstName: null, lastName: null, preferredLanguage: null };
}
/**
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only.
*/
export const profilesClientApi: ProfilesApi = {
getCustomerProfile: async () =>
orNull(
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)),
),
),
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile(
unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
}),
}),
),
),
getNurseProfile: async () =>
orNull(
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
),
upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
toNurseProfile(
unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, {
method: 'POST',
body: JSON.stringify({
bio: input.bio,
yearsOfExperience: input.yearsOfExperience,
educationLevel: input.educationLevel,
educationField: input.educationField,
specializationsJson: input.specializationsJson,
}),
}),
),
),
uploadAvatar: async (): Promise<AvatarUploadResult> => {
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.');
},
};
@@ -0,0 +1,10 @@
import { USE_PROFILES_MOCK } from '../constants';
import type { ProfilesApi } from '../types';
import { profilesClientApi } from './clientApi';
import { profilesMockApi } from './mockApi';
/**
* The selected ProfilesApi implementation — the single seam hooks import. Selection is by
* config (USE_PROFILES_MOCK), never by scattered `if (mock)` checks.
*/
export const profilesApi: ProfilesApi = USE_PROFILES_MOCK ? profilesMockApi : profilesClientApi;
@@ -0,0 +1,73 @@
import { sleep } from '@/utils';
import type {
AvatarUploadResult,
CustomerProfile,
NurseProfile,
ProfilesApi,
UpsertCustomerProfileInput,
UpsertNurseProfileInput,
} from '../types';
const MOCK_LATENCY_MS = 350;
// Both profiles start absent (a fresh user has none — the real GET would 404). Bootstrapping
// via upsert creates them; `isVerified` and the aggregates stay server-owned defaults.
let customerProfile: CustomerProfile | null = null;
let nurseProfile: NurseProfile | null = null;
/**
* In-memory mock behind the ProfilesApi seam. Mirrors the b3 shapes and keeps the guarded
* read-only fields (`isVerified=false`, zero aggregates) exactly as the server would, so a
* bootstrapped nurse is never presented as verified/bookable.
*/
export const profilesMockApi: ProfilesApi = {
getCustomerProfile: async () => {
await sleep(MOCK_LATENCY_MS);
return customerProfile;
},
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => {
await sleep(MOCK_LATENCY_MS);
customerProfile = {
id: customerProfile?.id ?? 1,
defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
firstName: input.firstName ?? customerProfile?.firstName ?? null,
lastName: input.lastName ?? customerProfile?.lastName ?? null,
preferredLanguage: input.preferredLanguage ?? customerProfile?.preferredLanguage ?? null,
};
return customerProfile;
},
getNurseProfile: async () => {
await sleep(MOCK_LATENCY_MS);
return nurseProfile;
},
upsertNurseProfile: async (input: UpsertNurseProfileInput) => {
await sleep(MOCK_LATENCY_MS);
nurseProfile = {
id: nurseProfile?.id ?? 1,
bio: input.bio,
yearsOfExperience: input.yearsOfExperience,
educationLevel: input.educationLevel,
educationField: input.educationField,
specializationsJson: input.specializationsJson,
// Server-owned, guarded — a bootstrapped profile is never verified or bookable.
isVerified: false,
isAcceptingBookings: nurseProfile?.isAcceptingBookings ?? false,
averageRating: 0,
totalReviews: 0,
totalCompletedBookings: 0,
avatarUrl: input.avatarUrl ?? nurseProfile?.avatarUrl ?? null,
};
return nurseProfile;
},
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
await sleep(MOCK_LATENCY_MS);
// Object URL reflects the actual picked image for the demo; the real impl returns an
// object-storage URL (REQ-006).
return { url: URL.createObjectURL(file) };
},
};
+11
View File
@@ -0,0 +1,11 @@
/**
* When true, the profiles domain is served by the in-memory mock behind the ProfilesApi
* seam. The b3 `customer_profiles/*` and `nurse_profiles/*` endpoints are live, but the
* avatar/object-storage route and the customer name/preferred-language fields are gaps
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/
export const USE_PROFILES_MOCK = true;
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
export const PROFILE_STALE_TIME = 60_000;
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { profilesApi } from '../apis';
import { profileKeys } from '../keys';
import { PROFILE_STALE_TIME } from '../constants';
/** The signed-in customer's payer profile (emergency contact + name). `null` until created. */
export function useCustomerProfile() {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: profileKeys.customer(),
queryFn: () => profilesApi.getCustomerProfile(),
enabled: isAuthenticated,
staleTime: PROFILE_STALE_TIME,
});
}
@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { profilesApi } from '../apis';
import { profileKeys } from '../keys';
import { PROFILE_STALE_TIME } from '../constants';
/** The signed-in nurse's own seller profile. `null` until bootstrapped (B7). */
export function useNurseProfile() {
const isAuthenticated = useIsAuthenticated();
return useQuery({
queryKey: profileKeys.nurse(),
queryFn: () => profilesApi.getNurseProfile(),
enabled: isAuthenticated,
staleTime: PROFILE_STALE_TIME,
});
}
@@ -0,0 +1,12 @@
import { useMutation } from '@tanstack/react-query';
import { profilesApi } from '../apis';
/**
* Uploads an avatar image and returns its URL. The caller folds the returned URL into the
* next profile upsert. Backed by the mock until the object-storage route lands (REQ-006).
*/
export function useUploadAvatar() {
return useMutation({
mutationFn: (file: File) => profilesApi.uploadAvatar(file),
});
}
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authKeys } from '@/services/auth/keys';
import { profilesApi } from '../apis';
import { profileKeys } from '../keys';
import type { UpsertCustomerProfileInput } from '../types';
/**
* Creates/updates the customer profile. Writes the fresh profile straight into cache and
* invalidates `/me` so a profile-completion change reflects in the Home nudge (b3 also
* auto-provisions a thin customer profile, flipping `hasCustomerProfile`).
*/
export function useUpsertCustomerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpsertCustomerProfileInput) => profilesApi.upsertCustomerProfile(input),
onSuccess: (profile) => {
queryClient.setQueryData(profileKeys.customer(), profile);
queryClient.invalidateQueries({ queryKey: authKeys.me() });
},
});
}
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authKeys } from '@/services/auth/keys';
import { profilesApi } from '../apis';
import { profileKeys } from '../keys';
import type { UpsertNurseProfileInput } from '../types';
/**
* Bootstraps (first entry) or edits the nurse profile via the single b3 upsert. `isVerified`
* is never sent — it stays server-owned/false. Writes the fresh profile to cache and
* invalidates `/me` so `hasNurseProfile` reflects the bootstrap.
*/
export function useUpsertNurseProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpsertNurseProfileInput) => profilesApi.upsertNurseProfile(input),
onSuccess: (profile) => {
queryClient.setQueryData(profileKeys.nurse(), profile);
queryClient.invalidateQueries({ queryKey: authKeys.me() });
},
});
}
+5
View File
@@ -0,0 +1,5 @@
export { useCustomerProfile } from './hooks/useCustomerProfile';
export { useUpsertCustomerProfile } from './hooks/useUpsertCustomerProfile';
export { useNurseProfile } from './hooks/useNurseProfile';
export { useUpsertNurseProfile } from './hooks/useUpsertNurseProfile';
export { useUploadAvatar } from './hooks/useUploadAvatar';
+9
View File
@@ -0,0 +1,9 @@
/**
* React Query key factory for the profiles domain. The customer and nurse profiles are
* distinct owner-scoped resources, each its own key.
*/
export const profileKeys = {
all: ['profiles'] as const,
customer: () => [...profileKeys.all, 'customer'] as const,
nurse: () => [...profileKeys.all, 'nurse'] as const,
};
+76
View File
@@ -0,0 +1,76 @@
/**
* Profiles domain — the nurse *seller* profile and the customer *payer* profile. Shapes
* mirror the b3 contract (`dev/contracts/domains/identity-profiles.md` → `NurseProfileDto`,
* `CustomerProfileDto`) exactly. The bank account is a separate domain (`services/nurse`).
*
* Client-augmented fields (not on the wire yet — filed in requests/for-backend.md):
* - nurse `avatarUrl` — no avatar/object-storage route in b3 (REQ-006).
* - customer `firstName`/`lastName`/`preferredLanguage` — the wire `CustomerProfileDto`
* carries only the emergency contact; name lives on `/me` with no update endpoint (REQ-007).
* The mock persists these; the real client sends only the wire fields.
*/
/** `NurseProfileDto` — `isVerified` + the three aggregates are server-owned and read-only. */
export interface NurseProfileDto {
id: number;
bio: string;
yearsOfExperience: number;
educationLevel: string;
educationField: string;
/** Raw JSON array string of specialization codes (the builder is deferred to f4). */
specializationsJson: string;
isVerified: boolean;
isAcceptingBookings: boolean;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
}
export interface NurseProfile extends NurseProfileDto {
avatarUrl: string | null;
}
/** `nurse_profiles/upsert` body — never carries `isVerified` or the aggregates. */
export interface UpsertNurseProfileInput {
bio: string;
yearsOfExperience: number;
educationLevel: string;
educationField: string;
specializationsJson: string;
avatarUrl?: string | null;
}
/** `CustomerProfileDto` — emergency contact returned in full to the owning customer. */
export interface CustomerProfileDto {
id: number;
defaultEmergencyContactName: string;
defaultEmergencyContactPhone: string;
}
export interface CustomerProfile extends CustomerProfileDto {
firstName: string | null;
lastName: string | null;
preferredLanguage: string | null;
}
/** `customer_profiles/upsert` body (emergency contact) + client-augmented name/language. */
export interface UpsertCustomerProfileInput {
defaultEmergencyContactName: string;
defaultEmergencyContactPhone: string;
firstName?: string | null;
lastName?: string | null;
preferredLanguage?: string | null;
}
export interface AvatarUploadResult {
url: string;
}
/** The domain's API seam. `get*` resolve to `null` when the caller has no profile yet (404). */
export interface ProfilesApi {
getCustomerProfile(): Promise<CustomerProfile | null>;
upsertCustomerProfile(input: UpsertCustomerProfileInput): Promise<CustomerProfile>;
getNurseProfile(): Promise<NurseProfile | null>;
upsertNurseProfile(input: UpsertNurseProfileInput): Promise<NurseProfile>;
uploadAvatar(file: File): Promise<AvatarUploadResult>;
}