backend phase 15 & frontend phase 8
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { BOOKINGS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
BookingDetailDto,
|
||||
BookingListItemDto,
|
||||
BookingListParams,
|
||||
BookingSessionListItemDto,
|
||||
BookingsApi,
|
||||
CareInstructionsDto,
|
||||
CheckInVisitInput,
|
||||
CheckOutVisitInput,
|
||||
TodaySessionsParams,
|
||||
VisitVerificationDto,
|
||||
} from '../types';
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
const SESSIONS = '/api/v1/booking_sessions';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `BookingsApi` seam (b9 contract `dev/contracts/domains/bookings-evv.md`,
|
||||
* swagger `dev/contracts/openapi/swagger.v1.json`). Routes are action-style + snake_case; ids come from
|
||||
* the **route**; bodies/fields are camelCase and `clientFetch` returns the raw envelope, so we `unwrap()`.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_BOOKINGS_MOCK = true`): a booking only exists after the
|
||||
* (mock-primary) request flow converts + is paid (b10), so `bookings/list` has nothing to return yet.
|
||||
* The server infers the viewer from auth + tenancy (nurse view masks `addressSnapshotJson`; the
|
||||
* care-instructions read 404s for anyone but the assigned nurse/admin), so the `viewerRole` args a mock
|
||||
* needs are ignored here. The EVV commands send only the coordinates — the server timestamps the
|
||||
* authoritative `checkInAt`, so the client's `capturedAt` is not sent. One config flip selects this.
|
||||
*/
|
||||
export const bookingsClientApi: BookingsApi = {
|
||||
getBookingDetail: async (id: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<BookingDetailDto>>(`${BOOKINGS}/get/${id}`)),
|
||||
|
||||
listBookings: async (params: BookingListParams): Promise<Paginated<BookingListItemDto>> => {
|
||||
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 ?? BOOKINGS_PAGE_SIZE));
|
||||
return unwrap(await clientFetch<ApiEnvelope<Paginated<BookingListItemDto>>>(`${BOOKINGS}/list?${query.toString()}`));
|
||||
},
|
||||
|
||||
listTodaySessions: async (params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.date) query.set('date', params.date);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? BOOKINGS_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<BookingSessionListItemDto>>>(`${SESSIONS}/today?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
getSessionEvv: async (sessionId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/evv/${sessionId}`)),
|
||||
|
||||
getCareInstructions: async (bookingId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<CareInstructionsDto>>(`${BOOKINGS}/care_instructions/${bookingId}`)),
|
||||
|
||||
checkInVisit: async (input: CheckInVisitInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/check_in/${input.bookingSessionId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }),
|
||||
}),
|
||||
),
|
||||
|
||||
checkOutVisit: async (input: CheckOutVisitInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<VisitVerificationDto>>(`${SESSIONS}/check_out/${input.bookingSessionId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_BOOKINGS_MOCK } from '../constants';
|
||||
import type { BookingsApi } from '../types';
|
||||
import { bookingsClientApi } from './clientApi';
|
||||
import { bookingsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `BookingsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_BOOKINGS_MOCK`), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const bookingsApi: BookingsApi = USE_BOOKINGS_MOCK ? bookingsMockApi : bookingsClientApi;
|
||||
@@ -0,0 +1,382 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import {
|
||||
BOOKINGS_PAGE_SIZE,
|
||||
MOCK_EVV_REFERENCE_LAT,
|
||||
MOCK_EVV_REFERENCE_LNG,
|
||||
MOCK_EVV_TOLERANCE_METERS,
|
||||
} from '../constants';
|
||||
import type {
|
||||
BookingDetailDto,
|
||||
BookingListItemDto,
|
||||
BookingListParams,
|
||||
BookingSessionDto,
|
||||
BookingSessionListItemDto,
|
||||
BookingsApi,
|
||||
BookingViewerRole,
|
||||
CareInstructionsDto,
|
||||
CheckInVisitInput,
|
||||
CheckOutVisitInput,
|
||||
TodaySessionsParams,
|
||||
VisitVerificationDto,
|
||||
} from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
/** Dispute window the mock stamps at completion — the payout-eligibility gate is server truth, mocked here. */
|
||||
const DISPUTE_WINDOW_HOURS = 72;
|
||||
|
||||
const NURSE_ID = 1;
|
||||
const NURSE_NAME = 'مریم رضایی';
|
||||
|
||||
/** `YYYY-MM-DD` for a day offset from today (mock seed dates — runs client-side, so `new Date()` is fine). */
|
||||
function isoDate(daysFromToday: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysFromToday);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Haversine distance in metres — the mock stand-in for the server's address-match math. */
|
||||
function distanceMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6_371_000;
|
||||
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
function makeSession(id: number, sessionIndex: number, daysFromToday: number, payoutIrr: string): BookingSessionDto {
|
||||
return {
|
||||
id,
|
||||
sessionIndex,
|
||||
scheduledDate: isoDate(daysFromToday),
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
status: 'scheduled',
|
||||
visitPayoutAmount: payoutIrr,
|
||||
payoutEligibleAt: null,
|
||||
evvStatus: 'pending',
|
||||
checkInAt: null,
|
||||
checkOutAt: null,
|
||||
checkInAddressMatch: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Shared, module-level store so both the customer view and the nurse view read the same booking, and an
|
||||
// EVV check-in/out flips the timeline/session/banner for both. Seeded with one multi-session booking and
|
||||
// one single-visit booking (proving the single-visit path renders one session row through the same card),
|
||||
// both assigned to the seeded nurse and scheduled with a today session so check-in is demoable out of box.
|
||||
let bookings: BookingDetailDto[] = [];
|
||||
const verifications: Record<number, VisitVerificationDto> = {};
|
||||
const care: Record<number, CareInstructionsDto> = {};
|
||||
|
||||
function seed(): void {
|
||||
const addr5001 = JSON.stringify({
|
||||
title: 'منزل',
|
||||
city: 'تهران',
|
||||
district: 'سعادتآباد',
|
||||
line: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
|
||||
postalCode: '1998887766',
|
||||
});
|
||||
const addr5002 = JSON.stringify({
|
||||
title: 'آپارتمان',
|
||||
city: 'تهران',
|
||||
district: 'ونک',
|
||||
line: 'خیابان ملاصدرا، پلاک ۴۵، واحد ۷',
|
||||
postalCode: '1991112233',
|
||||
});
|
||||
|
||||
bookings = [
|
||||
{
|
||||
id: 5001,
|
||||
bookingRequestId: 9001,
|
||||
status: 'confirmed',
|
||||
nurseId: NURSE_ID,
|
||||
nurseName: NURSE_NAME,
|
||||
patientId: 901,
|
||||
patientName: 'حاجآقا موسوی',
|
||||
variantId: 11,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت سالمند — شیفت روز', priceUnit: 'per_day' }),
|
||||
customerAddressId: 801,
|
||||
addressSnapshotJson: addr5001,
|
||||
grossPriceIrr: '45000000',
|
||||
balinyaarCommissionIrr: '5400000',
|
||||
nursePayoutAmount: '39600000',
|
||||
pspFeeAmount: '900000',
|
||||
platformFeeRate: 0.12,
|
||||
sessionCount: 3,
|
||||
scheduledDate: isoDate(0),
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
confirmedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
cancellationReason: null,
|
||||
cancellationPolicyCode: null,
|
||||
cancellationRefundPercentage: null,
|
||||
refundableAmountIrr: null,
|
||||
disputeWindowEndsAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
sessions: [
|
||||
makeSession(70011, 1, 0, '13200000'),
|
||||
makeSession(70012, 2, 1, '13200000'),
|
||||
makeSession(70013, 3, 2, '13200000'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5002,
|
||||
bookingRequestId: 9002,
|
||||
status: 'confirmed',
|
||||
nurseId: NURSE_ID,
|
||||
nurseName: NURSE_NAME,
|
||||
patientId: 902,
|
||||
patientName: 'خانم احمدی',
|
||||
variantId: 12,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی', priceUnit: 'per_session' }),
|
||||
customerAddressId: 802,
|
||||
addressSnapshotJson: addr5002,
|
||||
grossPriceIrr: '18000000',
|
||||
balinyaarCommissionIrr: '2160000',
|
||||
nursePayoutAmount: '15840000',
|
||||
pspFeeAmount: '360000',
|
||||
platformFeeRate: 0.12,
|
||||
sessionCount: 1,
|
||||
scheduledDate: isoDate(0),
|
||||
scheduledTimeStart: '15:00:00',
|
||||
scheduledTimeEnd: '19:00:00',
|
||||
confirmedAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
cancellationReason: null,
|
||||
cancellationPolicyCode: null,
|
||||
cancellationRefundPercentage: null,
|
||||
refundableAmountIrr: null,
|
||||
disputeWindowEndsAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }],
|
||||
},
|
||||
];
|
||||
|
||||
care[5001] = {
|
||||
bookingId: 5001,
|
||||
currentConditions: 'دیابت نوع ۲، فشار خون بالا',
|
||||
medications: 'متفورمین ۵۰۰ (صبح و شب) · لوزارتان ۲۵ (صبح)',
|
||||
allergies: 'حساسیت به پنیسیلین',
|
||||
specialInstructions: 'قند خون پیش از هر وعده اندازهگیری شود؛ یک پیادهروی کوتاه بعدازظهر توصیه شده است.',
|
||||
emergencyContactName: 'زهرا موسوی',
|
||||
emergencyContactPhone: '09121234567',
|
||||
};
|
||||
care[5002] = {
|
||||
bookingId: 5002,
|
||||
currentConditions: 'دورهٔ نقاهت پس از عمل زانو',
|
||||
medications: 'مسکن طبق دستور پزشک',
|
||||
allergies: null,
|
||||
specialInstructions: 'در جابهجایی و تعویض پانسمان کمک شود؛ از فشار روی زانوی عملشده پرهیز شود.',
|
||||
emergencyContactName: 'علی احمدی',
|
||||
emergencyContactPhone: '09120009988',
|
||||
};
|
||||
}
|
||||
|
||||
seed();
|
||||
|
||||
function cloneBooking(b: BookingDetailDto): BookingDetailDto {
|
||||
return { ...b, sessions: b.sessions.map((s) => ({ ...s })) };
|
||||
}
|
||||
|
||||
function findBooking(id: number): BookingDetailDto {
|
||||
const b = bookings.find((row) => row.id === id);
|
||||
// Tenancy is not modelled in the single-session mock; a missing booking 404s (no leak either way).
|
||||
if (!b) throw new ApiError(404, 'Booking not found', 'not_found');
|
||||
return b;
|
||||
}
|
||||
|
||||
function findSession(sessionId: number): { booking: BookingDetailDto; session: BookingSessionDto } {
|
||||
for (const booking of bookings) {
|
||||
const session = booking.sessions.find((s) => s.id === sessionId);
|
||||
if (session) return { booking, session };
|
||||
}
|
||||
throw new ApiError(404, 'Session not found', 'not_found');
|
||||
}
|
||||
|
||||
/** The nurse view omits the full address snapshot (two-stage disclosure — coarse context only). */
|
||||
function forViewer(b: BookingDetailDto, viewerRole: BookingViewerRole | undefined): BookingDetailDto {
|
||||
const clone = cloneBooking(b);
|
||||
if (viewerRole === 'nurse') clone.addressSnapshotJson = null;
|
||||
return clone;
|
||||
}
|
||||
|
||||
function toListItem(b: BookingDetailDto, role: BookingListParams['role']): BookingListItemDto {
|
||||
return {
|
||||
id: b.id,
|
||||
status: b.status,
|
||||
counterpartyName: role === 'nurse' ? b.patientName : b.nurseName,
|
||||
scheduledDate: b.scheduledDate,
|
||||
sessionCount: b.sessionCount,
|
||||
// Contract: gross for the customer, the nurse's payout for the nurse.
|
||||
amountIrr: role === 'nurse' ? b.nursePayoutAmount : b.grossPriceIrr,
|
||||
disputeWindowEndsAt: b.disputeWindowEndsAt,
|
||||
createdAt: b.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingVerification(session: BookingSessionDto): VisitVerificationDto {
|
||||
return {
|
||||
id: session.id,
|
||||
bookingSessionId: session.id,
|
||||
status: session.evvStatus,
|
||||
checkInAt: session.checkInAt,
|
||||
checkInLat: null,
|
||||
checkInLng: null,
|
||||
checkOutAt: session.checkOutAt,
|
||||
checkOutLat: null,
|
||||
checkOutLng: null,
|
||||
checkInAddressMatch: session.checkInAddressMatch,
|
||||
checkInDistanceMeters: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `BookingsApi` seam. Seeds confirmed bookings + sessions + care + EVV and
|
||||
* drives the check-in/out state machine so the timeline, session chips, and EVV banner all transition
|
||||
* without a live b9 backend. Mirrors the real shapes + status/EVV/masking/gating semantics for a one-line
|
||||
* swap once conversion (b10) is live client-side (`USE_BOOKINGS_MOCK = false`).
|
||||
*/
|
||||
export const bookingsMockApi: BookingsApi = {
|
||||
getBookingDetail: async (id, viewerRole) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return forViewer(findBooking(id), viewerRole);
|
||||
},
|
||||
|
||||
listBookings: async (params: BookingListParams): Promise<Paginated<BookingListItemDto>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const matched = bookings
|
||||
.filter((b) => (params.status ? b.status === params.status : true))
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||
.map((b) => toListItem(b, params.role));
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? BOOKINGS_PAGE_SIZE;
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
|
||||
},
|
||||
|
||||
listTodaySessions: async (params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const day = params.date ?? isoDate(0);
|
||||
const items: BookingSessionListItemDto[] = [];
|
||||
for (const booking of bookings) {
|
||||
for (const session of booking.sessions) {
|
||||
if (session.scheduledDate !== day) continue;
|
||||
items.push({
|
||||
sessionId: session.id,
|
||||
bookingId: booking.id,
|
||||
sessionIndex: session.sessionIndex,
|
||||
patientName: booking.patientName,
|
||||
scheduledDate: session.scheduledDate,
|
||||
scheduledTimeStart: session.scheduledTimeStart,
|
||||
scheduledTimeEnd: session.scheduledTimeEnd,
|
||||
status: session.status,
|
||||
evvStatus: session.evvStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.scheduledTimeStart.localeCompare(b.scheduledTimeStart));
|
||||
const page = params.page ?? 1;
|
||||
const pageSize = params.pageSize ?? BOOKINGS_PAGE_SIZE;
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: items.slice(start, start + pageSize), total: items.length, page, pageSize };
|
||||
},
|
||||
|
||||
getSessionEvv: async (sessionId: number) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = verifications[sessionId];
|
||||
if (existing) return { ...existing };
|
||||
const { session } = findSession(sessionId);
|
||||
return pendingVerification(session);
|
||||
},
|
||||
|
||||
getCareInstructions: async (bookingId: number, viewerRole) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const booking = findBooking(bookingId);
|
||||
// The gated boundary — anyone but the assigned nurse/admin 404s (never leaks). The client UI gate
|
||||
// means this is a defence-in-depth path the customer should never even reach.
|
||||
if (viewerRole !== 'nurse') throw new ApiError(404, 'Not found', 'not_found');
|
||||
const record = care[booking.id];
|
||||
if (!record) throw new ApiError(404, 'Not found', 'not_found');
|
||||
return { ...record };
|
||||
},
|
||||
|
||||
checkInVisit: async (input: CheckInVisitInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const { booking, session } = findSession(input.bookingSessionId);
|
||||
if (session.status !== 'scheduled') throw new ApiError(409, 'Session is not startable', 'not_startable');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const hasCoords = input.latitude != null && input.longitude != null;
|
||||
const meters = hasCoords
|
||||
? distanceMeters(input.latitude as number, input.longitude as number, MOCK_EVV_REFERENCE_LAT, MOCK_EVV_REFERENCE_LNG)
|
||||
: null;
|
||||
// Advisory: true in range · false out-of-range (under review) · null when GPS was unavailable.
|
||||
const match = meters == null ? null : meters <= MOCK_EVV_TOLERANCE_METERS;
|
||||
|
||||
session.status = 'in_progress';
|
||||
session.evvStatus = 'checked_in';
|
||||
session.checkInAt = now;
|
||||
session.checkInAddressMatch = match;
|
||||
if (booking.status === 'confirmed') booking.status = 'in_progress';
|
||||
|
||||
const verification: VisitVerificationDto = {
|
||||
id: session.id,
|
||||
bookingSessionId: session.id,
|
||||
status: 'checked_in',
|
||||
checkInAt: now,
|
||||
checkInLat: input.latitude,
|
||||
checkInLng: input.longitude,
|
||||
checkOutAt: null,
|
||||
checkOutLat: null,
|
||||
checkOutLng: null,
|
||||
checkInAddressMatch: match,
|
||||
checkInDistanceMeters: meters == null ? null : Math.round(meters),
|
||||
};
|
||||
verifications[session.id] = verification;
|
||||
return { ...verification };
|
||||
},
|
||||
|
||||
checkOutVisit: async (input: CheckOutVisitInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const { booking, session } = findSession(input.bookingSessionId);
|
||||
if (session.evvStatus !== 'checked_in') throw new ApiError(400, 'No open check-in to close', 'no_open_check_in');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
session.status = 'completed';
|
||||
session.evvStatus = 'completed';
|
||||
session.checkOutAt = now;
|
||||
|
||||
const disputeEnd = new Date(Date.now() + DISPUTE_WINDOW_HOURS * 3_600_000).toISOString();
|
||||
// Payout eligibility is server truth (gated by the dispute window). The mock stamps it; the client
|
||||
// renders it and never recomputes it.
|
||||
session.payoutEligibleAt = disputeEnd;
|
||||
|
||||
const allSettled = booking.sessions.every((s) => s.status === 'completed' || s.status === 'cancelled');
|
||||
if (allSettled) {
|
||||
booking.status = 'completed';
|
||||
booking.completedAt = now;
|
||||
booking.disputeWindowEndsAt = disputeEnd;
|
||||
}
|
||||
|
||||
const verification = verifications[session.id] ?? pendingVerification(session);
|
||||
const updated: VisitVerificationDto = {
|
||||
...verification,
|
||||
status: 'completed',
|
||||
checkOutAt: now,
|
||||
checkOutLat: input.latitude,
|
||||
checkOutLng: input.longitude,
|
||||
};
|
||||
verifications[session.id] = updated;
|
||||
return { ...updated };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { serverFetch } from '@/lib/api/server';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type { BookingDetailDto } from '../types';
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
|
||||
/**
|
||||
* Server-side reads for the bookings domain — used to **prefetch the booking detail in an RSC** and hand
|
||||
* it to the client tree via `initialData`, removing a client round-trip on first paint (f0 pattern). Only
|
||||
* the detail is prefetched; the EVV mutations and the gated care-instructions read stay on the client.
|
||||
*
|
||||
* Not wired into the pages while the domain is mock-primary (an RSC cannot read the in-memory mock store);
|
||||
* it becomes the first-paint source the moment `USE_BOOKINGS_MOCK` flips to `false`. Kept separate from
|
||||
* `clientApi.ts` — Next.js enforces the `serverFetch`/`clientFetch` environment boundary at build time.
|
||||
*/
|
||||
export const bookingsServerApi = {
|
||||
getBookingDetail: async (id: number): Promise<BookingDetailDto> =>
|
||||
unwrap(await serverFetch<ApiEnvelope<BookingDetailDto>>(`${BOOKINGS}/get/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* When true, the bookings domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `BookingsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** The b9 endpoints are fully specified in swagger, but a booking only
|
||||
* exists after `bookings/convert` runs against an `accepted_awaiting_payment` **request** that was paid —
|
||||
* and both upstreams are not real on the client yet: `services/bookingRequests` is mock-primary
|
||||
* (f7, `USE_BOOKING_REQUESTS_MOCK`) and card capture (b10) isn't wired. So a real `bookings/list` would
|
||||
* return nothing to render. The mock seeds confirmed bookings + sessions + care + EVV and drives the
|
||||
* check-in/out state machine so the timeline/session/banner transitions demo end-to-end. The real
|
||||
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single
|
||||
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
|
||||
*/
|
||||
export const USE_BOOKINGS_MOCK = true;
|
||||
|
||||
/**
|
||||
* The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and
|
||||
* on EVV mutations. Keep the stale window modest and **invalidate on every EVV mutation** so the timeline
|
||||
* reflects server truth immediately rather than waiting it out.
|
||||
*/
|
||||
export const BOOKING_DETAIL_STALE_TIME = 30 * 1000;
|
||||
export const BOOKING_DETAIL_GC_TIME = 5 * 60 * 1000;
|
||||
|
||||
/** The bookings list changes only on new conversions/transitions — a short stale window is plenty. */
|
||||
export const BOOKING_LIST_STALE_TIME = 30 * 1000;
|
||||
|
||||
/** A nurse's "today" feed changes as they clock in/out — kept fresh, invalidated on every EVV mutation. */
|
||||
export const TODAY_SESSIONS_STALE_TIME = 15 * 1000;
|
||||
|
||||
/** Per-session EVV detail is immutable once completed; a short window covers the checked-in interval. */
|
||||
export const SESSION_EVV_STALE_TIME = 15 * 1000;
|
||||
|
||||
/** Care instructions are effectively static per booking (edited rarely by the customer) — session-cached. */
|
||||
export const CARE_INSTRUCTIONS_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/** api-conventions default page size; a bookings/today page. */
|
||||
export const BOOKINGS_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* EVV GPS capture mode for the `ILocationProvider` seam (`evv/locationProvider.ts`).
|
||||
*
|
||||
* - `off` → the **real** browser Geolocation provider.
|
||||
* - `in_range` → mock returns coordinates that fall inside the seeded booking's tolerance (match `true`).
|
||||
* - `out_of_range` → mock returns far coordinates (advisory match `false`).
|
||||
* - `denied` → mock returns `null` (permission denied / unavailable) — the nurse still checks in.
|
||||
*
|
||||
* Default: while the bookings domain is mock-primary, real browser GPS would never fall near the seeded
|
||||
* Tehran address, so the happy path defaults to `in_range` so «موقعیت تایید شد» is demoable out of the box.
|
||||
* Override with `NEXT_PUBLIC_EVV_MOCK_GPS`; set to `off` (or flip `USE_BOOKINGS_MOCK`) for real capture.
|
||||
*/
|
||||
export type EvvGpsMode = 'off' | 'in_range' | 'out_of_range' | 'denied';
|
||||
|
||||
export const EVV_GPS_MODE: EvvGpsMode =
|
||||
(process.env.NEXT_PUBLIC_EVV_MOCK_GPS as EvvGpsMode | undefined) ?? (USE_BOOKINGS_MOCK ? 'in_range' : 'off');
|
||||
|
||||
/**
|
||||
* The seeded booking's reference location + advisory tolerance, shared by the mock `ILocationProvider`
|
||||
* (its `in_range` coords sit on this point) and the mock `BookingsApi` (it computes the advisory
|
||||
* `checkInAddressMatch` against this point). Real address-match math lives server-side behind the
|
||||
* backend geocoding seam — this is mock-only. A Tehran (Saadat-Abad) point, matching the seeded address.
|
||||
*/
|
||||
export const MOCK_EVV_REFERENCE_LAT = 35.7869;
|
||||
export const MOCK_EVV_REFERENCE_LNG = 51.3699;
|
||||
export const MOCK_EVV_TOLERANCE_METERS = 150;
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
EVV_GPS_MODE,
|
||||
MOCK_EVV_REFERENCE_LAT,
|
||||
MOCK_EVV_REFERENCE_LNG,
|
||||
type EvvGpsMode,
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* `ILocationProvider` — the one client seam this phase introduces. It wraps the browser Geolocation API
|
||||
* for EVV GPS capture so the check-in/out flow is testable without a device and so the
|
||||
* denied/unavailable path can be exercised deterministically.
|
||||
*
|
||||
* `getCurrentPosition` **never rejects** — a denied/unavailable/timed-out fix resolves to `null`. The
|
||||
* product rule is that a GPS problem is **advisory, never a hard stop**: the caller submits the check-in
|
||||
* with `null` coordinates (flagged server-side) rather than blocking the visit. Selection between the
|
||||
* real and mock implementations is by `EVV_GPS_MODE` (`NEXT_PUBLIC_EVV_MOCK_GPS`), never scattered checks.
|
||||
*
|
||||
* Registered in `dev/shared-working-context/reports/mocks-registry.md`. Server-side GPS/address-match
|
||||
* math lives behind the backend's geocoding seam — this seam only *captures* the position.
|
||||
*/
|
||||
export interface GeoPosition {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface ILocationProvider {
|
||||
/** Resolves the current position, or `null` when it can't be obtained (denied/unavailable/timeout). */
|
||||
getCurrentPosition(): Promise<GeoPosition | null>;
|
||||
}
|
||||
|
||||
const GEOLOCATION_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Real provider — `navigator.geolocation.getCurrentPosition`, resolving `null` on any failure. */
|
||||
const realLocationProvider: ILocationProvider = {
|
||||
getCurrentPosition: () =>
|
||||
new Promise<GeoPosition | null>((resolve) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),
|
||||
() => resolve(null), // denied / unavailable / timeout — advisory, never a throw
|
||||
{ enableHighAccuracy: true, timeout: GEOLOCATION_TIMEOUT_MS, maximumAge: 0 },
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
const MOCK_LATENCY_MS = 500;
|
||||
|
||||
// ~5 km offset from the reference — comfortably outside any sane tolerance (advisory mismatch).
|
||||
const OUT_OF_RANGE_DEGREE_OFFSET = 0.05;
|
||||
|
||||
/** Mock provider — canned coordinates per mode, with a small latency to exercise the "acquiring…" state. */
|
||||
function makeMockLocationProvider(mode: Exclude<EvvGpsMode, 'off'>): ILocationProvider {
|
||||
return {
|
||||
getCurrentPosition: () =>
|
||||
new Promise<GeoPosition | null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (mode === 'denied') {
|
||||
resolve(null);
|
||||
} else if (mode === 'out_of_range') {
|
||||
resolve({
|
||||
latitude: MOCK_EVV_REFERENCE_LAT + OUT_OF_RANGE_DEGREE_OFFSET,
|
||||
longitude: MOCK_EVV_REFERENCE_LNG + OUT_OF_RANGE_DEGREE_OFFSET,
|
||||
});
|
||||
} else {
|
||||
resolve({ latitude: MOCK_EVV_REFERENCE_LAT, longitude: MOCK_EVV_REFERENCE_LNG });
|
||||
}
|
||||
}, MOCK_LATENCY_MS);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The selected provider — the single seam the EVV controller imports. */
|
||||
export const locationProvider: ILocationProvider =
|
||||
EVV_GPS_MODE === 'off' ? realLocationProvider : makeMockLocationProvider(EVV_GPS_MODE);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { BOOKING_DETAIL_GC_TIME, BOOKING_DETAIL_STALE_TIME } from '../constants';
|
||||
import type { BookingViewerRole } from '../types';
|
||||
|
||||
/**
|
||||
* The booking header + money summary + embedded sessions + timeline status. `viewerRole` drives the
|
||||
* mock's address masking (the real server infers it from auth). A modest `staleTime` keeps the timeline
|
||||
* fresh across status transitions; EVV mutations **invalidate** this key so the timeline and session rows
|
||||
* reflect server truth immediately. Enabled only when an id is present.
|
||||
*/
|
||||
export function useBookingDetail(id: number | undefined, viewerRole: BookingViewerRole) {
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.bookingDetail(id ?? -1),
|
||||
queryFn: () => bookingsApi.getBookingDetail(id as number, viewerRole),
|
||||
enabled: id != null && id > 0,
|
||||
staleTime: BOOKING_DETAIL_STALE_TIME,
|
||||
gcTime: BOOKING_DETAIL_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { BOOKING_LIST_STALE_TIME, BOOKINGS_PAGE_SIZE } from '../constants';
|
||||
import type { BookingListParams, BookingListRole, BookingStatus } from '../types';
|
||||
|
||||
/**
|
||||
* The role-scoped "My bookings" list (`bookings/list`). The customer رزروها tab reads `role='customer'`;
|
||||
* the nurse reads `role='nurse'`. The role + status filter are part of the query key so each scope is a
|
||||
* distinct cache entry. A conversion/transition invalidates `bookingKeys.lists()`.
|
||||
*/
|
||||
export function useBookingList(
|
||||
role: BookingListRole,
|
||||
options?: { status?: BookingStatus; page?: number; pageSize?: number },
|
||||
) {
|
||||
const params: BookingListParams = {
|
||||
role,
|
||||
status: options?.status,
|
||||
page: options?.page ?? 1,
|
||||
pageSize: options?.pageSize ?? BOOKINGS_PAGE_SIZE,
|
||||
};
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.list(params),
|
||||
queryFn: () => bookingsApi.listBookings(params),
|
||||
staleTime: BOOKING_LIST_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { BOOKING_DETAIL_STALE_TIME } from '../constants';
|
||||
import type { BookingSessionDto, BookingViewerRole } from '../types';
|
||||
|
||||
/**
|
||||
* The session schedule for a booking. Sessions are **embedded** in the booking detail (the contract
|
||||
* exposes no standalone per-booking session-list endpoint), so this shares the `bookingDetail(id)` query
|
||||
* key + queryFn and `select`s `sessions` — it dedupes with `useBookingDetail` and never fires a second
|
||||
* request. Invalidating `bookingDetail(id)` (which the EVV mutations do) refreshes it automatically.
|
||||
*/
|
||||
export function useBookingSessions(id: number | undefined, viewerRole: BookingViewerRole) {
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.bookingDetail(id ?? -1),
|
||||
queryFn: () => bookingsApi.getBookingDetail(id as number, viewerRole),
|
||||
enabled: id != null && id > 0,
|
||||
staleTime: BOOKING_DETAIL_STALE_TIME,
|
||||
select: (detail): BookingSessionDto[] => detail.sessions,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { CARE_INSTRUCTIONS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The gated stage-2 care-instructions read (`bookings/care_instructions/{id}`) — the two-stage clinical
|
||||
* disclosure boundary. **`enabled` is the hard UI gate:** the caller passes `enabled` only when the
|
||||
* booking is `confirmed`+ **and** the viewer is the assigned nurse (or admin). When disabled the query
|
||||
* **never fires** — the client must not even request instructions it has no right to (a 403/404 from the
|
||||
* server is a defect path, not the design). Always reads with the `nurse` viewer role.
|
||||
*/
|
||||
export function useCareInstructions(bookingId: number | undefined, options: { enabled: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.careInstructions(bookingId ?? -1),
|
||||
queryFn: () => bookingsApi.getCareInstructions(bookingId as number, 'nurse'),
|
||||
enabled: options.enabled && bookingId != null && bookingId > 0,
|
||||
staleTime: CARE_INSTRUCTIONS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import type { CheckInVisitInput } from '../types';
|
||||
|
||||
/**
|
||||
* Nurse EVV check-in. On success it **invalidates** the booking detail (timeline + the embedded session
|
||||
* flips to `in_progress`), the today feed (the row's CTA state), and the bookings lists, and primes the
|
||||
* per-session EVV cache with the returned verification so the banner is instant. No client-side status or
|
||||
* money math — the server response is the single source. `bookingId` is passed alongside the input so the
|
||||
* invalidation is surgical (the verification payload carries only the session id).
|
||||
*
|
||||
* A GPS mismatch is advisory (`checkInAddressMatch = false`) and still succeeds — the banner warns, it
|
||||
* never blocks. Fetch-layer errors (401/403/5xx) are toasted by `clientFetch`; the caller surfaces only
|
||||
* domain-specific 4xx (e.g. a `409` not-startable).
|
||||
*/
|
||||
export function useCheckInVisit() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ input }: { input: CheckInVisitInput; bookingId: number }) => bookingsApi.checkInVisit(input),
|
||||
onSuccess: (verification, { input, bookingId }) => {
|
||||
queryClient.setQueryData(bookingKeys.sessionEvv(input.bookingSessionId), verification);
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.sessionEvv(input.bookingSessionId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.todayLists() });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import type { CheckOutVisitInput } from '../types';
|
||||
|
||||
/**
|
||||
* Nurse EVV check-out — must follow an open check-in (a `400 no_open_check_in` otherwise, surfaced by the
|
||||
* caller, not the fetch layer). On success the session flips to `completed` and, when it's the last
|
||||
* session, the booking completes + the dispute window opens — all server-driven. It invalidates the same
|
||||
* keys as check-in so the timeline, session row, and today feed reflect the new server state; no
|
||||
* client-side payout-eligibility math (`payoutEligibleAt` is server truth).
|
||||
*/
|
||||
export function useCheckOutVisit() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ input }: { input: CheckOutVisitInput; bookingId: number }) => bookingsApi.checkOutVisit(input),
|
||||
onSuccess: (verification, { input, bookingId }) => {
|
||||
queryClient.setQueryData(bookingKeys.sessionEvv(input.bookingSessionId), verification);
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.sessionEvv(input.bookingSessionId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.todayLists() });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { SESSION_EVV_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* Per-session EVV detail (`booking_sessions/evv/{id}`) — the `checkInAt` + advisory `checkInAddressMatch`
|
||||
* the EVV banner renders on the nurse's day surface (the booking-detail card reads these from the
|
||||
* embedded session instead, so it doesn't need this). `enabled` lets the day surface fetch it only for
|
||||
* sessions that already have EVV activity. EVV mutations `setQueryData` this key so the banner is instant.
|
||||
*/
|
||||
export function useSessionEvv(sessionId: number | undefined, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.sessionEvv(sessionId ?? -1),
|
||||
queryFn: () => bookingsApi.getSessionEvv(sessionId as number),
|
||||
enabled: (options?.enabled ?? true) && sessionId != null && sessionId > 0,
|
||||
staleTime: SESSION_EVV_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingsApi } from '../apis';
|
||||
import { bookingKeys } from '../keys';
|
||||
import { BOOKINGS_PAGE_SIZE, TODAY_SESSIONS_STALE_TIME } from '../constants';
|
||||
import type { TodaySessionsParams } from '../types';
|
||||
|
||||
/**
|
||||
* The nurse's "today" session feed (`booking_sessions/today`) — the ویزیت امروز surface. Kept fresh (short
|
||||
* `staleTime`) and **invalidated on every EVV mutation** so a check-in/out flips the row's CTA state
|
||||
* without a manual refresh. `date` omitted = the server's today.
|
||||
*/
|
||||
export function useTodaySessions(options?: { date?: string; page?: number; pageSize?: number }) {
|
||||
const params: TodaySessionsParams = {
|
||||
date: options?.date,
|
||||
page: options?.page ?? 1,
|
||||
pageSize: options?.pageSize ?? BOOKINGS_PAGE_SIZE,
|
||||
};
|
||||
return useQuery({
|
||||
queryKey: bookingKeys.today(params),
|
||||
queryFn: () => bookingsApi.listTodaySessions(params),
|
||||
staleTime: TODAY_SESSIONS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bookings domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
|
||||
* types/keys/apis/evv directly from their files when needed. This is the **post-payment** engagement half
|
||||
* (booking detail, sessions, EVV, gated care); the **pre-payment** request half lives in
|
||||
* `services/bookingRequests`.
|
||||
*/
|
||||
export { useBookingDetail } from './hooks/useBookingDetail';
|
||||
export { useBookingSessions } from './hooks/useBookingSessions';
|
||||
export { useBookingList } from './hooks/useBookingList';
|
||||
export { useTodaySessions } from './hooks/useTodaySessions';
|
||||
export { useSessionEvv } from './hooks/useSessionEvv';
|
||||
export { useCareInstructions } from './hooks/useCareInstructions';
|
||||
export { useCheckInVisit } from './hooks/useCheckInVisit';
|
||||
export { useCheckOutVisit } from './hooks/useCheckOutVisit';
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { BookingListParams, TodaySessionsParams } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the bookings domain (hierarchical, per the `services/{domain}` pattern).
|
||||
*
|
||||
* Sessions are **embedded** in `BookingDetailDto.sessions` (the contract exposes no standalone
|
||||
* per-booking session-list endpoint), so `bookingSessions(id)` is an intentional **alias** of
|
||||
* `bookingDetail(id)` — the session list is a `select` over the one detail query, never a second fetch.
|
||||
* Invalidating `bookingDetail(id)` therefore refreshes the timeline **and** the sessions in one shot.
|
||||
* `sessionEvv`/`today` are their own endpoints and their own keys.
|
||||
*/
|
||||
export const bookingKeys = {
|
||||
all: ['bookings'] as const,
|
||||
|
||||
lists: () => [...bookingKeys.all, 'list'] as const,
|
||||
list: (params: BookingListParams) =>
|
||||
[...bookingKeys.lists(), params.role, params.status ?? 'all', params.page ?? 1, params.pageSize ?? 0] as const,
|
||||
|
||||
details: () => [...bookingKeys.all, 'detail'] as const,
|
||||
bookingDetail: (id: number) => [...bookingKeys.details(), id] as const,
|
||||
/** Alias of `bookingDetail` — sessions live inside the detail payload (no separate endpoint). */
|
||||
bookingSessions: (id: number) => bookingKeys.bookingDetail(id),
|
||||
|
||||
todayLists: () => [...bookingKeys.all, 'today'] as const,
|
||||
today: (params: TodaySessionsParams) =>
|
||||
[...bookingKeys.todayLists(), params.date ?? 'today', params.page ?? 1, params.pageSize ?? 0] as const,
|
||||
|
||||
evv: () => [...bookingKeys.all, 'evv'] as const,
|
||||
sessionEvv: (sessionId: number) => [...bookingKeys.evv(), sessionId] as const,
|
||||
|
||||
care: () => [...bookingKeys.all, 'care'] as const,
|
||||
careInstructions: (bookingId: number) => [...bookingKeys.care(), bookingId] as const,
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Bookings domain — the **post-payment engagement** layer of the lifecycle (b9). An
|
||||
* `accepted_awaiting_payment` request that is paid converts (`bookings/convert`) into a `bookings` row
|
||||
* with N `booking_sessions`, an encrypted `booking_care_instructions`, and per-session EVV
|
||||
* (`visit_verifications`). This is the sibling of the **pre-payment** `services/bookingRequests` domain
|
||||
* (b8) — a distinct contract (`dev/contracts/domains/bookings-evv.md`), distinct routes
|
||||
* (`/api/v1/bookings/*` + `/api/v1/booking_sessions/*`), distinct shapes — **not** a rename of it.
|
||||
*
|
||||
* Shapes mirror the b9 swagger 1:1 (camelCase; `clientFetch` unwraps the `ApiEnvelope<T>`, so these are
|
||||
* the post-`unwrap()` payloads). Load-bearing semantics (contract + phase §5):
|
||||
* - **Money is display-only and never computed.** `grossPriceIrr = balinyaarCommissionIrr +
|
||||
* nursePayoutAmount` is guaranteed server-side; render the three IRR **digit-strings** as-is through
|
||||
* the money util — never sum, re-split, or derive them client-side.
|
||||
* - **The status timeline is server truth.** `BookingDetailDto.status` is the single source; never
|
||||
* advance/infer a step client-side. After an EVV mutation, invalidate and re-render from the server.
|
||||
* - **Two-stage clinical disclosure.** `CareInstructionsDto` is decrypted and returned **only** to the
|
||||
* assigned nurse (or admin) on a `confirmed`+ booking; the client must not even request it otherwise.
|
||||
* - **EVV mismatch is advisory, never a block.** `checkInAddressMatch` (`true` in range · `false`
|
||||
* out-of-range/under-review · `null` GPS unavailable) drives a banner, never a gate.
|
||||
* - **Payout-eligibility is server truth.** `payoutEligibleAt` is gated by the dispute window
|
||||
* server-side; render it, never recompute it.
|
||||
*/
|
||||
|
||||
/** `BookingStatus` — the seven-state booking lifecycle (contract enum, stable string codes). */
|
||||
export type BookingStatus =
|
||||
| 'pending_payment'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'disputed'
|
||||
| 'closed'
|
||||
| 'cancelled';
|
||||
|
||||
/** `BookingSessionStatus` — per-visit lifecycle (contract enum). */
|
||||
export type BookingSessionStatus = 'scheduled' | 'in_progress' | 'completed' | 'missed' | 'cancelled';
|
||||
|
||||
/** `VisitVerificationStatus` — the EVV state of a session (contract enum). */
|
||||
export type VisitVerificationStatus = 'pending' | 'checked_in' | 'completed';
|
||||
|
||||
/** Which "my bookings" scope to read — `all` is admin-only server-side (contract `list?role=`). */
|
||||
export type BookingListRole = 'customer' | 'nurse' | 'all';
|
||||
|
||||
/**
|
||||
* The viewer's actor role for a booking-detail read. Drives (a) the mock's address-snapshot masking
|
||||
* (the nurse view omits it) and (b) the client-side care-instructions **UI gate**. The real server
|
||||
* infers the view from auth + tenancy; this is passed for the mock and the gate. Admin is out of scope
|
||||
* this phase (its console is f15).
|
||||
*/
|
||||
export type BookingViewerRole = 'customer' | 'nurse';
|
||||
|
||||
/**
|
||||
* The happy-path timeline order rendered by `BookingStatusTimeline`. Terminal branches
|
||||
* (`disputed`/`closed`/`cancelled`) are shown distinctly, off this line — see `isBookingTerminalBranch`.
|
||||
*/
|
||||
export const BOOKING_TIMELINE_ORDER: readonly BookingStatus[] = [
|
||||
'pending_payment',
|
||||
'confirmed',
|
||||
'in_progress',
|
||||
'completed',
|
||||
] as const;
|
||||
|
||||
/** `disputed`/`closed`/`cancelled` leave the happy path — rendered as a distinct terminal state. */
|
||||
export function isBookingTerminalBranch(status: BookingStatus): boolean {
|
||||
return status === 'disputed' || status === 'closed' || status === 'cancelled';
|
||||
}
|
||||
|
||||
/**
|
||||
* `confirmed` or beyond — the booking has been paid/converted. Gates the care-instructions read
|
||||
* (two-stage disclosure) and the "upcoming sessions" content. `pending_payment` and `cancelled` are
|
||||
* **not** confirmed+; `disputed`/`closed`/`completed` are (they follow confirmation).
|
||||
*/
|
||||
export function isBookingConfirmedOrBeyond(status: BookingStatus): boolean {
|
||||
return (
|
||||
status === 'confirmed' ||
|
||||
status === 'in_progress' ||
|
||||
status === 'completed' ||
|
||||
status === 'disputed' ||
|
||||
status === 'closed'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active step index for the 4-step timeline. A terminal branch reports the step it left from
|
||||
* (`cancelled` from wherever, rendered distinctly), so callers should check `isBookingTerminalBranch`
|
||||
* first and only use this for the happy path.
|
||||
*/
|
||||
export function bookingTimelineActiveIndex(status: BookingStatus): number {
|
||||
const idx = BOOKING_TIMELINE_ORDER.indexOf(status);
|
||||
if (idx >= 0) return idx;
|
||||
// disputed/closed follow completion → sit the line at "completed"; cancelled is rendered distinctly
|
||||
// (off the line) by the timeline, so its index is only a harmless fallback.
|
||||
if (status === 'disputed' || status === 'closed') return BOOKING_TIMELINE_ORDER.indexOf('completed');
|
||||
return BOOKING_TIMELINE_ORDER.indexOf('confirmed');
|
||||
}
|
||||
|
||||
/** A per-visit session (embedded in `BookingDetailDto.sessions`; `BookingSessionSummaryDto` on the wire). */
|
||||
export interface BookingSessionDto {
|
||||
id: number;
|
||||
sessionIndex: number;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
/** `HH:mm:ss`. */
|
||||
scheduledTimeStart: string;
|
||||
scheduledTimeEnd: string;
|
||||
status: BookingSessionStatus;
|
||||
/** IRR digit-string — this session's share of the nurse payout (Σ over sessions = nursePayoutAmount). */
|
||||
visitPayoutAmount: string;
|
||||
/** Server truth: set at check-out, gated by the dispute window. `null` until eligible. Never computed. */
|
||||
payoutEligibleAt: string | null;
|
||||
evvStatus: VisitVerificationStatus;
|
||||
/** Server `checked_in_at` — the banner renders its Shamsi/clock from this, never a client clock. */
|
||||
checkInAt: string | null;
|
||||
checkOutAt: string | null;
|
||||
/** Advisory: `true` in range · `false` out-of-range (under review) · `null` GPS unavailable. */
|
||||
checkInAddressMatch: boolean | null;
|
||||
}
|
||||
|
||||
/** The booking header + money summary + embedded sessions (`bookings/get`, `convert`, `transition`). */
|
||||
export interface BookingDetailDto {
|
||||
id: number;
|
||||
bookingRequestId: number;
|
||||
status: BookingStatus;
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
patientId: number;
|
||||
patientName: string;
|
||||
variantId: number;
|
||||
variantSnapshotJson: string;
|
||||
customerAddressId: number;
|
||||
/** Full snapshot for the customer/admin; **`null` in the nurse view** (masked server-side). */
|
||||
addressSnapshotJson: string | null;
|
||||
/** The three money amounts — IRR digit-strings; `gross = commission + payout`, guaranteed server-side. */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
/** PSP fee (nullable) — a checkout concern, surfaced here for completeness. */
|
||||
pspFeeAmount: string | null;
|
||||
/** Snapshotted commission rate (decimal). */
|
||||
platformFeeRate: number;
|
||||
sessionCount: number;
|
||||
scheduledDate: string;
|
||||
scheduledTimeStart: string;
|
||||
scheduledTimeEnd: string;
|
||||
confirmedAt: string | null;
|
||||
completedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
cancelledBy: string | null;
|
||||
cancellationReason: string | null;
|
||||
cancellationPolicyCode: string | null;
|
||||
cancellationRefundPercentage: number | null;
|
||||
refundableAmountIrr: string | null;
|
||||
/** Set when the booking completes; the payout gate reads from this. `null` before completion. */
|
||||
disputeWindowEndsAt: string | null;
|
||||
createdAt: string;
|
||||
sessions: BookingSessionDto[];
|
||||
}
|
||||
|
||||
/** A row in the role-scoped "My bookings" list (`bookings/list`). `amountIrr` = gross (customer) / payout (nurse). */
|
||||
export interface BookingListItemDto {
|
||||
id: number;
|
||||
status: BookingStatus;
|
||||
counterpartyName: string;
|
||||
scheduledDate: string;
|
||||
sessionCount: number;
|
||||
amountIrr: string;
|
||||
disputeWindowEndsAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A row in the nurse's "today" session feed (`booking_sessions/today`) with EVV CTA state. */
|
||||
export interface BookingSessionListItemDto {
|
||||
sessionId: number;
|
||||
bookingId: number;
|
||||
sessionIndex: number;
|
||||
patientName: string;
|
||||
scheduledDate: string;
|
||||
scheduledTimeStart: string;
|
||||
scheduledTimeEnd: string;
|
||||
status: BookingSessionStatus;
|
||||
evvStatus: VisitVerificationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session EVV detail (`booking_sessions/evv/{id}`). Raw GPS (`*Lat`/`*Lng`/`checkInDistanceMeters`)
|
||||
* is gated to the owning nurse + admin server-side. The banner needs only `checkInAt` +
|
||||
* `checkInAddressMatch`; the coordinates are informational.
|
||||
*/
|
||||
export interface VisitVerificationDto {
|
||||
id: number;
|
||||
bookingSessionId: number;
|
||||
status: VisitVerificationStatus;
|
||||
checkInAt: string | null;
|
||||
checkInLat: number | null;
|
||||
checkInLng: number | null;
|
||||
checkOutAt: string | null;
|
||||
checkOutLat: number | null;
|
||||
checkOutLng: number | null;
|
||||
checkInAddressMatch: boolean | null;
|
||||
checkInDistanceMeters: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decrypted stage-2 clinical/logistical context (`bookings/care_instructions/{id}`). Encrypted at
|
||||
* rest; **present only in the gated read** to the assigned nurse (or admin) post-confirmation. All fields
|
||||
* are free-text and nullable (the write path is `bookings/submit_care_instructions/{id}`, customer/admin).
|
||||
*/
|
||||
export interface CareInstructionsDto {
|
||||
bookingId: number;
|
||||
currentConditions: string | null;
|
||||
medications: string | null;
|
||||
allergies: string | null;
|
||||
specialInstructions: string | null;
|
||||
emergencyContactName: string | null;
|
||||
emergencyContactPhone: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* EVV check-in command. `latitude`/`longitude` are **nullable** — a GPS-denied nurse still checks in
|
||||
* (flagged, never blocked). `capturedAt` is the client capture instant; the server timestamps the
|
||||
* authoritative `checkInAt`, so the real client sends only the coordinates (the contract command carries
|
||||
* `latitude`/`longitude`/`sessionId`). Kept on the input for the mock's banner + audit fidelity.
|
||||
*/
|
||||
export interface CheckInVisitInput {
|
||||
bookingSessionId: number;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
/** ISO instant the client captured position; server time is authoritative. */
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
/** EVV check-out command — same shape; must follow an open check-in (a `400` otherwise). */
|
||||
export type CheckOutVisitInput = CheckInVisitInput;
|
||||
|
||||
/** `bookings/list` query params (role-scoped, paginated, optional status filter). */
|
||||
export interface BookingListParams extends PageParams {
|
||||
role: BookingListRole;
|
||||
status?: BookingStatus;
|
||||
}
|
||||
|
||||
/** `booking_sessions/today` query params (a nurse's day; default = all today). */
|
||||
export interface TodaySessionsParams extends PageParams {
|
||||
/** ISO date `YYYY-MM-DD`; omitted = the server's "today". */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookings API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_BOOKINGS_MOCK`), never scattered `if (mock)` checks.
|
||||
*
|
||||
* `getBookingDetail`/`getCareInstructions` take an optional `viewerRole` that only the mock uses (address
|
||||
* masking + the care-instructions 404 boundary); the real client infers the view from auth and ignores it.
|
||||
*/
|
||||
export interface BookingsApi {
|
||||
getBookingDetail(id: number, viewerRole?: BookingViewerRole): Promise<BookingDetailDto>;
|
||||
listBookings(params: BookingListParams): Promise<Paginated<BookingListItemDto>>;
|
||||
listTodaySessions(params: TodaySessionsParams): Promise<Paginated<BookingSessionListItemDto>>;
|
||||
getSessionEvv(sessionId: number): Promise<VisitVerificationDto>;
|
||||
getCareInstructions(bookingId: number, viewerRole?: BookingViewerRole): Promise<CareInstructionsDto>;
|
||||
checkInVisit(input: CheckInVisitInput): Promise<VisitVerificationDto>;
|
||||
checkOutVisit(input: CheckOutVisitInput): Promise<VisitVerificationDto>;
|
||||
}
|
||||
Reference in New Issue
Block a user