backend phase 14 & frontend phase 7
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user