backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
@@ -0,0 +1,82 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { BOOKING_REQUEST_PAGE_SIZE } from '../constants';
import type {
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
} from '../types';
const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null };
}
/**
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
*
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse,
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice`
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
*/
export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
get: async (id: number) =>
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams();
query.set('role', params.role);
if (params.status) query.set('status', params.status);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<BookingRequestListItem>>>(`${BASE}/list?${query.toString()}`),
);
},
accept: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
cancel: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
};
@@ -0,0 +1,12 @@
import { USE_BOOKING_REQUESTS_MOCK } from '../constants';
import type { BookingRequestsApi } from '../types';
import { bookingRequestsClientApi } from './clientApi';
import { bookingRequestsMockApi } from './mockApi';
/**
* The selected `BookingRequestsApi` implementation — the single seam the hooks import. Selection is by
* config (`USE_BOOKING_REQUESTS_MOCK`), never by scattered `if (mock)` checks.
*/
export const bookingRequestsApi: BookingRequestsApi = USE_BOOKING_REQUESTS_MOCK
? bookingRequestsMockApi
: bookingRequestsClientApi;
@@ -0,0 +1,285 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import {
BOOKING_REQUEST_PAGE_SIZE,
CUSTOMER_NOTES_MAX_LENGTH,
MOCK_PAYMENT_DEADLINE_MINUTES,
MOCK_RESPONSE_DEADLINE_MINUTES,
REJECTION_REASON_MAX_LENGTH,
} from '../constants';
import type {
BookingRequestDisplayContext,
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
BookingRequestStatus,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
RequestRole,
} from '../types';
import { isTerminalBookingRequestStatus } from '../types';
const MOCK_LATENCY_MS = 350;
// Shared, module-level store so the customer's created request appears in the nurse inbox and a nurse
// accept/reject flips the customer's polled C5 — all within one browser session (one module instance).
// Seeded with two pending requests so a nurse-only visit sees a non-empty inbox before any create.
let store: BookingRequestDto[] = [];
let nextId = 1;
const minutesFromNow = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString();
function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
const id = nextId++;
return {
id,
status: 'pending_nurse_response',
nurseId: 1,
nurseName: 'مریم رضایی',
nurseRating: 4.9,
nurseTotalReviews: 37,
patientId: 900 + id,
patientName: 'حاج‌آقا موسوی',
variantId: 11,
variantLabel: 'مراقبت سالمند — شیفت روز',
variantPriceUnit: 'per_hour',
variantPrice: '2800000',
customerAddressId: 800 + id,
addressTitle: 'منزل',
cityId: 101,
cityNameFa: 'تهران',
cityNameEn: 'Tehran',
districtId: 1003,
districtNameFa: 'سعادت‌آباد',
districtNameEn: 'Saadat Abad',
addressLine: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766',
recipientName: 'زهرا موسوی',
recipientPhone: '09121234567',
requiredCaregiverGender: 'female',
requestedDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10),
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
customerNotes: 'لطفاً در تعویض سِرُم دقت شود.',
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
...overrides,
};
}
store = [
seedRow({}),
seedRow({
nurseName: 'مریم رضایی',
patientName: 'خانم احمدی',
variantLabel: 'مراقبت پس از جراحی',
requiredCaregiverGender: 'female',
customerNotes: 'بیمار پس از عمل زانو، نیاز به کمک در جابه‌جایی دارد.',
requestedTimeStart: '15:00:00',
requestedTimeEnd: '19:00:00',
}),
];
/**
* Lazily transition any past-deadline row to its terminal state — the client stand-in for the server's
* background expiry sweep, so `expired_no_response` / `payment_deadline_expired` surface through the poll
* without a real cron. Runs on every read/write.
*/
function sweep(): void {
const now = Date.now();
store = store.map((row) => {
if (row.status === 'pending_nurse_response' && Date.parse(row.nurseResponseDeadlineAt) < now) {
return { ...row, status: 'expired_no_response' as BookingRequestStatus };
}
if (
row.status === 'accepted_awaiting_payment' &&
row.paymentDeadlineAt != null &&
Date.parse(row.paymentDeadlineAt) < now
) {
return { ...row, status: 'payment_deadline_expired' as BookingRequestStatus };
}
return row;
});
}
function find(id: number): BookingRequestDto {
const row = store.find((r) => r.id === id);
// Party-scoping is not modelled in the single-session mock; existence is not leaked either way (404).
if (!row) throw new ApiError(404, 'Booking request not found', 'not_found');
return row;
}
/** The nurse view masks the full address (two-stage disclosure) — coarse city/district only. */
function maskForNurse(dto: BookingRequestDto): BookingRequestDto {
return { ...dto, addressLine: null, postalCode: null, recipientName: null, recipientPhone: null };
}
function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestListItem {
return {
id: dto.id,
status: dto.status,
counterpartyName: role === 'nurse' ? dto.patientName : dto.nurseName,
nurseRating: role === 'customer' ? dto.nurseRating : null,
requiredCaregiverGender: dto.requiredCaregiverGender,
requestedDate: dto.requestedDate,
requestedTimeStart: dto.requestedTimeStart,
requestedTimeEnd: dto.requestedTimeEnd,
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
paymentDeadlineAt: dto.paymentDeadlineAt,
customerNotes: role === 'nurse' ? dto.customerNotes : null,
};
}
// Actionable (non-terminal) rows first, then by soonest response deadline — mirrors the contract's
// "actionable rows sort first".
function actionableFirst(a: BookingRequestDto, b: BookingRequestDto): number {
const at = isTerminalBookingRequestStatus(a.status) ? 1 : 0;
const bt = isTerminalBookingRequestStatus(b.status) ? 1 : 0;
if (at !== bt) return at - bt;
return Date.parse(a.nurseResponseDeadlineAt) - Date.parse(b.nurseResponseDeadlineAt);
}
function assertCreateValid(payload: CreateBookingRequestPayload): void {
if (!payload.requiredCaregiverGender) {
throw new ApiError(400, 'requiredCaregiverGender is required', 'gender_required');
}
if (payload.requestedTimeEnd <= payload.requestedTimeStart) {
throw new ApiError(400, 'requestedTimeEnd must be after requestedTimeStart', 'invalid_time_range');
}
if ((payload.customerNotes?.length ?? 0) > CUSTOMER_NOTES_MAX_LENGTH) {
throw new ApiError(400, 'customerNotes too long', 'notes_too_long');
}
const start = Date.parse(`${payload.requestedDate}T${payload.requestedTimeStart}`);
if (Number.isFinite(start) && start < Date.now()) {
throw new ApiError(400, 'requestedDate/time is in the past', 'past_date');
}
}
function buildFromContext(
id: number,
payload: CreateBookingRequestPayload,
context: BookingRequestDisplayContext | undefined,
): BookingRequestDto {
return {
id,
status: 'pending_nurse_response',
nurseId: payload.nurseId,
nurseName: context?.nurseName ?? '',
nurseRating: context?.nurseRating ?? 0,
nurseTotalReviews: context?.nurseTotalReviews ?? 0,
patientId: payload.patientId,
patientName: context?.patientName ?? '',
variantId: payload.variantId,
variantLabel: context?.variantLabel ?? '',
variantPriceUnit: context?.variantPriceUnit ?? 'per_hour',
variantPrice: context?.variantPrice ?? null,
customerAddressId: payload.customerAddressId,
addressTitle: context?.addressTitle ?? '',
cityId: context?.cityId ?? 0,
cityNameFa: context?.cityNameFa ?? '',
cityNameEn: context?.cityNameEn ?? '',
districtId: context?.districtId ?? null,
districtNameFa: context?.districtNameFa ?? null,
districtNameEn: context?.districtNameEn ?? null,
addressLine: context?.addressLine ?? null,
postalCode: context?.postalCode ?? null,
recipientName: context?.recipientName ?? null,
recipientPhone: context?.recipientPhone ?? null,
requiredCaregiverGender: payload.requiredCaregiverGender,
requestedDate: payload.requestedDate,
requestedTimeStart: payload.requestedTimeStart,
requestedTimeEnd: payload.requestedTimeEnd,
customerNotes: payload.customerNotes?.trim() || null,
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
};
}
/**
* In-memory mock behind the `BookingRequestsApi` seam. Drives the full request lifecycle over shared
* session state so C4 → C5 → the nurse inbox → accept/reject/expire all demo end-to-end without a live
* b8 backend (and without the upstream mock domains' ids needing to exist server-side). Mirrors the real
* shapes + status/deadline/masking semantics for a one-line swap once the stack is live
* (`USE_BOOKING_REQUESTS_MOCK = false`).
*/
export const bookingRequestsMockApi: BookingRequestsApi = {
create: async (payload, context) => {
await sleep(MOCK_LATENCY_MS);
assertCreateValid(payload);
const dto = buildFromContext(nextId++, payload, context);
store = [dto, ...store];
return dto;
},
get: async (id, role) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
return role === 'nurse' ? maskForNurse(dto) : dto;
},
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
await sleep(MOCK_LATENCY_MS);
sweep();
const matched = [...store]
.filter((row) => (params.status ? row.status === params.status : true))
.sort(actionableFirst)
.map((row) => toListItem(row, params.role));
const page = params.page ?? 1;
const pageSize = params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
accept: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = {
...dto,
status: 'accepted_awaiting_payment',
paymentDeadlineAt: minutesFromNow(MOCK_PAYMENT_DEADLINE_MINUTES),
};
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
reject: async (id, payload: RejectBookingRequestPayload) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
const reason = payload.reason?.trim() ?? '';
if (!reason) throw new ApiError(400, 'reason is required', 'reason_required');
if (reason.length > REJECTION_REASON_MAX_LENGTH) {
throw new ApiError(400, 'reason too long', 'reason_too_long');
}
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = { ...dto, status: 'rejected_by_nurse', nurseRejectionReason: reason };
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
cancel: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response' && dto.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request can no longer be cancelled', 'not_cancellable');
}
const updated: BookingRequestDto = { ...dto, status: 'cancelled_by_customer' };
store = store.map((row) => (row.id === id ? updated : row));
return updated;
},
};
@@ -0,0 +1,43 @@
/**
* When true, the booking-requests domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `BookingRequestsApi` seam.
*
* **Mock is primary this phase.** The b8 endpoints are live server-side, but every *input* to a request
* — the nurse (search, f6), the patient (patients, f2), the address (addresses, f3) — is itself served by
* a **mock-primary** client domain today, so a real `booking_requests/create` would reference ids that
* exist only in those in-memory stores. Running the whole flow end-to-end (create → nurse inbox → accept
* → the customer's C5 flips → expiry) therefore needs a mock that shares the same session state. The mock
* drives exactly that. Additionally the contract DTO omits the variant price the summary card renders
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
*/
export const USE_BOOKING_REQUESTS_MOCK = true;
/**
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
* (accept / reject / expire) surfaces without a manual refresh; polling **stops** on a terminal/`converted`
* status (see the hooks' `refetchInterval` guard). 15s balances freshness against request volume.
*/
export const BOOKING_REQUEST_POLL_MS = 15 * 1000;
/** A single request is cheap and changes only on the other party's action — a short stale window. */
export const BOOKING_REQUEST_STALE_TIME = 10 * 1000;
export const BOOKING_REQUEST_GC_TIME = 5 * 60 * 1000;
/** api-conventions default/max page sizes (max 100 server-side); an inbox page. */
export const BOOKING_REQUEST_PAGE_SIZE = 20;
/** `customerNotes` limit (contract: ≤ 1000) — enforced client-side before the CTA. */
export const CUSTOMER_NOTES_MAX_LENGTH = 1000;
/** `nurseRejectionReason` limit (contract: ≤ 500) — enforced client-side in the reject dialog. */
export const REJECTION_REASON_MAX_LENGTH = 500;
/**
* Mock-only deadline windows. The **payment** window is contract-accurate (30 min); the **response**
* window is a demo-shortened stand-in for the server's real 24h so a session can observe the
* `expired_no_response` terminal path. Neither is used on the real path — the server freezes the true
* deadlines from `IPlatformConfig`. Documented in the phase report + mocks note.
*/
export const MOCK_RESPONSE_DEADLINE_MINUTES = 30;
export const MOCK_PAYMENT_DEADLINE_MINUTES = 30;
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Nurse accept — opens the 30-minute payment window server-side. On success it invalidates every inbox
* list (so the request leaves the pending inbox immediately) and the request detail (so the customer's
* polled C5 reflects the accept). A stale accept (past deadline / not pending) returns `409` via
* `mutation.error` — the caller surfaces it and refetches.
*/
export function useAcceptBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.accept(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import { BOOKING_REQUEST_GC_TIME, BOOKING_REQUEST_POLL_MS, BOOKING_REQUEST_STALE_TIME } from '../constants';
import { isTerminalBookingRequestStatus, type RequestRole } from '../types';
/**
* A single booking request (C5 customer view + the nurse request detail). **Polls** while the status is
* non-terminal so an accept / reject / expire transition surfaces without a manual refresh, and **stops**
* polling once a terminal/`converted` status is reached. `role` drives the mock's nurse-view masking (the
* real server infers it from auth). Enabled only when an id is present.
*/
export function useBookingRequest(id: number | undefined, role: RequestRole) {
return useQuery({
queryKey: bookingRequestKeys.detail(id ?? -1),
queryFn: () => bookingRequestsApi.get(id as number, role),
enabled: id != null && id > 0,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && isTerminalBookingRequestStatus(status) ? false : BOOKING_REQUEST_POLL_MS;
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Customer cancel — withdraws a request that is still `pending_nurse_response` or
* `accepted_awaiting_payment` (before paying). Invalidates the lists + the request detail so both inboxes
* and the C5 reflect the cancellation. A cancel on a terminal request returns `409` via `mutation.error`.
*/
export function useCancelBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.cancel(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { BookingRequestDisplayContext, CreateBookingRequestPayload } from '../types';
interface CreateArgs {
payload: CreateBookingRequestPayload;
/** Mock-only display aid (ignored by the real client) so the created request renders fully on C5. */
context?: BookingRequestDisplayContext;
}
/**
* Creates a booking request (C4 → C5). On success it seeds the new request into the detail cache and
* invalidates the customer inbox so both reflect it immediately; the page owns the navigation to C5.
* Domain `400`s (same-gender mismatch, tenancy 404, inactive variant, invalid time/date) surface via
* `mutation.error` — the fetch layer owns 401/403/5xx toasts.
*/
export function useCreateBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ payload, context }: CreateArgs) => bookingRequestsApi.create(payload, context),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
},
});
}
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The customer's own booking-requests inbox (`list?role=customer`), optionally filtered by status. C5 is
* keyed by a single request id, so this list is not required by a phase-7 screen — it exists so f8's
* "my bookings" surface (and any future customer request list) reads the same cached domain, and so a
* create/cancel invalidation has a list to refresh.
*/
export function useCustomerRequests(status?: BookingRequestStatus, page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'customer' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.list(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
});
}
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_POLL_MS,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly
* polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this
* list so an actioned request leaves the pending inbox immediately.
*/
export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.nurseInbox(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: BOOKING_REQUEST_POLL_MS,
});
}
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { RejectBookingRequestPayload } from '../types';
interface RejectArgs {
id: number;
payload: RejectBookingRequestPayload;
}
/**
* Nurse reject (with a required reason). On success it invalidates every inbox list (the request leaves
* the pending inbox) and the request detail (the customer's polled C5 shows the rejected terminal card
* with the reason). A stale reject returns `409` via `mutation.error`.
*/
export function useRejectBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: RejectArgs) => bookingRequestsApi.reject(id, payload),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,11 @@
/**
* Booking-requests domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCreateBookingRequest } from './hooks/useCreateBookingRequest';
export { useBookingRequest } from './hooks/useBookingRequest';
export { useNurseRequestInbox } from './hooks/useNurseRequestInbox';
export { useCustomerRequests } from './hooks/useCustomerRequests';
export { useAcceptBookingRequest } from './hooks/useAcceptBookingRequest';
export { useRejectBookingRequest } from './hooks/useRejectBookingRequest';
export { useCancelBookingRequest } from './hooks/useCancelBookingRequest';
@@ -0,0 +1,23 @@
import type { BookingRequestListParams } from './types';
/**
* React Query key factory for the booking-requests domain (hierarchical, per the `services/{domain}`
* pattern). The list key carries the role + status filter so the customer inbox and the nurse inbox are
* distinct cache entries and a nurse accept/reject invalidates exactly the right list.
*/
export const bookingRequestKeys = {
all: ['bookingRequests'] as const,
lists: () => [...bookingRequestKeys.all, 'list'] as const,
list: (params: BookingRequestListParams) =>
[
...bookingRequestKeys.lists(),
params.role,
params.status ?? 'all',
params.page ?? 1,
params.pageSize ?? 0,
] as const,
/** The nurse incoming-requests inbox (a `list` with `role='nurse'`), factored for readability. */
nurseInbox: (params: BookingRequestListParams) => bookingRequestKeys.list(params),
details: () => [...bookingRequestKeys.all, 'detail'] as const,
detail: (id: number) => [...bookingRequestKeys.details(), id] as const,
};
@@ -0,0 +1,198 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
/**
* Booking-requests domain — the **pre-payment intent** layer of the engagement lifecycle (b8). A
* customer requests a nurse for a patient/variant/address/date; the nurse accepts (opening a
* config-driven 30-minute payment window) or rejects; both sides read a role-scoped inbox and a single
* request. **There is no money and no `bookings` row here** — that conversion is b9/b10. Shapes mirror
* the b8 contract (`dev/contracts/domains/booking-requests.md`); the wire is **camelCase** and
* `clientFetch` unwraps the `ApiEnvelope<T>`, so these are the post-`unwrap()` payloads.
*
* Load-bearing semantics (contract "Key semantics" + phase §5):
* - **Deadlines are server-frozen absolute UTC instants.** The client renders countdowns from
* `nurseResponseDeadlineAt` / `paymentDeadlineAt` against `Date.now()` — it never computes a deadline.
* `paymentDeadlineAt` is `null` until the nurse accepts.
* - **`requiredCaregiverGender` is first-class and required on create** (`male`/`female`/`any`) — never
* silently defaulted. `male`/`female` must match the nurse's gender (a mismatch is a `400`).
* - **Two-stage clinical disclosure.** The nurse sees **only** `customerNotes` (stage-1 plaintext) and a
* **masked** address (`addressLine`/`postalCode`/recipient are `null` in the nurse view). Full
* clinical/care instructions do not exist until b9 and must never appear in this UI.
* - **Forward-only status machine.** Terminal states have no outgoing edges; a stale accept/reject/cancel
* returns `409`.
*/
/** The same-gender matching facet carried from search into the request (`any` = فرقی ندارد). */
export type RequiredCaregiverGender = 'male' | 'female' | 'any';
/** The full `booking_request_status` enum (contract). */
export type BookingRequestStatus =
| 'pending_nurse_response'
| 'accepted_awaiting_payment'
| 'converted'
| 'rejected_by_nurse'
| 'expired_no_response'
| 'payment_deadline_expired'
| 'cancelled_by_customer';
/** Which inbox to read — disambiguates a user who holds both roles (contract `list?role=`). */
export type RequestRole = 'customer' | 'nurse';
/** Terminal states: no outgoing edges, so polling stops and no accept/reject/cancel is offered. */
export const TERMINAL_BOOKING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'converted',
'rejected_by_nurse',
'expired_no_response',
'payment_deadline_expired',
'cancelled_by_customer',
] as const;
export function isTerminalBookingRequestStatus(status: BookingRequestStatus): boolean {
return TERMINAL_BOOKING_REQUEST_STATUSES.includes(status);
}
/**
* `BookingRequestDto` — the full single-request view. The customer/admin view carries the full address;
* the **nurse view masks it** (`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are `null`),
* leaving only the coarse city/district.
*
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
* real client leaves it `null` (the summary then hides the amount) until the field lands.
*/
export interface BookingRequestDto {
id: number;
status: BookingRequestStatus;
nurseId: number;
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientId: number;
patientName: string;
variantId: number;
variantLabel: string;
variantPriceUnit: PriceUnit;
/** Client-augmented (REQ-013): IRR digit-string, or `null` on the real path until the DTO carries it. */
variantPrice: string | null;
customerAddressId: number;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
/** Full-address PII — present in the customer/admin view, **`null` in the nurse view** (masked). */
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Stage-1 plaintext — the ONLY clinical text the nurse sees before accepting. */
customerNotes: string | null;
/** Server-frozen absolute UTC instant. */
nurseResponseDeadlineAt: string;
/** Server-frozen UTC; `null` until the nurse accepts. */
paymentDeadlineAt: string | null;
nurseRejectionReason: string | null;
createdAt: string;
}
/**
* `BookingRequestListItemDto` — an inbox row. The **customer** inbox sets `counterpartyName` = nurse name
* (+ `nurseRating`); the **nurse** inbox sets `counterpartyName` = patient name (+ `customerNotes`,
* stage-1 only). Actionable rows sort first server-side.
*/
export interface BookingRequestListItem {
id: number;
status: BookingRequestStatus;
counterpartyName: string;
/** Customer view only; `null` in the nurse inbox. */
nurseRating: number | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
requestedDate: string;
requestedTimeStart: string;
requestedTimeEnd: string;
nurseResponseDeadlineAt: string;
paymentDeadlineAt: string | null;
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
customerNotes: string | null;
}
/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */
export interface CreateBookingRequestPayload {
nurseId: number;
variantId: number;
patientId: number;
customerAddressId: number;
/** `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Required, never silently defaulted. */
requiredCaregiverGender: RequiredCaregiverGender;
/** ≤ 1000 chars; the only text the nurse sees pre-accept. */
customerNotes?: string | null;
}
/** The `booking_requests/reject` body. */
export interface RejectBookingRequestPayload {
/** Required, ≤ 500 chars. */
reason: string;
}
/** `booking_requests/list` query params (role-scoped, paginated, optional status filter). */
export interface BookingRequestListParams extends PageParams {
role: RequestRole;
status?: BookingRequestStatus;
}
/**
* Display fields the mock needs to build a faithful DTO, resolved by the C4 form from the already-loaded
* nurse profile / patient / address / variant queries. **The real `clientApi` ignores this** — the
* server returns the joined DTO from the ids alone; only the mock (which cannot read the other domains'
* in-memory stores) uses it. This is the b8 analogue of the search/patients/addresses "client-augmented"
* display fields, kept off the wire `CreateBookingRequestPayload`.
*/
export interface BookingRequestDisplayContext {
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
variantPrice: string | null;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
}
/**
* The booking-requests API seam — the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_BOOKING_REQUESTS_MOCK`), never scattered `if (mock)` checks.
*
* `get`/`create` take an optional `role`/`context` that only the mock uses (to mask the nurse view and
* to build a faithful DTO respectively); the real client infers the view from auth and ignores them.
*/
export interface BookingRequestsApi {
create(payload: CreateBookingRequestPayload, context?: BookingRequestDisplayContext): Promise<BookingRequestDto>;
get(id: number, role?: RequestRole): Promise<BookingRequestDto>;
list(params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>>;
accept(id: number): Promise<BookingRequestDto>;
reject(id: number, payload: RejectBookingRequestPayload): Promise<BookingRequestDto>;
cancel(id: number): Promise<BookingRequestDto>;
}