backend phase 15 & frontend phase 8
This commit is contained in:
+6
-3
@@ -126,7 +126,8 @@ client/
|
||||
│ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile — badges (TrustBadge + نظام پرستاری) + attribute chips + ServicePriceRow list + latest review; "درخواست رزرو" hands off to /bookings/request (f7)
|
||||
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
|
||||
│ │ │ ├── bookings/
|
||||
│ │ │ │ ├── page.tsx # /bookings
|
||||
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
|
||||
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer")
|
||||
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
|
||||
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards
|
||||
│ │ │ │ └── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id)
|
||||
@@ -149,7 +150,7 @@ client/
|
||||
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
|
||||
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
|
||||
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
|
||||
│ │ │ └── visits/page.tsx # /nurse/visits (EVV)
|
||||
│ │ │ └── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
|
||||
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
|
||||
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
|
||||
│ │ ├── page.tsx # /admin (overview)
|
||||
@@ -179,6 +180,7 @@ client/
|
||||
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
|
||||
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
|
||||
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
|
||||
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
|
||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
|
||||
├── i18n/
|
||||
@@ -231,6 +233,7 @@ client/
|
||||
│ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters
|
||||
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
|
||||
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
|
||||
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
|
||||
│ └── {domain}/
|
||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||
@@ -324,7 +327,7 @@ async function MyServerComponent() {
|
||||
- `'catalog'` — **shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side)
|
||||
- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm)
|
||||
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
|
||||
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions); consumed by the C4/C5 pages, the nurse requests pages, and the shared `BookingRequestSummaryCard`
|
||||
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
|
||||
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
|
||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
// Learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// next/jest loads .env / .env.test but not .env.development, so NEXT_PUBLIC_API_URL (required by
|
||||
// `@/config`) is absent in tests. Any test that renders a component using a `services/{domain}` hook pulls
|
||||
// the real `clientApi` → `@/config` at import time, which throws without this. Provide a harmless default.
|
||||
process.env.NEXT_PUBLIC_API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:5002';
|
||||
|
||||
// To get 'next/router' working with tests
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
|
||||
|
||||
+83
-1
@@ -474,7 +474,89 @@
|
||||
"status_rejected_by_nurse": "Declined by nurse",
|
||||
"status_expired_no_response": "No response in time",
|
||||
"status_payment_deadline_expired": "Payment window lapsed",
|
||||
"status_cancelled_by_customer": "Cancelled"
|
||||
"status_cancelled_by_customer": "Cancelled",
|
||||
|
||||
"bd_title": "Booking",
|
||||
"bd_ref": "Booking #{id}",
|
||||
"bd_not_found_title": "Booking not found",
|
||||
"bd_not_found_body": "This booking doesn't exist or isn't yours.",
|
||||
"bd_my_bookings": "My bookings",
|
||||
|
||||
"timeline_title": "Progress",
|
||||
"bstatus_pending_payment": "Pending payment",
|
||||
"bstatus_confirmed": "Confirmed",
|
||||
"bstatus_in_progress": "In progress",
|
||||
"bstatus_completed": "Completed",
|
||||
"bstatus_disputed": "Under review",
|
||||
"bstatus_closed": "Closed",
|
||||
"bstatus_cancelled": "Cancelled",
|
||||
|
||||
"confirmed_note": "Your booking is confirmed. The visits below are scheduled.",
|
||||
"in_progress_note": "A visit is currently underway.",
|
||||
"completed_note": "All visits are complete.",
|
||||
"dispute_window_note": "Payment is released to the nurse after the review window closes on {date}.",
|
||||
"cancelled_note": "This booking was cancelled.",
|
||||
"disputed_note": "This booking is under review by our support team.",
|
||||
|
||||
"sessions_title": "Visit schedule",
|
||||
"session_index": "Visit {n}",
|
||||
"session_count": "{count, plural, =0 {no visits} one {# visit} other {# visits}}",
|
||||
"sstatus_scheduled": "Scheduled",
|
||||
"sstatus_in_progress": "In progress",
|
||||
"sstatus_completed": "Completed",
|
||||
"sstatus_missed": "Missed",
|
||||
"sstatus_cancelled": "Cancelled",
|
||||
"session_elapsed": "On site {duration}",
|
||||
"session_payout": "Visit payout",
|
||||
|
||||
"money_title": "Payment summary",
|
||||
"money_gross": "Service cost",
|
||||
"money_commission": "Balinyaar fee",
|
||||
"money_payout": "Nurse payout",
|
||||
"money_nurse_earning": "Your earning",
|
||||
|
||||
"evv_today_title": "Today's visit",
|
||||
"evv_visits_title": "Today's visits",
|
||||
"evv_visits_subtitle": "Clock in and out of each visit with EVV.",
|
||||
"evv_no_visits": "No visits scheduled for today.",
|
||||
"evv_check_in": "Check in (EVV)",
|
||||
"evv_check_out": "Check out (EVV)",
|
||||
"evv_checking_in": "Checking in…",
|
||||
"evv_checking_out": "Checking out…",
|
||||
"evv_acquiring_location": "Getting your location…",
|
||||
"evv_banner_in_range": "Checked in {time} · location confirmed (EVV)",
|
||||
"evv_banner_out_of_range": "Checked in {time} · location out of range (under review)",
|
||||
"evv_banner_no_gps": "Checked in {time} · location not captured",
|
||||
"evv_checked_out_at": "Checked out {time}",
|
||||
"evv_gps_denied_note": "Location permission was denied — you can still record the visit; it's flagged for review.",
|
||||
"evv_no_open_check_in": "There's no open check-in to close.",
|
||||
"evv_not_startable": "This visit can't be started yet.",
|
||||
"evv_checked_in_toast": "Check-in recorded.",
|
||||
"evv_checked_out_toast": "Check-out recorded.",
|
||||
"evv_check_in_error": "Couldn't record the check-in. Please try again.",
|
||||
"evv_check_out_error": "Couldn't record the check-out. Please try again.",
|
||||
"evv_nurse_view": "Nurse view",
|
||||
"view_booking": "View booking",
|
||||
|
||||
"care_title": "Care instructions",
|
||||
"care_subtitle": "Shared with you as the assigned nurse after confirmation.",
|
||||
"care_conditions": "Conditions",
|
||||
"care_medications": "Medications",
|
||||
"care_allergies": "Allergies",
|
||||
"care_instructions_label": "Special instructions",
|
||||
"care_emergency": "Emergency contact",
|
||||
"care_none": "Not recorded",
|
||||
"care_empty": "No care instructions were recorded for this booking.",
|
||||
"care_error": "Couldn't load the care instructions.",
|
||||
"care_locked_title": "Care details",
|
||||
"care_locked_body": "The full care record is visible only to your assigned nurse and support.",
|
||||
|
||||
"list_title": "My bookings",
|
||||
"list_subtitle": "Your confirmed engagements.",
|
||||
"list_empty_title": "No bookings yet",
|
||||
"list_empty_body": "Once a nurse accepts and you pay, your booking appears here.",
|
||||
"list_error": "Couldn't load your bookings.",
|
||||
"list_total": "Total"
|
||||
},
|
||||
"auth": {
|
||||
"customer_title": "Sign in to Balinyaar",
|
||||
|
||||
+83
-1
@@ -474,7 +474,89 @@
|
||||
"status_rejected_by_nurse": "ردشده توسط پرستار",
|
||||
"status_expired_no_response": "بدون پاسخ در مهلت",
|
||||
"status_payment_deadline_expired": "پایان مهلت پرداخت",
|
||||
"status_cancelled_by_customer": "لغوشده"
|
||||
"status_cancelled_by_customer": "لغوشده",
|
||||
|
||||
"bd_title": "رزرو",
|
||||
"bd_ref": "رزرو #{id}",
|
||||
"bd_not_found_title": "رزرو یافت نشد",
|
||||
"bd_not_found_body": "این رزرو وجود ندارد یا متعلق به شما نیست.",
|
||||
"bd_my_bookings": "رزروهای من",
|
||||
|
||||
"timeline_title": "روند رزرو",
|
||||
"bstatus_pending_payment": "در انتظار پرداخت",
|
||||
"bstatus_confirmed": "تاییدشده",
|
||||
"bstatus_in_progress": "در حال انجام",
|
||||
"bstatus_completed": "تکمیلشده",
|
||||
"bstatus_disputed": "در حال بررسی اختلاف",
|
||||
"bstatus_closed": "بستهشده",
|
||||
"bstatus_cancelled": "لغوشده",
|
||||
|
||||
"confirmed_note": "رزرو شما تایید شد. ویزیتهای زیر برنامهریزی شدهاند.",
|
||||
"in_progress_note": "یک ویزیت هماکنون در حال انجام است.",
|
||||
"completed_note": "همهٔ ویزیتها به پایان رسید.",
|
||||
"dispute_window_note": "مبلغ پس از پایان مهلت بررسی در {date} برای پرستار آزاد میشود.",
|
||||
"cancelled_note": "این رزرو لغو شده است.",
|
||||
"disputed_note": "این رزرو در حال بررسی توسط تیم پشتیبانی است.",
|
||||
|
||||
"sessions_title": "برنامهٔ ویزیتها",
|
||||
"session_index": "ویزیت {n}",
|
||||
"session_count": "{count} ویزیت",
|
||||
"sstatus_scheduled": "برنامهریزیشده",
|
||||
"sstatus_in_progress": "در حال انجام",
|
||||
"sstatus_completed": "تکمیلشده",
|
||||
"sstatus_missed": "انجامنشده",
|
||||
"sstatus_cancelled": "لغوشده",
|
||||
"session_elapsed": "مدت حضور {duration}",
|
||||
"session_payout": "سهم این ویزیت",
|
||||
|
||||
"money_title": "خلاصهٔ پرداخت",
|
||||
"money_gross": "هزینهٔ خدمت",
|
||||
"money_commission": "کارمزد پلتفرم",
|
||||
"money_payout": "سهم پرستار",
|
||||
"money_nurse_earning": "درآمد شما",
|
||||
|
||||
"evv_today_title": "ویزیت امروز",
|
||||
"evv_visits_title": "ویزیتهای امروز",
|
||||
"evv_visits_subtitle": "برای هر ویزیت، ورود و خروج را با EVV ثبت کنید.",
|
||||
"evv_no_visits": "برای امروز ویزیتی برنامهریزی نشده است.",
|
||||
"evv_check_in": "ثبت ورود (EVV)",
|
||||
"evv_check_out": "ثبت خروج (EVV)",
|
||||
"evv_checking_in": "در حال ثبت ورود…",
|
||||
"evv_checking_out": "در حال ثبت خروج…",
|
||||
"evv_acquiring_location": "در حال دریافت موقعیت…",
|
||||
"evv_banner_in_range": "ورود ثبت شد {time} · موقعیت تایید شد (EVV)",
|
||||
"evv_banner_out_of_range": "ورود ثبت شد {time} · موقعیت خارج از محدوده (در حال بررسی)",
|
||||
"evv_banner_no_gps": "ورود ثبت شد {time} · موقعیت ثبت نشد",
|
||||
"evv_checked_out_at": "خروج ثبت شد {time}",
|
||||
"evv_gps_denied_note": "دسترسی به موقعیت داده نشد — همچنان میتوانید ویزیت را ثبت کنید؛ برای بررسی علامتگذاری میشود.",
|
||||
"evv_no_open_check_in": "ورود بازی برای ثبت خروج وجود ندارد.",
|
||||
"evv_not_startable": "این ویزیت هنوز قابل شروع نیست.",
|
||||
"evv_checked_in_toast": "ورود ثبت شد.",
|
||||
"evv_checked_out_toast": "خروج ثبت شد.",
|
||||
"evv_check_in_error": "ثبت ورود انجام نشد. دوباره تلاش کنید.",
|
||||
"evv_check_out_error": "ثبت خروج انجام نشد. دوباره تلاش کنید.",
|
||||
"evv_nurse_view": "نمای پرستار",
|
||||
"view_booking": "مشاهدهٔ رزرو",
|
||||
|
||||
"care_title": "دستورالعمل مراقبت",
|
||||
"care_subtitle": "پس از تایید، بهعنوان پرستار مسئول با شما به اشتراک گذاشته شده است.",
|
||||
"care_conditions": "شرایط پزشکی",
|
||||
"care_medications": "داروها",
|
||||
"care_allergies": "حساسیتها",
|
||||
"care_instructions_label": "دستورهای ویژه",
|
||||
"care_emergency": "تماس اضطراری",
|
||||
"care_none": "ثبت نشده",
|
||||
"care_empty": "برای این رزرو دستورالعمل مراقبتی ثبت نشده است.",
|
||||
"care_error": "بارگذاری دستورالعمل مراقبت ممکن نشد.",
|
||||
"care_locked_title": "شرح مراقبت",
|
||||
"care_locked_body": "شرح کامل مراقبت تنها برای پرستار مسئول شما و پشتیبانی قابل مشاهده است.",
|
||||
|
||||
"list_title": "رزروهای من",
|
||||
"list_subtitle": "مراقبتهای تاییدشدهٔ شما.",
|
||||
"list_empty_title": "هنوز رزروی ندارید",
|
||||
"list_empty_body": "پس از تایید پرستار و پرداخت، رزرو شما اینجا نمایش داده میشود.",
|
||||
"list_error": "بارگذاری رزروها ممکن نشد.",
|
||||
"list_total": "مبلغ کل"
|
||||
},
|
||||
"auth": {
|
||||
"customer_title": "ورود به بلینیار",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
|
||||
/**
|
||||
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
|
||||
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
|
||||
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
|
||||
*/
|
||||
export default function CustomerBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />;
|
||||
}
|
||||
@@ -1,8 +1,104 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, StatusChip } from '@/components';
|
||||
import { BOOKING_STATUS_KIND } from '@/components/booking/statusKind';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { useBookingList } from '@/services/bookings';
|
||||
import type { BookingListItemDto } from '@/services/bookings/types';
|
||||
|
||||
export default async function BookingsPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="bookings" title={t('bookings')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* Customer رزروها — the "My bookings" list. Reads `useBookingList('customer')`; each row opens the
|
||||
* booking detail (`/bookings/{id}`). This is the customer entry to the f8 booking-detail surface (the C5
|
||||
* `converted` state also lands here). Amounts render in Toman via the money util.
|
||||
*/
|
||||
export default function CustomerBookingsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading, isError } = useBookingList('customer');
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('list_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={120} />
|
||||
))}
|
||||
</Stack>
|
||||
) : isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('list_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="bookings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('list_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('list_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<BookingRow key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingRow({ item }: { item: BookingListItemDto }) {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{item.counterpartyName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[item.status]} label={t(`bstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { BookingDetailView } from '@/components/booking';
|
||||
|
||||
/**
|
||||
* Nurse booking detail (`/nurse/visits/{id}`) — the both-roles view in the **nurse** shell, where the
|
||||
* assigned nurse gets the per-session EVV check-in/out controls and the gated care-instructions card
|
||||
* (two-stage disclosure). Reached from the ویزیت امروز day surface.
|
||||
*/
|
||||
export default function NurseBookingDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="nurse" />;
|
||||
}
|
||||
@@ -1,8 +1,98 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { PlaceholderScreen } from '@/components';
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon } from '@/components';
|
||||
import { SessionCard, useEvvController } from '@/components/booking';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useSessionEvv, useTodaySessions } from '@/services/bookings';
|
||||
import type { BookingSessionListItemDto } from '@/services/bookings/types';
|
||||
|
||||
export default async function NurseVisitsPage() {
|
||||
const t = await getTranslations('nav');
|
||||
const tShell = await getTranslations('shell');
|
||||
return <PlaceholderScreen icon="visits" title={t('visits')} description={tShell('placeholder_body')} />;
|
||||
/**
|
||||
* Nurse ویزیت امروز (E3 top) — the day's operational surface. Lists today's sessions from
|
||||
* `useTodaySessions`; each renders the shared `SessionCard` with the per-session EVV check-in/out control
|
||||
* (driven by one `useEvvController`) and the advisory EVV banner once checked in. A GPS mismatch is
|
||||
* advisory, never a block. Each card also deep-links to the full booking detail (`/nurse/visits/{id}`),
|
||||
* where the gated care instructions live. Visit-note authoring + task checklist are deferred to f13.
|
||||
*/
|
||||
export default function NurseVisitsPage() {
|
||||
const t = useTranslations('booking');
|
||||
const { data, isLoading } = useTodaySessions();
|
||||
const evv = useEvvController();
|
||||
const items = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('evv_visits_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('evv_visits_subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((key) => (
|
||||
<Skeleton key={key} variant="rounded" height={150} />
|
||||
))}
|
||||
</Stack>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="visits" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
{t('evv_no_visits')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<TodayVisitCard key={item.sessionId} item={item} evv={evv} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: ReturnType<typeof useEvvController> }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch the EVV detail only once the session has activity, so the banner has the server check-in time.
|
||||
const hasEvvActivity = item.evvStatus !== 'pending';
|
||||
const { data: evvDetail } = useSessionEvv(item.sessionId, { enabled: hasEvvActivity });
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<SessionCard
|
||||
title={item.patientName}
|
||||
sessionIndex={item.sessionIndex}
|
||||
scheduledDate={item.scheduledDate}
|
||||
scheduledTimeStart={item.scheduledTimeStart}
|
||||
scheduledTimeEnd={item.scheduledTimeEnd}
|
||||
status={item.status}
|
||||
evvStatus={item.evvStatus}
|
||||
checkInAt={evvDetail?.checkInAt ?? null}
|
||||
checkOutAt={evvDetail?.checkOutAt ?? null}
|
||||
checkInAddressMatch={evvDetail?.checkInAddressMatch ?? null}
|
||||
showEvvControls
|
||||
evvPending={evv.busySessionId === item.sessionId}
|
||||
acquiringLocation={evv.acquiringSessionId === item.sessionId}
|
||||
onCheckIn={() => evv.checkIn({ sessionId: item.sessionId, bookingId: item.bookingId })}
|
||||
onCheckOut={() => evv.checkOut({ sessionId: item.sessionId, bookingId: item.bookingId })}
|
||||
/>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="bookings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
|
||||
|
||||
import BookingDetailView from './BookingDetailView';
|
||||
import { bookingsApi } from '@/services/bookings/apis';
|
||||
|
||||
function renderView(viewerRole: 'customer' | 'nurse') {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<BookingDetailView bookingId={5001} viewerRole={viewerRole} />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<BookingDetailView/> — two-stage disclosure gate', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('customer: never fetches care instructions and shows the "visible to your nurse only" affordance', async () => {
|
||||
const careSpy = jest.spyOn(bookingsApi, 'getCareInstructions');
|
||||
renderView('customer');
|
||||
|
||||
// Wait for the booking to load and the locked affordance to render.
|
||||
await waitFor(() => expect(screen.getByText('care_locked_title')).toBeInTheDocument());
|
||||
|
||||
// The hard gate: the client must not even request the clinical record for the customer.
|
||||
expect(careSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('care_title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('assigned nurse: fetches and renders the gated care instructions on a confirmed booking', async () => {
|
||||
const careSpy = jest.spyOn(bookingsApi, 'getCareInstructions');
|
||||
renderView('nurse');
|
||||
|
||||
await waitFor(() => expect(careSpy).toHaveBeenCalledWith(5001, 'nurse'));
|
||||
// The nurse never sees the customer's locked placeholder.
|
||||
expect(screen.queryByText('care_locked_title')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('care_title')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Chip, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useBookingDetail, useCareInstructions } from '@/services/bookings';
|
||||
import {
|
||||
isBookingConfirmedOrBeyond,
|
||||
type BookingDetailDto,
|
||||
type BookingViewerRole,
|
||||
} from '@/services/bookings/types';
|
||||
import BookingStatusTimeline from '../BookingStatusTimeline';
|
||||
import SessionList from '../SessionList';
|
||||
import BookingMoneySummary from '../BookingMoneySummary';
|
||||
import CareInstructionsCard from '../CareInstructionsCard';
|
||||
import { useEvvController } from '../useEvvController';
|
||||
|
||||
export interface BookingDetailViewProps {
|
||||
bookingId: number;
|
||||
/** `customer` sees the read-only timeline + sessions + money; `nurse` adds EVV controls + gated care. */
|
||||
viewerRole: BookingViewerRole;
|
||||
}
|
||||
|
||||
/** Best-effort read of the frozen variant display name from the booking's variant snapshot. */
|
||||
function variantName(snapshotJson: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshotJson) as { displayName?: string };
|
||||
return parsed?.displayName ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The both-roles booking detail — the hinge screen. Fetches `useBookingDetail`, renders the server-truth
|
||||
* `BookingStatusTimeline`, the `SessionList`, and the `BookingMoneySummary`. Role-conditioned: the
|
||||
* **assigned nurse** additionally gets the per-session EVV controls and the gated `CareInstructionsCard`;
|
||||
* the **customer** sees the read-only view and a "visible to your nurse only" affordance in place of the
|
||||
* clinical record.
|
||||
*
|
||||
* **Two-stage disclosure is a hard UI gate:** the care-instructions query is `enabled` **only** for the
|
||||
* nurse view on a `confirmed`+ booking — for the customer it never fires (a 404 would be a defect path,
|
||||
* not the design). Money is display-only; the timeline is never advanced client-side.
|
||||
* @component BookingDetailView
|
||||
*/
|
||||
const BookingDetailView: FunctionComponent<BookingDetailViewProps> = ({ bookingId, viewerRole }) => {
|
||||
const t = useTranslations('booking');
|
||||
const isNurse = viewerRole === 'nurse';
|
||||
|
||||
const { data: booking, isLoading, isError } = useBookingDetail(bookingId, viewerRole);
|
||||
const careEnabled = isNurse && !!booking && isBookingConfirmedOrBeyond(booking.status);
|
||||
const care = useCareInstructions(bookingId, { enabled: careEnabled });
|
||||
const evv = useEvvController();
|
||||
|
||||
if (isLoading) return <DetailSkeleton />;
|
||||
if (isError || !booking) return <NotFoundCard title={t('bd_not_found_title')} body={t('bd_not_found_body')} />;
|
||||
|
||||
const service = variantName(booking.variantSnapshotJson);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }} data-viewer-role={viewerRole}>
|
||||
{/* Header — carries the terracotta accent + "نمای پرستار" chip on the nurse view. */}
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
...(isNurse ? { borderTopWidth: 3, borderTopColor: 'var(--bal-secondary)' } : {}),
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{service ?? t('bd_title')}
|
||||
</Typography>
|
||||
{isNurse ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('evv_nurse_view')}
|
||||
sx={{ bgcolor: 'var(--bal-secondary-soft)', color: 'var(--bal-secondary-dark)', fontWeight: 700 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('bd_ref', { id: booking.id })}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
<HeaderFact label={t('summary_patient')} value={booking.patientName} />
|
||||
<HeaderFact label={t('unnamed_nurse')} value={booking.nurseName} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<BookingStatusTimeline status={booking.status} />
|
||||
</Paper>
|
||||
|
||||
<StatusNote booking={booking} />
|
||||
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('sessions_title')}
|
||||
</Typography>
|
||||
<SessionList
|
||||
sessions={booking.sessions}
|
||||
showEvvControls={isNurse}
|
||||
showPayout={isNurse}
|
||||
busySessionId={evv.busySessionId}
|
||||
acquiringSessionId={evv.acquiringSessionId}
|
||||
onCheckIn={isNurse ? (s) => evv.checkIn({ sessionId: s.id, bookingId: booking.id }) : undefined}
|
||||
onCheckOut={isNurse ? (s) => evv.checkOut({ sessionId: s.id, bookingId: booking.id }) : undefined}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<BookingMoneySummary
|
||||
grossPriceIrr={booking.grossPriceIrr}
|
||||
balinyaarCommissionIrr={booking.balinyaarCommissionIrr}
|
||||
nursePayoutAmount={booking.nursePayoutAmount}
|
||||
viewerRole={viewerRole}
|
||||
/>
|
||||
|
||||
<CareSection viewerRole={viewerRole} careEnabled={careEnabled} care={care} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function HeaderFact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The per-status content note — confirmed/in-progress/completed(+dispute window)/disputed/cancelled. */
|
||||
function StatusNote({ booking }: { booking: BookingDetailDto }) {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
|
||||
const lines: string[] = [];
|
||||
let tone: 'info' | 'warning' | 'neutral' = 'info';
|
||||
let icon = 'info';
|
||||
|
||||
switch (booking.status) {
|
||||
case 'confirmed':
|
||||
lines.push(t('confirmed_note'));
|
||||
break;
|
||||
case 'in_progress':
|
||||
lines.push(t('in_progress_note'));
|
||||
break;
|
||||
case 'completed':
|
||||
case 'closed':
|
||||
lines.push(t('completed_note'));
|
||||
icon = 'verified';
|
||||
break;
|
||||
case 'disputed':
|
||||
lines.push(t('disputed_note'));
|
||||
tone = 'warning';
|
||||
icon = 'warning';
|
||||
break;
|
||||
case 'cancelled':
|
||||
lines.push(t('cancelled_note'));
|
||||
tone = 'neutral';
|
||||
icon = 'rejected';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (booking.disputeWindowEndsAt && (booking.status === 'completed' || booking.status === 'closed')) {
|
||||
lines.push(t('dispute_window_note', { date: formatShamsiDate(booking.disputeWindowEndsAt, locale) }));
|
||||
}
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const bg =
|
||||
tone === 'warning' ? 'var(--bal-secondary-soft)' : tone === 'neutral' ? 'var(--bal-divider)' : 'var(--bal-primary-soft)';
|
||||
const color = tone === 'warning' ? 'var(--bal-secondary-dark)' : 'var(--bal-text-secondary)';
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', p: 1.5, borderRadius: 2, bgcolor: bg }}>
|
||||
<AppIcon icon={icon} size={18} color={color} />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
{lines.map((line) => (
|
||||
<Typography key={line} variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{line}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The care-instructions region — the gated nurse card, or the customer's "visible to your nurse" note. */
|
||||
function CareSection({
|
||||
viewerRole,
|
||||
careEnabled,
|
||||
care,
|
||||
}: {
|
||||
viewerRole: BookingViewerRole;
|
||||
careEnabled: boolean;
|
||||
care: ReturnType<typeof useCareInstructions>;
|
||||
}) {
|
||||
const t = useTranslations('booking');
|
||||
|
||||
if (viewerRole !== 'nurse') {
|
||||
// Customer: never renders the clinical record and never fires the query.
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon="lock" size={20} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('care_locked_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('care_locked_body')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!careEnabled) return null;
|
||||
if (care.isLoading) return <Skeleton variant="rounded" height={180} />;
|
||||
if (care.isError || !care.data) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('care_error')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
return <CareInstructionsCard data={care.data} />;
|
||||
}
|
||||
|
||||
function NotFoundCard({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640, mx: 'auto' }}
|
||||
>
|
||||
<AppIcon icon="error" size={44} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%' }}>
|
||||
<Skeleton variant="rounded" height={110} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="rounded" height={72} />
|
||||
<Skeleton variant="rounded" height={140} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingDetailView;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './BookingDetailView';
|
||||
export type { BookingDetailViewProps } from './BookingDetailView';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import BookingMoneySummary from './BookingMoneySummary';
|
||||
|
||||
function renderSummary(viewerRole: 'customer' | 'nurse') {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<BookingMoneySummary
|
||||
grossPriceIrr="45000000"
|
||||
balinyaarCommissionIrr="5400000"
|
||||
nursePayoutAmount="39600000"
|
||||
viewerRole={viewerRole}
|
||||
/>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<BookingMoneySummary/> component', () => {
|
||||
it('renders the three amounts as grouped Toman (IRR ÷ 10), never re-summed', () => {
|
||||
renderSummary('customer');
|
||||
expect(screen.getByText(/4,500,000/)).toBeInTheDocument(); // gross
|
||||
expect(screen.getByText(/540,000/)).toBeInTheDocument(); // commission
|
||||
expect(screen.getByText(/3,960,000/)).toBeInTheDocument(); // payout
|
||||
});
|
||||
|
||||
it('labels the payout row for the customer', () => {
|
||||
renderSummary('customer');
|
||||
expect(screen.getByText('money_payout')).toBeInTheDocument();
|
||||
expect(screen.queryByText('money_nurse_earning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the payout row as the nurse earning in the nurse view', () => {
|
||||
renderSummary('nurse');
|
||||
expect(screen.getByText('money_nurse_earning')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import type { BookingViewerRole } from '@/services/bookings/types';
|
||||
|
||||
export interface BookingMoneySummaryProps {
|
||||
/** IRR digit-strings; `gross = commission + payout`, guaranteed server-side. **Never** summed/re-split here. */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
/** Drives the payout row label (nurse sees «درآمد شما»; customer sees «سهم پرستار»). */
|
||||
viewerRole: BookingViewerRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmed booking money summary: service cost / Balinyaar fee (کارمزد) / nurse payout — each
|
||||
* rendered exactly as the server sent it (IRR digit-strings) through the money util as grouped Toman.
|
||||
* **Display-only:** no client-side sum, derive, or re-split; the tax line + escrow notice are the
|
||||
* checkout surface (deferred to f9). The payout row label adapts to the viewer.
|
||||
* @component BookingMoneySummary
|
||||
*/
|
||||
const BookingMoneySummary: FunctionComponent<BookingMoneySummaryProps> = ({
|
||||
grossPriceIrr,
|
||||
balinyaarCommissionIrr,
|
||||
nursePayoutAmount,
|
||||
viewerRole,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const toman = (irr: string) => `${formatIrrToToman(irr, locale)} ${tc('currency_toman')}`;
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
{t('money_title')}
|
||||
</Typography>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<MoneyRow label={t('money_gross')} value={toman(grossPriceIrr)} emphasize />
|
||||
<MoneyRow label={t('money_commission')} value={toman(balinyaarCommissionIrr)} />
|
||||
<MoneyRow
|
||||
label={viewerRole === 'nurse' ? t('money_nurse_earning') : t('money_payout')}
|
||||
value={toman(nursePayoutAmount)}
|
||||
accent
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
function MoneyRow({
|
||||
label,
|
||||
value,
|
||||
emphasize = false,
|
||||
accent = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
emphasize?: boolean;
|
||||
accent?: boolean;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: emphasize ? 800 : 700, color: accent ? 'var(--bal-secondary-dark)' : 'text.primary' }}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingMoneySummary;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './BookingMoneySummary';
|
||||
export type { BookingMoneySummaryProps } from './BookingMoneySummary';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import BookingStatusTimeline from './BookingStatusTimeline';
|
||||
|
||||
function renderTimeline(status: Parameters<typeof BookingStatusTimeline>[0]['status']) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<BookingStatusTimeline status={status} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<BookingStatusTimeline/> component', () => {
|
||||
it('renders the 4-step happy-path stepper with the current-status chip', () => {
|
||||
renderTimeline('confirmed');
|
||||
// The stepper renders every step label; pending_payment appears only in the stepper.
|
||||
expect(screen.getByText('bstatus_pending_payment')).toBeInTheDocument();
|
||||
expect(screen.getByText('bstatus_in_progress')).toBeInTheDocument();
|
||||
// confirmed appears in both the chip and the stepper.
|
||||
expect(screen.getAllByText('bstatus_confirmed').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('reflects the server status without advancing the client (in_progress)', () => {
|
||||
const { container } = renderTimeline('in_progress');
|
||||
expect(container.querySelector('[data-booking-status="in_progress"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cancelled distinctly — no misleading progress stepper', () => {
|
||||
renderTimeline('cancelled');
|
||||
expect(screen.getAllByText('bstatus_cancelled').length).toBeGreaterThanOrEqual(1);
|
||||
// The stepper is replaced by a terminal row, so its step labels are absent.
|
||||
expect(screen.queryByText('bstatus_pending_payment')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import StepperHeader from '@/components/StepperHeader';
|
||||
import {
|
||||
BOOKING_TIMELINE_ORDER,
|
||||
bookingTimelineActiveIndex,
|
||||
type BookingStatus,
|
||||
} from '@/services/bookings/types';
|
||||
import { BOOKING_STATUS_KIND } from '../statusKind';
|
||||
|
||||
export interface BookingStatusTimelineProps {
|
||||
/** Server truth — the single source. The timeline never advances/infers a step client-side. */
|
||||
status: BookingStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The both-roles booking status timeline — the grown-up version of the f7 C5 3-step tracker. It renders
|
||||
* the canonical happy path `pending_payment → confirmed → in_progress → completed` over the shared
|
||||
* StepperHeader, with the current-status chip above it. Terminal branches are shown **distinctly**:
|
||||
* `cancelled` replaces the stepper with a neutral terminal row (a progress bar would mislead); `disputed`
|
||||
* / `closed` keep the stepper (they follow completion) and surface through the chip. **Reflects
|
||||
* `BookingDetailDto.status` exactly** — it is never advanced on the client.
|
||||
* @component BookingStatusTimeline
|
||||
*/
|
||||
const BookingStatusTimeline: FunctionComponent<BookingStatusTimelineProps> = ({ status }) => {
|
||||
const t = useTranslations('booking');
|
||||
const steps = BOOKING_TIMELINE_ORDER.map((s) => t(`bstatus_${s}`));
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }} data-booking-status={status}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||
{t('timeline_title')}
|
||||
</Typography>
|
||||
<StatusChip status={BOOKING_STATUS_KIND[status]} label={t(`bstatus_${status}`)} />
|
||||
</Stack>
|
||||
|
||||
{status === 'cancelled' ? (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', px: 1.5, py: 1.25, borderRadius: 2, bgcolor: 'var(--bal-primary-soft)' }}
|
||||
>
|
||||
<AppIcon icon="rejected" size={20} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
{t('bstatus_cancelled')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : (
|
||||
<StepperHeader steps={steps} activeStep={bookingTimelineActiveIndex(status)} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingStatusTimeline;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './BookingStatusTimeline';
|
||||
export type { BookingStatusTimelineProps } from './BookingStatusTimeline';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import CareInstructionsCard from './CareInstructionsCard';
|
||||
import type { CareInstructionsDto } from '@/services/bookings/types';
|
||||
|
||||
const FULL: CareInstructionsDto = {
|
||||
bookingId: 5001,
|
||||
currentConditions: 'Type 2 diabetes, hypertension',
|
||||
medications: 'Metformin 500 · Losartan 25',
|
||||
allergies: 'Penicillin',
|
||||
specialInstructions: 'Check blood sugar before meals.',
|
||||
emergencyContactName: 'Zahra Mousavi',
|
||||
emergencyContactPhone: '09121234567',
|
||||
};
|
||||
|
||||
function renderCard(data: CareInstructionsDto) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<CareInstructionsCard data={data} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<CareInstructionsCard/> component', () => {
|
||||
it('renders every decrypted clinical field', () => {
|
||||
renderCard(FULL);
|
||||
expect(screen.getByText('Type 2 diabetes, hypertension')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metformin 500 · Losartan 25')).toBeInTheDocument();
|
||||
expect(screen.getByText('Penicillin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Check blood sugar before meals.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zahra Mousavi · 09121234567')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the "not recorded" placeholder for a null field', () => {
|
||||
renderCard({ ...FULL, allergies: null });
|
||||
expect(screen.getByText('care_none')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing was recorded', () => {
|
||||
renderCard({
|
||||
bookingId: 5001,
|
||||
currentConditions: null,
|
||||
medications: null,
|
||||
allergies: null,
|
||||
specialInstructions: null,
|
||||
emergencyContactName: null,
|
||||
emergencyContactPhone: null,
|
||||
});
|
||||
expect(screen.getByText('care_empty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import type { CareInstructionsDto } from '@/services/bookings/types';
|
||||
|
||||
export interface CareInstructionsCardProps {
|
||||
/** The decrypted stage-2 record — supplied only by the gated read (assigned nurse / admin, confirmed+). */
|
||||
data: CareInstructionsDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* The decrypted care-instructions card — conditions / medications / allergies / special instructions /
|
||||
* emergency contact. This is the **read side** of the two-stage clinical disclosure: it is only ever
|
||||
* mounted by the booking-detail view once the viewer is confirmed to be the assigned nurse (or admin) on
|
||||
* a `confirmed`+ booking. The card itself is presentational; the gate (and the "never even fetch" rule)
|
||||
* lives in the view + `useCareInstructions`.
|
||||
* @component CareInstructionsCard
|
||||
*/
|
||||
const CareInstructionsCard: FunctionComponent<CareInstructionsCardProps> = ({ data }) => {
|
||||
const t = useTranslations('booking');
|
||||
|
||||
const emergency =
|
||||
data.emergencyContactName || data.emergencyContactPhone
|
||||
? [data.emergencyContactName, data.emergencyContactPhone].filter(Boolean).join(' · ')
|
||||
: null;
|
||||
|
||||
const isEmpty =
|
||||
!data.currentConditions &&
|
||||
!data.medications &&
|
||||
!data.allergies &&
|
||||
!data.specialInstructions &&
|
||||
!emergency;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
borderInlineStartWidth: 4,
|
||||
borderInlineStartColor: 'var(--bal-primary)',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, mb: 1.5 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon="clinical" size={20} color="var(--bal-primary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('care_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('care_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{isEmpty ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('care_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack divider={<Divider />} sx={{ gap: 1.5 }}>
|
||||
<CareRow icon="clinical" label={t('care_conditions')} value={data.currentConditions} noneLabel={t('care_none')} />
|
||||
<CareRow icon="medication" label={t('care_medications')} value={data.medications} noneLabel={t('care_none')} />
|
||||
<CareRow icon="warning" label={t('care_allergies')} value={data.allergies} noneLabel={t('care_none')} />
|
||||
<CareRow icon="info" label={t('care_instructions_label')} value={data.specialInstructions} noneLabel={t('care_none')} />
|
||||
<CareRow icon="emergency" label={t('care_emergency')} value={emergency} noneLabel={t('care_none')} ltrValue />
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
function CareRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
noneLabel,
|
||||
ltrValue = false,
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
noneLabel: string;
|
||||
ltrValue?: boolean;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1.25, alignItems: 'flex-start' }}>
|
||||
<AppIcon icon={icon} size={18} color="var(--bal-text-secondary)" />
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
{...(ltrValue && value ? { dir: 'ltr' as const } : {})}
|
||||
sx={{ color: value ? 'text.primary' : 'text.secondary', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{value || noneLabel}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default CareInstructionsCard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './CareInstructionsCard';
|
||||
export type { CareInstructionsCardProps } from './CareInstructionsCard';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so the clock formats with ASCII digits.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import EvvStatusBanner from './EvvStatusBanner';
|
||||
|
||||
function renderBanner(match: boolean | null) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EvvStatusBanner checkInAtIso="2026-07-10T09:02:00.000Z" addressMatch={match} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EvvStatusBanner/> component', () => {
|
||||
it('shows the in-range confirmation when the address matched', () => {
|
||||
renderBanner(true);
|
||||
expect(screen.getByText('evv_banner_in_range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the advisory out-of-range variant (warning tone, not error) when the address mismatched', () => {
|
||||
const { container } = renderBanner(false);
|
||||
expect(screen.getByText('evv_banner_out_of_range')).toBeInTheDocument();
|
||||
// Advisory: the warning tone, never the error tone.
|
||||
const banner = container.querySelector('[data-evv-match="false"]') as HTMLElement;
|
||||
expect(banner).toHaveAttribute('data-evv-tone', 'warning');
|
||||
});
|
||||
|
||||
it('shows the neutral no-GPS variant when the position was unavailable', () => {
|
||||
renderBanner(null);
|
||||
expect(screen.getByText('evv_banner_no_gps')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatClock } from '../format';
|
||||
|
||||
/**
|
||||
* The advisory address-match result driving the banner variant:
|
||||
* - `true` → in range · «موقعیت تایید شد (EVV)» (success-tokened)
|
||||
* - `false` → out of range, under review · «موقعیت خارج از محدوده (در حال بررسی)» (**warning**-tokened, NOT error)
|
||||
* - `null` → GPS unavailable/denied · «موقعیت ثبت نشد» (neutral/info-tokened)
|
||||
*/
|
||||
export type EvvMatchState = boolean | null;
|
||||
|
||||
export interface EvvStatusBannerProps {
|
||||
/** The server `checkInAt` — the banner clock renders from this, never a client clock. */
|
||||
checkInAtIso: string;
|
||||
/** Advisory match; a mismatch is a warning banner, **never a block**. */
|
||||
addressMatch: EvvMatchState;
|
||||
}
|
||||
|
||||
type BannerTone = 'success' | 'warning' | 'info';
|
||||
|
||||
interface BannerStyle {
|
||||
tone: BannerTone;
|
||||
bg: string;
|
||||
fg: string;
|
||||
icon: string;
|
||||
messageKey: 'evv_banner_in_range' | 'evv_banner_out_of_range' | 'evv_banner_no_gps';
|
||||
}
|
||||
|
||||
// Colors resolve from the semantic --bal-* tokens (both schemes), so the banner switches with the color
|
||||
// scheme. Out-of-range uses the **warning** token (amber), never the error token — a mismatch is advisory.
|
||||
function styleFor(match: EvvMatchState): BannerStyle {
|
||||
if (match === true) {
|
||||
return { tone: 'success', bg: 'var(--bal-success)', fg: 'var(--bal-success-contrast)', icon: 'verified', messageKey: 'evv_banner_in_range' };
|
||||
}
|
||||
if (match === false) {
|
||||
return { tone: 'warning', bg: 'var(--bal-warning)', fg: 'var(--bal-warning-contrast)', icon: 'warning', messageKey: 'evv_banner_out_of_range' };
|
||||
}
|
||||
return { tone: 'info', bg: 'var(--bal-info)', fg: 'var(--bal-info-contrast)', icon: 'gps', messageKey: 'evv_banner_no_gps' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The EVV check-in banner — «ورود ثبت شد {time} · موقعیت تایید شد (EVV)» in range, the advisory
|
||||
* out-of-range variant when the GPS mismatched, and a neutral variant when GPS was unavailable. A
|
||||
* tokenised status banner (never an error), shown wherever an open/closed check-in exists (the
|
||||
* booking-detail session card and the nurse day surface both use it). Presentational — time and match are
|
||||
* supplied by the caller from server truth.
|
||||
* @component EvvStatusBanner
|
||||
*/
|
||||
const EvvStatusBanner: FunctionComponent<EvvStatusBannerProps> = ({ checkInAtIso, addressMatch }) => {
|
||||
const t = useTranslations('booking');
|
||||
const locale = useLocale();
|
||||
const style = styleFor(addressMatch);
|
||||
const time = formatClock(checkInAtIso, locale);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
data-evv-match={String(addressMatch)}
|
||||
data-evv-tone={style.tone}
|
||||
sx={{
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
px: 1.5,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
backgroundColor: style.bg,
|
||||
color: style.fg,
|
||||
}}
|
||||
>
|
||||
<AppIcon icon={style.icon} size={18} color={style.fg} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: style.fg }}>
|
||||
{t(style.messageKey, { time })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EvvStatusBanner;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './EvvStatusBanner';
|
||||
export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner';
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import SessionCard, { SessionCardProps } from './SessionCard';
|
||||
|
||||
const BASE: SessionCardProps = {
|
||||
sessionIndex: 1,
|
||||
scheduledDate: '2026-08-01',
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
status: 'scheduled',
|
||||
};
|
||||
|
||||
function renderCard(props: Partial<SessionCardProps> = {}) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<SessionCard {...BASE} {...props} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<SessionCard/> component', () => {
|
||||
it('renders the visit index and a per-session status chip', () => {
|
||||
const { container } = renderCard();
|
||||
expect(screen.getByText('session_index')).toBeInTheDocument();
|
||||
expect(screen.getByText('sstatus_scheduled')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-session-status="scheduled"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no EVV controls for the customer view', () => {
|
||||
renderCard({ showEvvControls: false });
|
||||
expect(screen.queryByText('evv_check_in')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers check-in for a scheduled session in the nurse view and fires onCheckIn', () => {
|
||||
const onCheckIn = jest.fn();
|
||||
renderCard({ showEvvControls: true, onCheckIn });
|
||||
const button = screen.getByText('evv_check_in');
|
||||
fireEvent.click(button);
|
||||
expect(onCheckIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers check-out once checked in', () => {
|
||||
renderCard({ showEvvControls: true, status: 'in_progress', evvStatus: 'checked_in', checkInAt: '2026-08-01T09:02:00.000Z' });
|
||||
expect(screen.getByText('evv_check_out')).toBeInTheDocument();
|
||||
// The in-range banner renders from the server check-in time (default match null → no-gps variant here).
|
||||
expect(screen.getByText('evv_banner_no_gps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the in-range EVV banner when the address matched', () => {
|
||||
renderCard({ status: 'in_progress', evvStatus: 'checked_in', checkInAt: '2026-08-01T09:02:00.000Z', checkInAddressMatch: true });
|
||||
expect(screen.getByText('evv_banner_in_range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the acquiring-location label while capturing GPS', () => {
|
||||
renderCard({ showEvvControls: true, acquiringLocation: true });
|
||||
expect(screen.getByText('evv_acquiring_location')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton } from '@/components/common';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import type { BookingSessionStatus, VisitVerificationStatus } from '@/services/bookings/types';
|
||||
import EvvStatusBanner from '../EvvStatusBanner';
|
||||
import { formatClock, formatElapsed, formatTimeRange } from '../format';
|
||||
import { SESSION_STATUS_KIND } from '../statusKind';
|
||||
|
||||
export interface SessionCardProps {
|
||||
/** Optional heading above the visit row — the patient name on the nurse's day feed (omitted in the detail). */
|
||||
title?: string;
|
||||
sessionIndex: number;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
/** `HH:mm:ss`. */
|
||||
scheduledTimeStart: string;
|
||||
scheduledTimeEnd: string;
|
||||
status: BookingSessionStatus;
|
||||
evvStatus?: VisitVerificationStatus;
|
||||
/** Server `checkInAt`/`checkOutAt` — the banner + elapsed render from these, never a client clock. */
|
||||
checkInAt?: string | null;
|
||||
checkOutAt?: string | null;
|
||||
/** Advisory match: `true` in range · `false` out-of-range (under review) · `null` GPS unavailable. */
|
||||
checkInAddressMatch?: boolean | null;
|
||||
/** IRR digit-string — this session's payout share; shown in the nurse view when `showPayout`. */
|
||||
visitPayoutAmount?: string | null;
|
||||
showPayout?: boolean;
|
||||
/** Render the nurse EVV check-in/out CTA (state-machine driven). */
|
||||
showEvvControls?: boolean;
|
||||
/** This session's EVV mutation is in flight. */
|
||||
evvPending?: boolean;
|
||||
/** Acquiring GPS for this session (pre-mutation). */
|
||||
acquiringLocation?: boolean;
|
||||
onCheckIn?: () => void;
|
||||
onCheckOut?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One visit row: the Shamsi schedule, a per-session status chip, the EVV banner once checked in, and —
|
||||
* for the assigned nurse — the check-in/out CTA driven by the session + EVV state machine (`scheduled` →
|
||||
* «ثبت ورود» · `in_progress`/checked-in → «ثبت خروج» · `completed` → elapsed duration · `missed` → no
|
||||
* action). A single-visit booking renders exactly this same card. Presentational: the container supplies
|
||||
* the EVV handlers + busy flags; a GPS mismatch never disables the flow (advisory, not a block).
|
||||
* @component SessionCard
|
||||
*/
|
||||
const SessionCard: FunctionComponent<SessionCardProps> = ({
|
||||
title,
|
||||
sessionIndex,
|
||||
scheduledDate,
|
||||
scheduledTimeStart,
|
||||
scheduledTimeEnd,
|
||||
status,
|
||||
evvStatus,
|
||||
checkInAt,
|
||||
checkOutAt,
|
||||
checkInAddressMatch = null,
|
||||
visitPayoutAmount,
|
||||
showPayout = false,
|
||||
showEvvControls = false,
|
||||
evvPending = false,
|
||||
acquiringLocation = false,
|
||||
onCheckIn,
|
||||
onCheckOut,
|
||||
}) => {
|
||||
const t = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const dateLabel = formatShamsiDate(scheduledDate, locale);
|
||||
const timeLabel = formatTimeRange(scheduledTimeStart, scheduledTimeEnd, locale);
|
||||
const showBanner = (evvStatus === 'checked_in' || evvStatus === 'completed') && Boolean(checkInAt);
|
||||
const elapsed = formatElapsed(checkInAt ?? null, checkOutAt ?? null, locale);
|
||||
const busy = acquiringLocation || evvPending;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-session-status={status}
|
||||
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{title ? (
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: title ? 600 : 700, color: title ? 'text.secondary' : 'text.primary' }}>
|
||||
{t('session_index', { n: sessionIndex })}
|
||||
</Typography>
|
||||
<StatusChip status={SESSION_STATUS_KIND[status]} label={t(`sstatus_${status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{dateLabel} · <Typography component="span" dir="ltr" sx={{ fontVariantNumeric: 'tabular-nums' }}>{timeLabel}</Typography>
|
||||
</Typography>
|
||||
|
||||
{showBanner ? <EvvStatusBanner checkInAtIso={checkInAt as string} addressMatch={checkInAddressMatch} /> : null}
|
||||
|
||||
{checkOutAt ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('evv_checked_out_at', { time: formatClock(checkOutAt, locale) })}
|
||||
{elapsed ? ` · ${t('session_elapsed', { duration: elapsed })}` : ''}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{showPayout && visitPayoutAmount ? (
|
||||
<Typography variant="caption" sx={{ color: 'var(--bal-secondary-dark)', fontWeight: 600 }}>
|
||||
{t('session_payout')}: {formatIrrToToman(visitPayoutAmount, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{showEvvControls && status === 'scheduled' ? (
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
startIcon={acquiringLocation ? 'gps' : 'check_in'}
|
||||
disabled={busy}
|
||||
onClick={onCheckIn}
|
||||
sx={{ m: 0, alignSelf: 'flex-start', py: 1 }}
|
||||
>
|
||||
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_in') : t('evv_check_in')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
{showEvvControls && status === 'in_progress' && evvStatus === 'checked_in' ? (
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
startIcon={acquiringLocation ? 'gps' : 'check_out'}
|
||||
disabled={busy}
|
||||
onClick={onCheckOut}
|
||||
sx={{ m: 0, alignSelf: 'flex-start', py: 1 }}
|
||||
>
|
||||
{acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_out') : t('evv_check_out')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionCard;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './SessionCard';
|
||||
export type { SessionCardProps } from './SessionCard';
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import SessionList from './SessionList';
|
||||
import type { BookingSessionDto } from '@/services/bookings/types';
|
||||
|
||||
function makeSession(id: number, sessionIndex: number): BookingSessionDto {
|
||||
return {
|
||||
id,
|
||||
sessionIndex,
|
||||
scheduledDate: '2026-08-01',
|
||||
scheduledTimeStart: '09:00:00',
|
||||
scheduledTimeEnd: '13:00:00',
|
||||
status: 'scheduled',
|
||||
visitPayoutAmount: '13200000',
|
||||
payoutEligibleAt: null,
|
||||
evvStatus: 'pending',
|
||||
checkInAt: null,
|
||||
checkOutAt: null,
|
||||
checkInAddressMatch: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList(sessions: BookingSessionDto[]) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<SessionList sessions={sessions} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<SessionList/> component', () => {
|
||||
it('renders one card per session', () => {
|
||||
renderList([makeSession(1, 1), makeSession(2, 2), makeSession(3, 3)]);
|
||||
expect(screen.getAllByText('session_index')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders exactly one card for a single-visit booking (no special case)', () => {
|
||||
renderList([makeSession(1, 1)]);
|
||||
expect(screen.getAllByText('session_index')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
import type { BookingSessionDto } from '@/services/bookings/types';
|
||||
import SessionCard from '../SessionCard';
|
||||
|
||||
export interface SessionListProps {
|
||||
sessions: BookingSessionDto[];
|
||||
/** Render the nurse EVV check-in/out CTA on each card. */
|
||||
showEvvControls?: boolean;
|
||||
showPayout?: boolean;
|
||||
/** The session whose EVV mutation is in flight (per-card busy state). */
|
||||
busySessionId?: number | null;
|
||||
/** The session currently acquiring GPS (pre-mutation). */
|
||||
acquiringSessionId?: number | null;
|
||||
onCheckIn?: (session: BookingSessionDto) => void;
|
||||
onCheckOut?: (session: BookingSessionDto) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session schedule list — one `SessionCard` per `BookingSessionDto`. A **single-visit booking renders
|
||||
* exactly one card through this same path** (no special case). Per-session busy state is derived from the
|
||||
* container's controller so only the acting card shows the spinner.
|
||||
* @component SessionList
|
||||
*/
|
||||
const SessionList: FunctionComponent<SessionListProps> = ({
|
||||
sessions,
|
||||
showEvvControls = false,
|
||||
showPayout = false,
|
||||
busySessionId = null,
|
||||
acquiringSessionId = null,
|
||||
onCheckIn,
|
||||
onCheckOut,
|
||||
}) => (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
sessionIndex={session.sessionIndex}
|
||||
scheduledDate={session.scheduledDate}
|
||||
scheduledTimeStart={session.scheduledTimeStart}
|
||||
scheduledTimeEnd={session.scheduledTimeEnd}
|
||||
status={session.status}
|
||||
evvStatus={session.evvStatus}
|
||||
checkInAt={session.checkInAt}
|
||||
checkOutAt={session.checkOutAt}
|
||||
checkInAddressMatch={session.checkInAddressMatch}
|
||||
visitPayoutAmount={session.visitPayoutAmount}
|
||||
showPayout={showPayout}
|
||||
showEvvControls={showEvvControls}
|
||||
evvPending={busySessionId === session.id}
|
||||
acquiringLocation={acquiringSessionId === session.id}
|
||||
onCheckIn={onCheckIn ? () => onCheckIn(session) : undefined}
|
||||
onCheckOut={onCheckOut ? () => onCheckOut(session) : undefined}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default SessionList;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './SessionList';
|
||||
export type { SessionListProps } from './SessionList';
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Shared time formatters for the booking composites. Timestamps cross the wire as UTC ISO; these render
|
||||
* a locale clock (Persian digits for `fa`) for the EVV banner + session schedule. Kept internal to the
|
||||
* booking components — money/date-of formatting lives in `@/utils`; this is only the clock/duration view.
|
||||
*/
|
||||
|
||||
/** `HH:MM` for a UTC ISO instant in the active locale (Persian digits for `fa`). */
|
||||
export function formatClock(iso: string, locale: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** `HH:MM` for a `HH:mm:ss` wire time, in the active locale. */
|
||||
export function formatTimeOfDay(time: string, locale: string): string {
|
||||
const [h, m] = time.split(':');
|
||||
if (h == null || m == null) return time;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
return `${nf.format(Number(h))}:${nf.format(Number(m))}`;
|
||||
}
|
||||
|
||||
/** The `HH:MM`-range label for a session's scheduled window, in the active locale. */
|
||||
export function formatTimeRange(start: string, end: string, locale: string): string {
|
||||
return `${formatTimeOfDay(start, locale)} – ${formatTimeOfDay(end, locale)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elapsed on-site duration between two UTC ISO instants, as `H:MM` in the active locale. Returns
|
||||
* `null` when either endpoint is missing (e.g. an open check-in with no check-out yet).
|
||||
*/
|
||||
export function formatElapsed(startIso: string | null, endIso: string | null, locale: string): string | null {
|
||||
if (!startIso || !endIso) return null;
|
||||
const start = new Date(startIso).getTime();
|
||||
const end = new Date(endIso).getTime();
|
||||
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null;
|
||||
const totalMinutes = Math.round((end - start) / 60_000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const nf = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { useGrouping: false });
|
||||
const nfPad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
});
|
||||
return `${nf.format(hours)}:${nfPad.format(minutes)}`;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Booking composites (f8) — the shared visual layer for the post-payment engagement: the both-roles
|
||||
* booking detail, the server-truth status timeline, the session schedule + per-session EVV, the advisory
|
||||
* EVV banner, and the gated care-instructions card. Kept at the shared level (co-located tests) so f9
|
||||
* (checkout) and f13 (records) extend them rather than re-derive the layout. Import from `@/components/booking`.
|
||||
*/
|
||||
export { default as BookingDetailView } from './BookingDetailView';
|
||||
export type { BookingDetailViewProps } from './BookingDetailView';
|
||||
export { default as BookingStatusTimeline } from './BookingStatusTimeline';
|
||||
export type { BookingStatusTimelineProps } from './BookingStatusTimeline';
|
||||
export { default as SessionList } from './SessionList';
|
||||
export type { SessionListProps } from './SessionList';
|
||||
export { default as SessionCard } from './SessionCard';
|
||||
export type { SessionCardProps } from './SessionCard';
|
||||
export { default as EvvStatusBanner } from './EvvStatusBanner';
|
||||
export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner';
|
||||
export { default as CareInstructionsCard } from './CareInstructionsCard';
|
||||
export type { CareInstructionsCardProps } from './CareInstructionsCard';
|
||||
export { default as BookingMoneySummary } from './BookingMoneySummary';
|
||||
export type { BookingMoneySummaryProps } from './BookingMoneySummary';
|
||||
export { useEvvController } from './useEvvController';
|
||||
export type { EvvTarget } from './useEvvController';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { StatusKind } from '@/components/StatusChip';
|
||||
import type { BookingSessionStatus, BookingStatus } from '@/services/bookings/types';
|
||||
|
||||
/** Booking status → the shared semantic StatusChip kind (used by the timeline + the bookings list). */
|
||||
export const BOOKING_STATUS_KIND: Record<BookingStatus, StatusKind> = {
|
||||
pending_payment: 'pending',
|
||||
confirmed: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
disputed: 'rejected',
|
||||
closed: 'neutral',
|
||||
cancelled: 'neutral',
|
||||
};
|
||||
|
||||
/** Session status → the shared semantic StatusChip kind (used by the session card + the today feed). */
|
||||
export const SESSION_STATUS_KIND: Record<BookingSessionStatus, StatusKind> = {
|
||||
scheduled: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
missed: 'rejected',
|
||||
cancelled: 'neutral',
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { useCheckInVisit, useCheckOutVisit } from '@/services/bookings';
|
||||
import { locationProvider } from '@/services/bookings/evv/locationProvider';
|
||||
|
||||
export interface EvvTarget {
|
||||
sessionId: number;
|
||||
bookingId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates an EVV check-in/out for a session: capture GPS through the `ILocationProvider` seam, then
|
||||
* post via the `useCheckInVisit`/`useCheckOutVisit` mutations (which invalidate the detail/today/EVV
|
||||
* queries so the timeline, session row, and banner re-render from server truth). Tracks the acting
|
||||
* session so only its card shows the "acquiring…" / pending state.
|
||||
*
|
||||
* **GPS is never a hard stop** — a denied/unavailable fix (`null`) still checks in (flagged), with an
|
||||
* advisory toast. A mismatch is advisory server-side (the banner warns). Only domain 4xx are toasted here
|
||||
* (409 not-startable, 400 no-open-check-in); `clientFetch` already toasts 401/403/5xx/network.
|
||||
*/
|
||||
export function useEvvController() {
|
||||
const t = useTranslations('booking');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const checkInMut = useCheckInVisit();
|
||||
const checkOutMut = useCheckOutVisit();
|
||||
const [acquiringSessionId, setAcquiringSessionId] = useState<number | null>(null);
|
||||
const [busySessionId, setBusySessionId] = useState<number | null>(null);
|
||||
|
||||
const capture = useCallback(async (sessionId: number) => {
|
||||
setAcquiringSessionId(sessionId);
|
||||
const pos = await locationProvider.getCurrentPosition();
|
||||
setAcquiringSessionId(null);
|
||||
return pos;
|
||||
}, []);
|
||||
|
||||
const checkIn = useCallback(
|
||||
async ({ sessionId, bookingId }: EvvTarget) => {
|
||||
const pos = await capture(sessionId);
|
||||
setBusySessionId(sessionId);
|
||||
try {
|
||||
await checkInMut.mutateAsync({
|
||||
input: {
|
||||
bookingSessionId: sessionId,
|
||||
latitude: pos?.latitude ?? null,
|
||||
longitude: pos?.longitude ?? null,
|
||||
capturedAt: new Date().toISOString(),
|
||||
},
|
||||
bookingId,
|
||||
});
|
||||
enqueueSnackbar(t('evv_checked_in_toast'), { variant: 'success' });
|
||||
// GPS denied/unavailable is advisory, not a block — surface it without failing the check-in.
|
||||
if (!pos) enqueueSnackbar(t('evv_gps_denied_note'), { variant: 'warning' });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
enqueueSnackbar(t('evv_not_startable'), { variant: 'warning' });
|
||||
}
|
||||
} finally {
|
||||
setBusySessionId(null);
|
||||
}
|
||||
},
|
||||
[capture, checkInMut, enqueueSnackbar, t],
|
||||
);
|
||||
|
||||
const checkOut = useCallback(
|
||||
async ({ sessionId, bookingId }: EvvTarget) => {
|
||||
const pos = await capture(sessionId);
|
||||
setBusySessionId(sessionId);
|
||||
try {
|
||||
await checkOutMut.mutateAsync({
|
||||
input: {
|
||||
bookingSessionId: sessionId,
|
||||
latitude: pos?.latitude ?? null,
|
||||
longitude: pos?.longitude ?? null,
|
||||
capturedAt: new Date().toISOString(),
|
||||
},
|
||||
bookingId,
|
||||
});
|
||||
enqueueSnackbar(t('evv_checked_out_toast'), { variant: 'success' });
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && (error.status === 400 || error.code === 'no_open_check_in')) {
|
||||
enqueueSnackbar(t('evv_no_open_check_in'), { variant: 'warning' });
|
||||
} else if (error instanceof ApiError && error.status === 409) {
|
||||
enqueueSnackbar(t('evv_check_out_error'), { variant: 'warning' });
|
||||
}
|
||||
} finally {
|
||||
setBusySessionId(null);
|
||||
}
|
||||
},
|
||||
[capture, checkOutMut, enqueueSnackbar, t],
|
||||
);
|
||||
|
||||
return { acquiringSessionId, busySessionId, checkIn, checkOut };
|
||||
}
|
||||
@@ -61,6 +61,15 @@ import TuneIcon from '@mui/icons-material/TuneOutlined';
|
||||
// Booking requests — the pre-payment intent flow (f7/b8): nurse inbox + the pay-&-continue handoff
|
||||
import RequestsIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import PaymentIcon from '@mui/icons-material/CreditCardOutlined';
|
||||
// Bookings, sessions & EVV — the post-payment engagement (f8/b9): check-in/out, GPS, care instructions
|
||||
import CheckInIcon from '@mui/icons-material/LoginOutlined';
|
||||
import CheckOutIcon from '@mui/icons-material/LogoutOutlined';
|
||||
import GpsIcon from '@mui/icons-material/MyLocationOutlined';
|
||||
import ScheduleIcon from '@mui/icons-material/ScheduleOutlined';
|
||||
import ClinicalIcon from '@mui/icons-material/HealthAndSafetyOutlined';
|
||||
import MedicationIcon from '@mui/icons-material/MedicationOutlined';
|
||||
import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
|
||||
import LockIcon from '@mui/icons-material/LockOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -133,4 +142,12 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
tune: TuneIcon,
|
||||
requests: RequestsIcon,
|
||||
payment: PaymentIcon,
|
||||
check_in: CheckInIcon,
|
||||
check_out: CheckOutIcon,
|
||||
gps: GpsIcon,
|
||||
schedule: ScheduleIcon,
|
||||
clinical: ClinicalIcon,
|
||||
medication: MedicationIcon,
|
||||
emergency: EmergencyIcon,
|
||||
lock: LockIcon,
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -33,6 +33,8 @@
|
||||
--bal-secondary-light: #e6a98a;
|
||||
--bal-secondary-dark: #bf6f4d;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
/* Soft terracotta tint — the "نمای پرستار" nurse-view chip + EVV/financial affordances */
|
||||
--bal-secondary-soft: rgba(217, 140, 106, 0.14);
|
||||
|
||||
/* Surfaces */
|
||||
--bal-bg-default: #faf9f5;
|
||||
@@ -71,6 +73,8 @@
|
||||
--bal-secondary-light: #f0bfa3;
|
||||
--bal-secondary-dark: #d98c6a;
|
||||
--bal-secondary-contrast: #2a1a12;
|
||||
/* Soft terracotta tint — the "نمای پرستار" nurse-view chip + EVV/financial affordances */
|
||||
--bal-secondary-soft: rgba(230, 169, 138, 0.18);
|
||||
|
||||
/* Surfaces — deep teal */
|
||||
--bal-bg-default: #0f1c19;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# Contract — Messaging (tickets), partner centers & admin backoffice (backend phase b15)
|
||||
|
||||
> One-line: the ticket system (the only sanctioned post-booking channel, admin-readable, with a hard
|
||||
> `is_internal` boundary), the licensed **partner centers** (sponsor / merchant-of-record → invoice issuer +
|
||||
> settlement target), and the consolidated admin backoffice (support-alert worklist + audit viewer + the
|
||||
> verify/refund/payout/moderation surfaces built in prior phases). Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
|
||||
> [`../openapi/swagger.v1.json`](../openapi/README.md).
|
||||
|
||||
**Status:** live as of backend-phase-b15 · **Frontend consumers:** frontend-phase-14-b15 (messaging/notifications),
|
||||
frontend-phase-15-b15 (admin + partner consoles)
|
||||
|
||||
Timestamps are UTC ISO-8601. IDs are numbers. Pagination is `page` / `pageSize` (default 50, max 100), response
|
||||
`{ items, total, page, pageSize }`. All settlement money is IRR `BIGINT`; the `settlement_iban` is **never**
|
||||
returned in plaintext — only a masked last-4 (`"••••0001"`).
|
||||
|
||||
---
|
||||
|
||||
## Critical rules the frontend must respect
|
||||
|
||||
- **`is_internal` is a hard boundary enforced at the query layer.** `GET /tickets/{id}` (the **user** view)
|
||||
never contains an internal message; `GET /admin/tickets/{id}` (the **admin** view, staff only) contains them.
|
||||
A non-staff caller cannot set `is_internal` on a message (→ `403`) and can never read one. Do not rely on the
|
||||
UI to hide internal notes — the backend already strips them from the user payload.
|
||||
- **No direct nurse↔customer channel.** All post-booking communication is ticket-mediated. Never surface a
|
||||
phone number. The emergency flow (`POST /tickets/emergency`) records the *aftermath* of an out-of-platform
|
||||
call; it exposes no contact.
|
||||
- **Ticket ↔ booking/refund links are optional.** `bookingId` and `refundId` are both nullable — a pure support
|
||||
ticket has neither.
|
||||
- **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`), quoted to users; never mutated.
|
||||
- **Merchant-of-record follows `partner_centers`.** `GET /internal/bookings/{bookingId}/center` returns
|
||||
`issuingEntityType = partner_center` (+ the center id) only when the booking's nurse is sponsored by a
|
||||
merchant-of-record center, else `platform`. Invoices + settlement follow this, not a hardcoded platform.
|
||||
- **Admin endpoints are internal-only + RBAC-gated + audited.** Every admin state change writes an append-only
|
||||
`audit_logs` row (never mutate prior rows). `support_alerts` are internal-only — never in a user response.
|
||||
|
||||
## Enums
|
||||
|
||||
- `ticket.status`: `open` | `closed`.
|
||||
- `ticket.category`: `coordination` | `support` | `refund` | `emergency`.
|
||||
- `ticket_participant.role_on_ticket`: `customer` | `nurse` | `admin` (display label, not an auth source).
|
||||
- `support_alert.status`: `open` | `assigned` | `resolved` (forward-only).
|
||||
- `support_alert.type`: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` |
|
||||
`shared_sim` | `payment_anomaly` | `fraud_signal` | `nurse_clawback` | `emergency`.
|
||||
- `invoice.issuing_entity_type` (resolver output): `platform` | `partner_center`.
|
||||
|
||||
---
|
||||
|
||||
## Tickets — authenticated (participant-scoped)
|
||||
|
||||
| Verb & route | Maps to | Auth |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/tickets` | open a ticket | authenticated |
|
||||
| `POST /api/v1/tickets/emergency` | log an emergency ticket (+ optional alert) | assigned nurse / staff |
|
||||
| `POST /api/v1/tickets/{id}/messages` | post a message | participant (staff may set `isInternal`) |
|
||||
| `POST /api/v1/tickets/{id}/participants` | add a participant | staff / ticket owner |
|
||||
| `DELETE /api/v1/tickets/{id}/participants/{userId}` | soft-remove a participant | staff / ticket owner |
|
||||
| `POST /api/v1/tickets/{id}/close` · `/reopen` | status transitions | participant / staff |
|
||||
| `GET /api/v1/tickets` | my tickets (paginated) | authenticated (own) |
|
||||
| `GET /api/v1/tickets/{id}` | thread — **user view, internal stripped** | participant / staff |
|
||||
|
||||
### `POST /api/v1/tickets`
|
||||
Request:
|
||||
```json
|
||||
{ "category": "support", "subject": "Reschedule", "body": "Can we move to 5pm?", "bookingId": 42, "refundId": null }
|
||||
```
|
||||
`bookingId`/`refundId` optional. A booking link requires the caller to be a party to the booking (staff bypass);
|
||||
a refund link is staff-only. Response `data`:
|
||||
```json
|
||||
{ "ticketId": 12, "referenceCode": "TKT-9F3K2A7Q", "status": "open", "category": "support" }
|
||||
```
|
||||
|
||||
### `POST /api/v1/tickets/{id}/messages`
|
||||
```json
|
||||
{ "body": "internal note", "isInternal": true }
|
||||
```
|
||||
`isInternal` defaults `false`; a non-staff caller sending `true` → `403`; posting to a closed ticket as a
|
||||
non-staff caller → `403`. Response `data`: `{ "messageId", "ticketId", "sentAt" }`.
|
||||
|
||||
### `POST /api/v1/tickets/emergency`
|
||||
```json
|
||||
{ "bookingId": 42, "body": "Called 115; patient stable.", "raiseAlert": true }
|
||||
```
|
||||
Only the assigned nurse (or staff). Response is the same shape as opening a ticket (`category: "emergency"`).
|
||||
|
||||
### `GET /api/v1/tickets/{id}` (user) / `GET /api/v1/admin/tickets/{id}` (admin)
|
||||
Response `data` (admin view shown; the user view omits internal messages):
|
||||
```json
|
||||
{
|
||||
"id": 12, "referenceCode": "TKT-9F3K2A7Q", "subject": "Reschedule",
|
||||
"status": "open", "category": "support", "bookingId": 42, "refundId": null,
|
||||
"openedById": 7, "closedAt": null,
|
||||
"participants": [ { "userId": 7, "roleOnTicket": "customer" }, { "userId": 3, "roleOnTicket": "admin" } ],
|
||||
"messages": [ { "id": 1, "senderId": 7, "body": "…", "isInternal": false, "sentAt": "2026-07-10T…Z" } ]
|
||||
}
|
||||
```
|
||||
|
||||
A duplicate `POST …/participants` returns **409** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
|
||||
|
||||
## Tickets — admin (`support`/`admin`)
|
||||
|
||||
| Verb & route | Maps to |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/admin/tickets` | global queue (filter `status`/`category`, search `referenceCode`, `bookingId`/`refundId`) |
|
||||
| `GET /api/v1/admin/tickets/{id}` | thread — **admin view, internal included** |
|
||||
|
||||
---
|
||||
|
||||
## Partner centers — admin (`admin`/`super_admin`)
|
||||
|
||||
| Verb & route | Maps to |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/admin/partner-centers` | create (inactive until verified) |
|
||||
| `PATCH /api/v1/admin/partner-centers/{id}` | update (replace semantics) |
|
||||
| `POST /api/v1/admin/partner-centers/{id}/verify` | record licensing approval + activate |
|
||||
| `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse` | set/clear `nurse_profiles.partner_center_id` |
|
||||
| `GET /api/v1/admin/partner-centers` | list (no IBAN, sponsored-nurse counts) |
|
||||
| `GET /api/v1/admin/partner-centers/{id}` | detail (**IBAN masked**) |
|
||||
|
||||
### `POST /api/v1/admin/partner-centers`
|
||||
```json
|
||||
{
|
||||
"name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
|
||||
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
|
||||
"settlementIban": "IR062960000000100324200001", "isMerchantOfRecord": true,
|
||||
"commissionRate": 0.05, "adminUserId": 8
|
||||
}
|
||||
```
|
||||
Validation: `commissionRate ∈ [0, 1)`; `settlementIban` required when `isMerchantOfRecord=true`;
|
||||
`mohEstablishmentPermitNo` non-empty. Response `data` (detail):
|
||||
```json
|
||||
{
|
||||
"id": 1, "name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
|
||||
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
|
||||
"settlementIbanMasked": "••••0001", "isMerchantOfRecord": true, "commissionRate": 0.05,
|
||||
"adminUserId": 8, "isActive": false, "verifiedAt": null, "sponsoredNurseCount": 0, "createdAt": "…Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse`
|
||||
```json
|
||||
{ "nurseProfileId": 15, "unlink": false }
|
||||
```
|
||||
Staff, or the center's own `adminUserId`, may sponsor within that center. `unlink: true` clears the link.
|
||||
|
||||
## Partner center — portal + internal resolver
|
||||
|
||||
| Verb & route | Maps to | Auth |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/centers/{id}/dashboard` | sponsored nurses + booking/invoice counts + masked settlement | center `adminUserId` / staff |
|
||||
| `GET /api/v1/internal/bookings/{bookingId}/center` | issuer/settlement resolution | internal / admin |
|
||||
|
||||
`GET /internal/bookings/{bookingId}/center` response `data`:
|
||||
```json
|
||||
{ "bookingId": 42, "issuingEntityType": "partner_center", "partnerCenterId": 1, "partnerCenterName": "Asanism Center", "isMerchantOfRecord": true }
|
||||
```
|
||||
For an unsponsored / non-merchant-of-record nurse: `{ "issuingEntityType": "platform", "partnerCenterId": null, … }`.
|
||||
|
||||
---
|
||||
|
||||
## Admin backoffice (surfaced, built in prior phases)
|
||||
|
||||
The support-alert worklist and audit viewer existed since b1; b15 confirms them as the backoffice surface (no
|
||||
rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other staff scopes via seeded claims).
|
||||
|
||||
| Verb & route | Maps to | Scope |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/support_alerts/get_support_alerts` | list (filter `type`/`status`/`ownerUserId`) | `support`/`admin` |
|
||||
| `POST /api/v1/support_alerts/assign_support_alert` | set owner | `support`/`admin` |
|
||||
| `POST /api/v1/support_alerts/resolve_support_alert` | resolve + note | `support`/`admin` |
|
||||
| `GET /api/v1/audit/get_audit_trail` | append-only audit log (filter entity/actor/date) | `super_admin`/`admin` |
|
||||
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
|
||||
|
||||
`support_alerts` are internal-only and must never appear in a user-facing response or join.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,30 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
## backend-phase-15 — Messaging (tickets), partner centers & admin backoffice — 2026-07-10
|
||||
- **Shipped (FINAL backend phase):** new `messaging` schema — `Tickets` (`UNIQUE(reference_code)`, status/
|
||||
category, nullable `booking_id`/`refund_id`), `TicketParticipants` (`UNIQUE(ticket_id, user_id)`, soft-remove
|
||||
via `removed_at`), `TicketMessages` (`is_internal` hard boundary) — and new `partner` schema — `PartnerCenters`
|
||||
(`IAuditable`, encrypted+masked `settlement_iban`, `commission_rate` separate from `platform_fee_rate`). Added
|
||||
the `nurse_profiles.partner_center_id` FK in place. One migration (`MessagingAndPartnerCenters`). CQRS:
|
||||
OpenTicket / AutoCreateCoordinationTicket / PostMessage / Add+RemoveParticipant / Close+ReopenTicket /
|
||||
LogEmergencyTicket / GetTicketThread (user vs admin view) / ListMyTickets / ListTicketsForAdmin; CreatePartnerCenter
|
||||
/ UpdatePartnerCenter / VerifyPartnerCenter / SponsorNurse / GetCenterForBooking / ListPartnerCenters /
|
||||
GetPartnerCenterById / GetCenterDashboard. 5 new controllers (Tickets/AdminTickets/AdminPartnerCenters/Centers/
|
||||
InternalCenters). Wired: b11 `IssueInvoice` now resolves issuer/settlement via `GetCenterForBooking`; b11
|
||||
`CreateRefund` auto-opens a `refund` ticket (so `refunds.ticket_id` is always non-null); the card confirm + BNPL
|
||||
settle handlers auto-create the coordination ticket. Support-alert worklist + audit viewer reused from b1 (not
|
||||
rebuilt). The 4 DEFERRED tables were **not** created.
|
||||
- **Contracts:** `dev/contracts/domains/messaging-notifications-admin.md` + openapi snapshot refreshed (yes).
|
||||
- **Mocked:** `ILicenseVerificationService` (eNamad / MoH permit — manual-approve at MVP) → 🟡 (see reports/mocks-registry.md).
|
||||
- **Gate:** build clean (0 new code warnings) / tests green (358 total: 4 identity + 240 foundation + 114 API,
|
||||
incl. 4 new merchant-of-record resolver tests + 8 new ticket/partner-center API tests).
|
||||
- **Handoff:** backend/handoff/after-backend-phase-15.md
|
||||
- **Notes for frontend:** `is_internal` is stripped from the user thread view server-side (never trust the UI);
|
||||
no direct nurse↔customer channel / no phone numbers; ticket↔booking/refund links are optional (nullable);
|
||||
duplicate participant add = 409; `settlement_iban` is only ever returned masked (last 4); merchant-of-record
|
||||
(invoice issuer + settlement) follows `partner_centers`, resolved by `GET /internal/bookings/{id}/center`.
|
||||
|
||||
## backend-phase-14 — Reviews, ratings & patient care records — 2026-07-09
|
||||
- **Shipped:** new `reviews` schema, 4 tables — `Reviews` (`UNIQUE(booking_id)`, `CHECK(rating 1–5)`, guarded
|
||||
`moderation_status`, `IAuditable`), `ReviewTagsMaster` (seeded 5-tag vocab, `UNIQUE(code)`), `ReviewTagLinks`
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Handoff — after backend phase 15 (Messaging, partner centers & admin backoffice)
|
||||
|
||||
**This is the final backend phase. The backend chain is complete.** Every domain the admin backoffice acts on
|
||||
now exists and is wired together.
|
||||
|
||||
## What is now live (frontend can build against it)
|
||||
|
||||
### Tickets — the post-booking channel (f14 messaging)
|
||||
- `POST /api/v1/tickets` — open a ticket (`category` ∈ `support|coordination|refund|emergency`; optional
|
||||
`bookingId`/`refundId`; body optional). Opener is auto-added as the first participant. Returns
|
||||
`{ ticketId, referenceCode, status, category }`.
|
||||
- `POST /api/v1/tickets/{id}/messages` — post a message. **`isInternal` is staff-only**; a non-staff caller
|
||||
sending `true` → `403`; posting to a closed ticket as non-staff → `403`.
|
||||
- `POST /api/v1/tickets/{id}/participants` (add) / `DELETE …/participants/{userId}` (soft-remove) — staff or
|
||||
ticket owner. A **duplicate add is `409`** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
|
||||
- `POST /api/v1/tickets/{id}/close` · `/reopen` — participant or staff (idempotent).
|
||||
- `POST /api/v1/tickets/emergency` — assigned nurse (or staff) logs an emergency (+ optional support alert).
|
||||
- `GET /api/v1/tickets` — my tickets (paginated, filter `status`, search `referenceCode`).
|
||||
- `GET /api/v1/tickets/{id}` — **user thread view: internal notes are stripped** in the projection.
|
||||
- `GET /api/v1/admin/tickets` + `GET /api/v1/admin/tickets/{id}` — admin queue + **admin thread view: internal
|
||||
notes included** (`support`/`admin`).
|
||||
|
||||
**The rule f14 must respect:** never build a direct nurse↔customer channel, never surface a phone number, and
|
||||
never rely on the UI to hide internal notes — the backend already strips them from the user payload. The
|
||||
coordination ticket for a booking is auto-created on confirmation (you don't create it).
|
||||
|
||||
### Partner centers + merchant-of-record (f15 admin + partner consoles)
|
||||
- `POST /api/v1/admin/partner-centers` (create, inactive) · `PATCH …/{id}` (update) ·
|
||||
`POST …/{id}/verify` (activate) · `POST …/{id}/sponsor-nurse` · `GET …` (list) · `GET …/{id}` (detail) —
|
||||
`admin`/`super_admin`. **`settlementIbanMasked` (last 4) is the only IBAN ever returned** — never plaintext.
|
||||
`commissionRate ∈ [0,1)`; a merchant-of-record center requires a `settlementIban`.
|
||||
- `GET /api/v1/centers/{id}/dashboard` — the center's own account (or staff): sponsored nurses + booking/invoice
|
||||
counts + masked settlement summary.
|
||||
- `GET /api/v1/internal/bookings/{bookingId}/center` — the issuer/settlement resolver
|
||||
(`platform` | `partner_center`).
|
||||
|
||||
### Admin backoffice (surfaced, not rebuilt)
|
||||
- Support-alert worklist: `GET support_alerts/get_support_alerts`, `POST …/assign_support_alert`,
|
||||
`POST …/resolve_support_alert` (built b1). Audit viewer: `GET audit/get_audit_trail` (built b1). Both
|
||||
`DynamicPermission`. Verification queue / refunds / payout dashboard / moderation queue are their own phases'
|
||||
routes — surface them under the admin console with the right RBAC scope.
|
||||
|
||||
## RBAC the frontend must respect (per route)
|
||||
- Authenticated (own) ticket routes: any logged-in user; participation is enforced server-side.
|
||||
- Admin ticket queue + admin thread: `support`/`admin`. Partner centers: `admin`/`super_admin`. Center
|
||||
dashboard: the center's `adminUserId` (or staff). Support alerts: `support`/`admin`. Audit: `super_admin`/`admin`.
|
||||
- The admin role passes every `DynamicPermission` check; narrower staff scopes (`support`/`finance`/`moderation`)
|
||||
are granted via seeded role claims.
|
||||
|
||||
## What's mocked
|
||||
- **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — `MockLicenseVerificationService`,
|
||||
manual-approve at MVP (`NeedsManualReview`); `VerifyPartnerCenter` records the human decision. Config
|
||||
`Seams:LicenseVerification:AutoApprove` forces `Valid`. See the mock registry (🟡). There is **no** telephony
|
||||
seam — the emergency call is an out-of-platform `tel:` link by design.
|
||||
|
||||
## Contracts
|
||||
- `dev/contracts/domains/messaging-notifications-admin.md` (this phase). `swagger.v1.json` refreshed (now includes
|
||||
`/tickets`, `/admin/tickets`, `/admin/partner-centers`, `/centers`, `/internal/bookings/{id}/center`).
|
||||
|
||||
## Types / wire notes
|
||||
- Envelope unchanged (camelCase body, snake_case URL tokens where `[action]`-based; the new controllers use
|
||||
explicit REST routes). Pagination `page`/`pageSize` (default 50, max 100). `sentAt`/`closedAt`/`verifiedAt` are
|
||||
UTC ISO-8601; ids are numbers; the settlement IBAN is a masked string (`"••••0001"`).
|
||||
@@ -12,6 +12,39 @@ for awareness.
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
## frontend-phase-8-b9 — Booking detail, sessions & nurse EVV — 2026-07-10
|
||||
- **Shipped:** the post-payment engagement — a **new** `services/bookings` domain (the sibling of
|
||||
`bookingRequests`, NOT a rename): types/keys/constants/apis[client(1:1 b9)+mock+serverApi]/8 hooks +
|
||||
barrel, plus the `evv/locationProvider.ts` **ILocationProvider** GPS seam. Screens: customer **رزروها
|
||||
list** `/bookings` + **booking detail** `/bookings/[id]` (BookingDetailView, customer view), nurse
|
||||
**ویزیت امروز** `/nurse/visits` (today-sessions EVV feed) + **nurse booking detail** `/nurse/visits/[id]`
|
||||
(EVV controls + gated care). Seven shared tested composites under `src/components/booking/`:
|
||||
`BookingDetailView`, `BookingStatusTimeline` (server-truth 7-status), `SessionList`→`SessionCard`
|
||||
(per-session schedule/status/EVV CTA), `EvvStatusBanner` (advisory in/out-of-range/no-gps), `CareInstructionsCard`,
|
||||
`BookingMoneySummary`, + `useEvvController`. i18n `booking` extended (`bstatus_*`/`sstatus_*`/`evv_*`/`care_*`/
|
||||
`money_*`/`list_*`) both locales; new icons (check_in/out, gps, clinical, medication, emergency, lock) +
|
||||
`--bal-secondary-soft` token.
|
||||
- **Load-bearing rules honored:** **two-stage disclosure is a UI gate** — `useCareInstructions` is
|
||||
`enabled` only for the assigned-nurse view on a `confirmed`+ booking; the customer NEVER fires it (proven
|
||||
by test). **EVV mismatch/GPS-denial is advisory, never a block** — out-of-range check-in still succeeds
|
||||
(warning-tokened banner, not error); denial still submits. **Timeline = server truth** (never advanced
|
||||
client-side); **money display-only** (gross/commission/payout rendered as sent, never summed/re-split;
|
||||
`payoutEligibleAt` never recomputed); single-visit renders one session row through the same card; EVV
|
||||
mutations **invalidate** detail+sessionEvv+today+list.
|
||||
- **Consumes:** dev/contracts/domains/bookings-evv.md (b9) + swagger `BookingDetailDto`/`BookingSessionSummaryDto`/
|
||||
`VisitVerificationDto`/`CareInstructionsDto` — `services/bookings/types.ts` derives from these 1:1.
|
||||
- **Mocked client-side:** `services/bookings` via `bookingsMockApi` (**USE_BOOKINGS_MOCK=true, primary**) —
|
||||
seeds 2 confirmed bookings (one 3-session, one single-visit) + care + a check-in/out EVV state machine,
|
||||
because a real booking only exists after `bookings/convert` runs on a paid request and both upstreams
|
||||
(bookingRequests mock, card capture b10) aren't real client-side yet. Real `bookingsClientApi` maps the
|
||||
routes 1:1; swap is one flag. Also the **ILocationProvider** GPS seam (`NEXT_PUBLIC_EVV_MOCK_GPS`
|
||||
in_range|out_of_range|denied|off) — the first frontend seam recorded in mocks-registry.
|
||||
- **Gate:** npm run check green · npm run test:ci green (195 tests, +22). Added a committed
|
||||
`NEXT_PUBLIC_API_URL` default in `jest.setup.ts` (first test to render a service-hook component pulled
|
||||
`@/config` at import).
|
||||
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-015: confirm the booking/session/EVV enum
|
||||
string codes + the `checkInAddressMatch` tri-state semantics the client unions assume).
|
||||
|
||||
## frontend-phase-7-b8 — Booking request flow (customer request + nurse inbox) — 2026-07-09
|
||||
- **Shipped:** the money-free request phase — `services/bookingRequests` (types/keys/constants/apis[client+
|
||||
mock]/hooks + barrel) and screens **C4** `/bookings/request` (patient/variant/address/date+time + a
|
||||
|
||||
@@ -195,3 +195,22 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
|
||||
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
|
||||
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
|
||||
- **Status:** open
|
||||
|
||||
## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
|
||||
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
|
||||
1. **Enum string codes.** The b9 swagger types `status`/`evvStatus`/session `status` as bare `string`
|
||||
(no enum constraint). Confirm the stable wire codes match the client unions: `BookingStatus`
|
||||
= `pending_payment|confirmed|in_progress|completed|disputed|closed|cancelled`; `BookingSessionStatus`
|
||||
= `scheduled|in_progress|completed|missed|cancelled`; `VisitVerificationStatus`
|
||||
= `pending|checked_in|completed`. (They match the contract doc's "Enums used" — this just asks that the
|
||||
serialized JSON emits these exact snake_case codes, not PascalCase/int.)
|
||||
2. **`checkInAddressMatch` tri-state semantics.** The EVV banner keys off it as: `true` = in range
|
||||
(«موقعیت تایید شد»), `false` = out-of-range/advisory-under-review («موقعیت خارج از محدوده»), `null` =
|
||||
GPS unavailable/denied («موقعیت ثبت نشد»). Confirm the server returns `null` (not `false`) when the
|
||||
nurse checked in **without** coordinates (GPS denied), so the UI can distinguish "flagged mismatch"
|
||||
from "no position captured". A mismatch stays advisory server-side (support alert, never a block) — the
|
||||
UI mirrors that.
|
||||
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
|
||||
a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
|
||||
but worth locking before f9/f13 consume the same shapes.
|
||||
- **Status:** open
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Backend Phase 15 report — Messaging (tickets), partner centers & admin backoffice
|
||||
|
||||
**The final backend phase.** It closes the operational loop: the ticket system, the licensed partner centers
|
||||
(merchant-of-record), and the consolidated admin backoffice. The backend chain is now complete.
|
||||
|
||||
## What was built
|
||||
|
||||
### Messaging (tickets) — new `messaging` schema
|
||||
- Entities `Domain/Entities/Messaging/`: `Ticket`, `TicketParticipant`, `TicketMessage` + `TicketStatus` /
|
||||
`TicketCategory` / `TicketParticipantRole` code sets. Configs in `Persistence/Configuration/MessagingConfig/`.
|
||||
- `ITicketRepository` (+ `TicketRepository`) on `IUnitOfWork`.
|
||||
- Features `Application/Features/Messaging/`: `OpenTicket`, `AutoCreateCoordinationTicket`, `PostMessage`,
|
||||
`AddParticipant`, `RemoveParticipant`, `CloseTicket`, `ReopenTicket`, `LogEmergencyTicket`, `GetTicketThread`
|
||||
(role-aware user/admin view), `ListMyTickets`, `ListTicketsForAdmin`. Shared helpers `TicketReferenceCode`
|
||||
(collision-checked mint) + `TicketRoleResolver` + `StaffRoles` (Application/Common).
|
||||
- Controllers `TicketsController` (authenticated) + `AdminTicketsController` (`support`/`admin`).
|
||||
|
||||
### Partner centers — new `partner` schema
|
||||
- Entity `Domain/Entities/PartnerCenters/PartnerCenter` (`IAuditable`; `settlement_iban` `[AuditRedacted]` +
|
||||
encrypted converter in `ApplicationDbContext`). Config in `Persistence/Configuration/PartnerCentersConfig/`
|
||||
(also adds the `nurse_profiles.partner_center_id` FK in place). `IPartnerCenterRepository` (+ impl).
|
||||
- Features: `CreatePartnerCenter`, `UpdatePartnerCenter`, `VerifyPartnerCenter`, `SponsorNurse`,
|
||||
`GetCenterForBooking` (the merchant-of-record resolver), `ListPartnerCenters`, `GetPartnerCenterById`,
|
||||
`GetCenterDashboard`. Controllers `AdminPartnerCentersController`, `CentersController` (portal),
|
||||
`InternalCentersController` (resolver).
|
||||
|
||||
### Seam
|
||||
- **`ILicenseVerificationService`** (`Application/Contracts/Common`) + `MockLicenseVerificationService`
|
||||
(`CrossCutting/Seams/`, registered in `AddCrossCuttingSeams`, config `Seams:LicenseVerification:AutoApprove`).
|
||||
|
||||
### Cross-phase wiring
|
||||
- b11 `IssueInvoiceCommand` now sets `invoices.issuing_entity_type` + `partner_center_id` from
|
||||
`ResolveCenterForBookingAsync` (the single merchant-of-record resolver).
|
||||
- b11 `CreateRefundCommand` auto-opens a `category=refund` ticket via `OpenTicketCommand` when the caller passes
|
||||
none, so `refunds.ticket_id` is always non-null (replaces the old config-gated "ticket required" check).
|
||||
- The card `ConfirmPaymentAndPostLedger` and BNPL `SettleBnplOrder` handlers dispatch
|
||||
`AutoCreateCoordinationTicketCommand` after a booking is confirmed (idempotent, one per booking).
|
||||
|
||||
### Reused, not rebuilt (admin backoffice consolidation)
|
||||
- Support-alert worklist (`ISupportAlertService` List/Assign/Resolve — `SupportAlertsController`) and the audit
|
||||
viewer (`GetAuditTrail` — `AuditController`) already existed since b1; verified as the backoffice surface.
|
||||
Verification/refund/payout/moderation queues are their own phases' endpoints.
|
||||
|
||||
## What is now testable and exactly how (the §7 steps)
|
||||
1. **Open + message:** `POST /api/v1/tickets` (no links) → 200 with a `TKT-…` `referenceCode` + opener as
|
||||
participant; `POST /api/v1/tickets/{id}/messages` → the message appears in the thread.
|
||||
2. **Internal boundary (proven by a test):** admin `POST …/messages {isInternal:true}` → 200; user
|
||||
`GET /api/v1/tickets/{id}` omits it; admin `GET /api/v1/admin/tickets/{id}` includes it; a non-staff
|
||||
`isInternal:true` → 403. (`MessagingApiTests.InternalNote_IsHiddenInUserView_ShownInAdminView` + `NonAdmin_CannotSetInternal`.)
|
||||
3. **Participant uniqueness:** add a user → 200; add again → **409** (not 500); delete → 200.
|
||||
(`MessagingApiTests.AddParticipant_DuplicateIsConflict_NotServerError`.)
|
||||
4. **Partner center + masked IBAN:** `POST /api/v1/admin/partner-centers {isMerchantOfRecord:true, settlementIban}`
|
||||
→ 200 with `settlementIbanMasked` (last 4), never plaintext; `GET …/{id}` masks it too; created inactive.
|
||||
(`PartnerCentersApiTests.CreateMerchantOfRecord_MasksSettlementIban`, `Verify_ActivatesTheCenter`.)
|
||||
5. **Merchant-of-record resolution:** `GET /api/v1/internal/bookings/{id}/center` → `partner_center` (+ id) for a
|
||||
nurse sponsored by a merchant-of-record center, `platform` otherwise. (`CenterForBookingTests`, 4 cases.)
|
||||
6. **Refund anchors a ticket:** `CreateRefund` yields a non-null `refunds.ticket_id` (foundation refund tests
|
||||
pass with the auto-open wired via `TestSenders.WithTicketHooks()`).
|
||||
7. **Admin worklists / RBAC:** support alerts + audit reachable under admin scope; a non-admin token on an admin
|
||||
route → 403 (`PartnerCentersApiTests.NonAdmin_IsForbidden`), unauthenticated → 401.
|
||||
8. **Audit:** admin state changes (e.g. `VerifyPartnerCenter`) append an `audit_logs` row (`PartnerCenter` is
|
||||
`IAuditable`; `settlement_iban` is redacted in the diff).
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- `ILicenseVerificationService` → manual-approve at MVP (no public eNamad/MoH B2B API). Make-it-real steps in
|
||||
`reports/mocks-registry.md` (🟡). No telephony seam — the emergency call is out-of-platform by design.
|
||||
|
||||
## Contracts produced
|
||||
- `dev/contracts/domains/messaging-notifications-admin.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
|
||||
(now includes tickets, partner centers, the center resolver).
|
||||
|
||||
## Gate
|
||||
- `dotnet build Baya.sln` — 0 new code warnings. `dotnet test Baya.sln` — green: 4 identity + 240 foundation +
|
||||
114 API (12 new tests this phase). Migration `MessagingAndPartnerCenters` scaffolds cleanly.
|
||||
|
||||
## Decisions / notes for the future
|
||||
- **Merchant-of-record** = `partner_center` issuer only when the sponsoring center `is_merchant_of_record`; a
|
||||
non-MoR sponsor leaves the platform as issuer (so a sponsored-but-platform-billed nurse is representable).
|
||||
- **Participant removal** is a soft `removed_at` stamp (not a hard delete / not `deleted_at`), so the
|
||||
`UNIQUE(ticket_id, user_id)` row survives and a re-add resurrects it.
|
||||
- **SQLite gotcha (again):** messages are ordered by the monotonic `Id` (== send order), never `ORDER BY sent_at`
|
||||
(`DateTimeOffset`), which the SQLite test provider can't translate.
|
||||
- **Follow-ups:** the invoice-issuer wire sets the columns but the downstream settlement rail (paying a center's
|
||||
IBAN when it is MoR) is not exercised end-to-end here; the center dashboard caps the sponsored-nurse list at 50
|
||||
(count is exact) — paginate it if a center grows large. Bookings/invoices `partner_center_id` columns exist
|
||||
without a DB FK (only `nurse_profiles` got the FK, per the DoD).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Frontend Phase 8 (f8-b9) — Booking detail, sessions & nurse EVV — report
|
||||
|
||||
**Date:** 2026-07-10 · **Lane:** frontend · **Consumes:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) (b9)
|
||||
· **Unlocks:** f9 (checkout/pay), f13 (reviews & patient records)
|
||||
|
||||
## What was built
|
||||
|
||||
The post-payment engagement — the hinge between "I asked for a nurse" and "a nurse is delivering care."
|
||||
|
||||
### Data layer — a **new** `services/bookings` domain
|
||||
The **sibling** of `services/bookingRequests` (b8), **not** a rename — a distinct b9 contract, distinct
|
||||
routes (`/api/v1/bookings/*` + `/api/v1/booking_sessions/*`), distinct shapes. Same `services/{domain}`
|
||||
shape as every other domain:
|
||||
- `types.ts` — derived 1:1 from the b9 swagger (camelCase): `BookingDetailDto`, `BookingSessionDto`
|
||||
(`BookingSessionSummaryDto`), `VisitVerificationDto`, `CareInstructionsDto`, `BookingListItemDto`,
|
||||
`BookingSessionListItemDto`, `CheckInVisitInput`/`CheckOutVisitInput`, the `BookingsApi` seam, the three
|
||||
enum unions (`BookingStatus`/`BookingSessionStatus`/`VisitVerificationStatus`), and pure helpers
|
||||
(`isBookingConfirmedOrBeyond`, `isBookingTerminalBranch`, `bookingTimelineActiveIndex`, `BOOKING_TIMELINE_ORDER`).
|
||||
- `keys.ts` — `bookingDetail(id)`, `bookingSessions(id)` (alias of `bookingDetail` — sessions are embedded),
|
||||
`today(params)`, `sessionEvv(id)`, `careInstructions(id)`, `list(params)`.
|
||||
- `apis/` — real `clientApi` (maps the routes 1:1), `mockApi` (the seeded state machine, **primary**),
|
||||
`serverApi.getBookingDetail` (the RSC-prefetch seam for the real path), and a config-selecting `index`.
|
||||
- `evv/locationProvider.ts` — the **`ILocationProvider`** GPS seam (real `navigator.geolocation` vs a canned
|
||||
mock; `getCurrentPosition()` resolves `null` on denial, never rejects).
|
||||
- `hooks/` (one per file): `useBookingDetail`, `useBookingSessions` (a `select` over the detail query, no
|
||||
second fetch), `useBookingList`, `useTodaySessions`, `useSessionEvv`, `useCareInstructions` (**enabled-gated**),
|
||||
`useCheckInVisit`, `useCheckOutVisit` (both invalidate detail+sessionEvv+today+list on success).
|
||||
|
||||
### Shared composites — `src/components/booking/` (each with a co-located `*.test.tsx`)
|
||||
- `BookingDetailView` — the both-roles smart container (role-conditioned EVV + gated care).
|
||||
- `BookingStatusTimeline` — the server-truth 7-status timeline over the f0 `StepperHeader` + status chip.
|
||||
- `SessionList` → `SessionCard` — per-session Shamsi schedule, status chip, EVV CTA, elapsed/payout.
|
||||
- `EvvStatusBanner` — advisory banner (in-range success / out-of-range warning / no-gps neutral).
|
||||
- `CareInstructionsCard` — the decrypted clinical read (conditions/meds/allergies/instructions/emergency).
|
||||
- `BookingMoneySummary` — gross / commission (کارمزد) / payout, display-only via the money util.
|
||||
- `useEvvController` — GPS-capture + check-in/out orchestration (one instance per surface, per-session busy).
|
||||
- `format.ts` (clock/duration) + `statusKind.ts` (status → StatusChip kind) helpers.
|
||||
|
||||
### Screens
|
||||
- **Customer:** `/bookings` (رزروها list) → `/bookings/[id]` (detail, customer view: timeline + sessions +
|
||||
money; the care record shows the "visible to your nurse only" affordance and the query never fires).
|
||||
- **Nurse:** `/nurse/visits` (ویزیت امروز — today's sessions with inline EVV check-in/out) →
|
||||
`/nurse/visits/[id]` (detail, nurse view: EVV controls + the gated care card).
|
||||
|
||||
### Cross-cutting
|
||||
- i18n `booking` namespace **extended** (both locales, key-synced): `bstatus_*`, `sstatus_*`, `evv_*`
|
||||
(banner variants + CTAs + GPS copy), `care_*` (+ the customer lock copy), `money_*`, `list_*`, dispute note.
|
||||
- 8 new registry icons (`check_in`/`check_out`/`gps`/`schedule`/`clinical`/`medication`/`emergency`/`lock`)
|
||||
and one new token `--bal-secondary-soft` (the نمای پرستار chip / EVV affordance), both schemes.
|
||||
|
||||
## What is now testable, and exactly how
|
||||
|
||||
Run `npm run dev` (mock is primary — no backend needed). The b9 endpoints are also live if you flip the flag.
|
||||
1. **Confirmed booking (customer):** open the رزروها tab → the list shows the two seeded bookings; open one
|
||||
→ **status timeline** at `confirmed`, the **session schedule** (booking #5002 shows exactly **one**
|
||||
session; #5001 shows **3**), and the **money summary** in Toman. Toggle `/en`↔`/fa` → strings + `dir`
|
||||
flip; the timeline reads RTL.
|
||||
2. **Care gate:** open a booking **as the nurse** (`/nurse/visits` → a session → view booking) → the
|
||||
**care-instructions card** is visible (conditions/meds/allergies/instructions/emergency). As the
|
||||
**customer**, the card is absent and the Network tab shows the care request **was never made** (proven by
|
||||
the `BookingDetailView` test too).
|
||||
3. **Nurse check-in:** on `/nurse/visits` (or in the nurse booking detail), tap **«ثبت ورود (EVV)»** →
|
||||
"در حال دریافت موقعیت…" → the **«ورود ثبت شد … موقعیت تایید شد (EVV)»** banner; the session chip →
|
||||
`in_progress`; the timeline → `in_progress`. Set `NEXT_PUBLIC_EVV_MOCK_GPS=out_of_range` → the **advisory**
|
||||
«موقعیت خارج از محدوده (در حال بررسی)» banner and the check-in **still succeeds**. `=denied` → the nurse
|
||||
still checks in (no block; advisory toast + no-gps banner).
|
||||
4. **Nurse check-out:** tap **«ثبت خروج (EVV)»** → the session chip → `completed` with elapsed duration; for
|
||||
the single-visit booking (#5002) the timeline advances to `completed` + the dispute-window note appears —
|
||||
all from the server response, no client-side step jump.
|
||||
5. **Caching:** in React Query Devtools, an EVV mutation invalidates `bookingDetail`/`sessionEvv`/`today`/
|
||||
`list` and the UI re-renders from the refetch; revisiting within `staleTime` does not refetch.
|
||||
6. `npm run check` green · `npm run test:ci` green (195 tests, +22).
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
|
||||
- **`services/bookings` — mock-primary** (`USE_BOOKINGS_MOCK=true`). A booking only exists after
|
||||
`bookings/convert` runs on a **paid** request, and both upstreams (`bookingRequests` mock, card capture
|
||||
b10) aren't real client-side yet, so a real `bookings/list` returns nothing. The mock seeds confirmed
|
||||
bookings + sessions + care + the EVV state machine. Real `bookingsClientApi` maps the routes 1:1; the swap
|
||||
is one flag (see mocks-registry). `serverApi.getBookingDetail` is ready for the RSC prefetch on the real path.
|
||||
- **`ILocationProvider`** (`NEXT_PUBLIC_EVV_MOCK_GPS`) — GPS capture seam; the real path is
|
||||
`navigator.geolocation`. Server-side address-match math stays behind the backend geocoding seam.
|
||||
- Both are recorded in [mocks-registry.md](./mocks-registry.md).
|
||||
|
||||
## Contract consumed + gaps filed
|
||||
|
||||
- **Consumed:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) + the b9 swagger shapes — types
|
||||
derive 1:1. No shape was guessed.
|
||||
- **Filed:** **REQ-015** — confirm the booking/session/EVV enum **string codes** (bare `string` in swagger)
|
||||
match the client unions, and the **`checkInAddressMatch` tri-state** (`true`/`false`/`null`) so the banner
|
||||
can distinguish an advisory mismatch from "no GPS captured." Low-risk (mock-primary now); worth locking
|
||||
before f9/f13 reuse the shapes.
|
||||
|
||||
## Deliberate design decisions (non-obvious)
|
||||
|
||||
- **Two-stage disclosure is a UI gate, not just a server check.** `useCareInstructions` is `enabled` only for
|
||||
the assigned-nurse view on a `confirmed`+ booking; the customer/unassigned viewer **never fires** the
|
||||
request (a 403/404 is treated as a defect path). The `BookingDetailView` test asserts the customer never
|
||||
calls `getCareInstructions` and the nurse does.
|
||||
- **EVV mismatch/denial is advisory, never a block.** Out-of-range check-in succeeds with a **warning**-tokened
|
||||
banner (never the error token); GPS denial still submits. Check-out is never gated on the match.
|
||||
- **Server-truth timeline + display-only money.** The timeline renders `BookingDetailDto.status` exactly (no
|
||||
client step advance); money is rendered as-sent (no sum/derive/re-split); `payoutEligibleAt` is never
|
||||
recomputed. Sessions are **embedded** in the detail (no standalone list endpoint) — `useBookingSessions`
|
||||
is a `select` over the one detail query, so invalidating `bookingDetail(id)` refreshes both.
|
||||
- **`ILocationProvider`** is the single new client seam; the `NEXT_PUBLIC_EVV_MOCK_GPS` default is `in_range`
|
||||
while mock-primary so the happy path is demoable without a device (real GPS would never fall near the
|
||||
seeded Tehran address).
|
||||
|
||||
## Follow-ups for later phases
|
||||
|
||||
- **f9 (checkout/pay):** the money summary here shows the confirmed **split only**; the **tax (مالیات) line**,
|
||||
escrow notice, and invoice are the checkout surface — b9's `BookingDetailDto` has **no tax field** (flagged;
|
||||
f9 owns it). The C5 accept CTA still lands on `/bookings/checkout?request_id=…` (f7 stub).
|
||||
- **f13 (reviews & records):** the E3 **visit-note authoring** (bottom half) and the full **E2 patient-record
|
||||
viewer** are deferred here; the booking-detail/EVV/care pattern (timeline + sessions + gated care + EVV
|
||||
banner) is the template they extend. The customer-side **care-details authoring** (`submit_care_instructions`)
|
||||
write form is also f13 — f8 only reads the gated record.
|
||||
- **f15 (admin):** the EVV-review queue (mismatch / no-show worklist) is the admin console; f8 raises no
|
||||
alerts client-side (no-show detection is a server job).
|
||||
- **Swap to real:** flip `USE_BOOKINGS_MOCK=false` once `bookings/convert` is reachable client-side (b10
|
||||
card capture) — `bookingsClientApi` + `bookingsServerApi` are wired; no hook/component change.
|
||||
@@ -47,6 +47,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
|
||||
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 |
|
||||
| `INursePayoutStatus` | backend-phase-11 (interim) → **backend-phase-13 (authoritative)** | "Was the nurse already paid for this booking?" — **b13 shipped the real `NursePayoutLinkStatusService`** (`Persistence/Services/Payments/`): a booking is paid iff a `nurse_payout_booking_links` row ties it to a `nurse_payouts` row in status `paid`. This **supersedes** the interim `NursePayoutStatusService` (dispute-window derivation, now deleted); the `refund_assume_nurse_paid` config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in `AddPersistenceServices`. The refund pre-payout/clawback fork is unchanged | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 |
|
||||
|
||||
| `ILicenseVerificationService` | **backend-phase-15** | Partner-center licensing (eNamad / MoH establishment-permit پروانه تأسیس) — `MockLicenseVerificationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `VerifyEstablishmentPermitAsync`/`VerifyENamadAsync` return `NeedsManualReview` (no automated registry → a human admin decides), so `VerifyPartnerCenterCommand` records the manual approval and activates the center. A config toggle makes clean checks return `Valid` (auto-approve path); an explicit `Invalid` verdict blocks activation. Registered singleton in `AddCrossCuttingSeams` | `Seams:LicenseVerification:AutoApprove` (default `false`) | 1) obtain access to a real eNamad status endpoint and/or the MoH establishment-permit registry (no public B2B API today — likely a manual/partner data feed at launch); 2) implement the two methods to look up the permit/eNamad code and return `Valid`/`Invalid` + a reason; 3) swap the registration (config-selected) — `VerifyPartnerCenter` is unchanged (it keeps the human-override decision authority) | 🟡 |
|
||||
|
||||
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
|
||||
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
|
||||
|
||||
@@ -67,3 +69,5 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
||||
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange` — `AddressForm` and every caller stay unchanged | 🟡 |
|
||||
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 1–5, `sortOrder` 0–4). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false` — `catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 |
|
||||
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false` — `verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
|
||||
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine** — `checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false` — `bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟡 |
|
||||
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 |
|
||||
|
||||
@@ -455,6 +455,47 @@ review_tags) / `PatientCareRecordsController`. Load-bearing rules:
|
||||
config `Seams:ReviewModeration`) is the AI pre-screen; clean text stays pending by default (publish gate),
|
||||
banned-word → auto-hidden. Decision authority stays with `ModerateReviewCommand` (human override).
|
||||
|
||||
**Messaging, partner centers & admin backoffice (backend-phase-15).** The final backend phase adds two schemas
|
||||
and consolidates the admin surface. A new **`messaging` schema** holds `Tickets` / `TicketParticipants` /
|
||||
`TicketMessages` (entities in `Domain/Entities/Messaging/` + `TicketStatus`/`TicketCategory`/`TicketParticipantRole`
|
||||
codes) — the only sanctioned post-booking channel. A new **`partner` schema** holds `PartnerCenters` (entity in
|
||||
`Domain/Entities/PartnerCenters/`, `IAuditable`; the licensed sponsor / merchant-of-record). Configs in
|
||||
`Persistence/Configuration/{MessagingConfig|PartnerCentersConfig}/`; per-domain repos `ITicketRepository` +
|
||||
`IPartnerCenterRepository` on `IUnitOfWork`; features under `Baya.Application/Features/{Messaging|PartnerCenters}/`;
|
||||
controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCentersController` / `CentersController`
|
||||
/ `InternalCentersController`; one migration (`MessagingAndPartnerCenters`, which also adds the
|
||||
`nurse_profiles.partner_center_id` FK in place). Load-bearing rules:
|
||||
- **`is_internal` is a HARD visibility boundary enforced at the QUERY layer.** `GetTicketThreadQuery` takes an
|
||||
`AsAdmin` flag; the user view (`false`) strips every `is_internal` message in the repository projection
|
||||
(`GetMessagesAsync(includeInternal:false)`), the admin view (`true`, staff only) returns them. A non-staff
|
||||
caller can never *set* `is_internal` on `PostMessage` nor *read* one. Never enforced only in the UI.
|
||||
- **No direct nurse↔customer channel.** All post-booking comms are ticket-mediated + admin-readable; participation
|
||||
(via `TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via `removed_at`) plus staff is the auth
|
||||
boundary. `reference_code` is minted once (collision-checked, UNIQUE) and stable. Both `booking_id`/`refund_id`
|
||||
links are nullable — handle a ticket with neither. A coordination ticket is auto-created (idempotent, one per
|
||||
booking) on confirmation via `AutoCreateCoordinationTicketCommand`, dispatched from the card confirm + BNPL
|
||||
settle handlers. `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call (+ optional
|
||||
`support_alert`) — it exposes no phone number.
|
||||
- **Merchant-of-record resolution follows `partner_centers`, not a hardcoded platform.**
|
||||
`PartnerCenterRepository.ResolveCenterForBookingAsync` (surfaced by `GetCenterForBookingQuery`, endpoint
|
||||
`GET /internal/bookings/{id}/center`) resolves booking → nurse → `partner_center_id`; the issuer/settlement
|
||||
target is `partner_center` **only** when that center `is_merchant_of_record`, else `platform`. This is the
|
||||
single resolver **b11's `IssueInvoice` now calls** to set `invoices.issuing_entity_type` + `partner_center_id`.
|
||||
- **`partner_centers` ≠ `organizations`.** The launch licensing *sponsor* (`partner_centers`) is distinct from
|
||||
the future *employer* (`organizations`, DEFERRED). `settlement_iban` is encrypted at rest (converter in
|
||||
`ApplicationDbContext`, `[AuditRedacted]`) and **masked** (last 4) in every read; `commission_rate` (the
|
||||
center's cut) is separate from `platform_fee_rate`. The four DEFERRED tables (`organizations`,
|
||||
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) are **not** created.
|
||||
- **Refund↔ticket link wired.** `CreateRefundCommand` (b11) now auto-opens a `category=refund` ticket via
|
||||
`OpenTicketCommand` when the caller supplies none, so `refunds.ticket_id` is always non-null.
|
||||
- **Backoffice consolidation surfaces, doesn't rebuild.** The support-alert worklist (`ISupportAlertService`
|
||||
List/Assign/Resolve — `SupportAlertsController`) and the audit viewer (`GetAuditTrail` — `AuditController`)
|
||||
already existed since b1 and are reused as-is; verification/refund/payout/moderation surfaces are their own
|
||||
phases'. New seam **`ILicenseVerificationService`** (`Contracts/Common`; mock `MockLicenseVerificationService`
|
||||
in CrossCutting, config `Seams:LicenseVerification`, `AutoApprove` toggle) is the eNamad / MoH permit check —
|
||||
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
|
||||
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
|
||||
|
||||
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
||||
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
||||
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
|
||||
using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
|
||||
using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
|
||||
using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
|
||||
using Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById;
|
||||
using Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.PartnerCenters;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// Admin partner-center console (admin/super_admin). Creates/updates/verifies licensed centers and sponsors
|
||||
/// nurses. The settlement IBAN is encrypted at rest and returned <b>masked</b> (last 4). Internal-only, audited.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/admin/partner-centers")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[Display(Description = "Admin partner-center management (licensed sponsor / merchant-of-record)")]
|
||||
public sealed class AdminPartnerCentersController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost]
|
||||
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
|
||||
public async Task<IActionResult> Create(CreatePartnerCenterCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
|
||||
public async Task<IActionResult> Update(long id, UpdatePartnerCenterCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
|
||||
|
||||
[HttpPost("{id}/verify")]
|
||||
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
|
||||
public async Task<IActionResult> Verify(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new VerifyPartnerCenterCommand(id), cancellationToken));
|
||||
|
||||
[HttpPost("{id}/sponsor-nurse")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken));
|
||||
|
||||
[HttpGet]
|
||||
[ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
[HttpGet("{id}")]
|
||||
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
|
||||
public async Task<IActionResult> GetById(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetPartnerCenterByIdQuery(id), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
using Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>Admin ticket console (support/admin): the global queue and the <b>admin</b> thread view (internal
|
||||
/// notes included). Internal-only, RBAC-gated, audited.</summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/admin/tickets")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[Display(Description = "Admin ticket queue + full (internal-inclusive) thread view")]
|
||||
public sealed class AdminTicketsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet]
|
||||
[ProducesOkApiResponseType<PagedResult<TicketSummaryDto>>]
|
||||
public async Task<IActionResult> List([FromQuery] ListTicketsForAdminQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
// Admin view — internal notes ARE returned.
|
||||
[HttpGet("{id}")]
|
||||
[ProducesOkApiResponseType<TicketThreadDto>]
|
||||
public async Task<IActionResult> Thread(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: true), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard;
|
||||
using Baya.Application.Models.PartnerCenters;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>The partner-center portal — scoped to the center's own dashboard account (or staff). Surfaces the
|
||||
/// sponsored nurses + booking/invoice counts + the masked settlement summary.</summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/centers")]
|
||||
[Authorize]
|
||||
[Display(Description = "Partner-center dashboard (center account scoped)")]
|
||||
public sealed class CentersController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("{id}/dashboard")]
|
||||
[ProducesOkApiResponseType<CenterDashboardDto>]
|
||||
public async Task<IActionResult> Dashboard(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetCenterDashboardQuery(id), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking;
|
||||
using Baya.Application.Models.PartnerCenters;
|
||||
using Baya.Infrastructure.Identity.Identity.PermissionManager;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>Internal/admin resolver: which center legally covers a booking (invoice issuer + settlement target).
|
||||
/// The single merchant-of-record decision — invoices and settlement follow <c>partner_centers</c>.</summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/internal/bookings")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)]
|
||||
[Display(Description = "Internal: resolve the invoice issuer / settlement center for a booking")]
|
||||
public sealed class InternalCentersController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("{bookingId}/center")]
|
||||
[ProducesOkApiResponseType<CenterForBookingDto>]
|
||||
public async Task<IActionResult> CenterForBooking(long bookingId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetCenterForBookingQuery(bookingId), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Asp.Versioning;
|
||||
using Baya.Application.Features.Messaging.Commands.AddParticipant;
|
||||
using Baya.Application.Features.Messaging.Commands.CloseTicket;
|
||||
using Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
|
||||
using Baya.Application.Features.Messaging.Commands.OpenTicket;
|
||||
using Baya.Application.Features.Messaging.Commands.PostMessage;
|
||||
using Baya.Application.Features.Messaging.Commands.RemoveParticipant;
|
||||
using Baya.Application.Features.Messaging.Commands.ReopenTicket;
|
||||
using Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
using Baya.Application.Features.Messaging.Queries.ListMyTickets;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.WebFramework.Attributes;
|
||||
using Baya.WebFramework.BaseController;
|
||||
using Mediator;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Baya.Web.Api.Controllers.V1;
|
||||
|
||||
/// <summary>
|
||||
/// The ticket system — the only sanctioned post-booking communication channel. All reads/writes are
|
||||
/// participation-gated (staff may attach to any ticket). The user thread view never contains an internal note.
|
||||
/// </summary>
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/tickets")]
|
||||
[Authorize]
|
||||
[Display(Description = "Post-booking ticket communication (participant-scoped)")]
|
||||
public sealed class TicketsController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpPost]
|
||||
[ProducesOkApiResponseType<OpenTicketResult>]
|
||||
public async Task<IActionResult> Open(OpenTicketCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("emergency")]
|
||||
[ProducesOkApiResponseType<OpenTicketResult>]
|
||||
public async Task<IActionResult> Emergency(LogEmergencyTicketCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command, cancellationToken));
|
||||
|
||||
[HttpPost("{id}/messages")]
|
||||
[ProducesOkApiResponseType<PostMessageResult>]
|
||||
public async Task<IActionResult> PostMessage(long id, PostMessageCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken));
|
||||
|
||||
[HttpPost("{id}/participants")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> AddParticipant(long id, AddParticipantCommand command, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken));
|
||||
|
||||
[HttpDelete("{id}/participants/{userId}")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> RemoveParticipant(long id, int userId, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new RemoveParticipantCommand(id, userId), cancellationToken));
|
||||
|
||||
[HttpPost("{id}/close")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> Close(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new CloseTicketCommand(id), cancellationToken));
|
||||
|
||||
[HttpPost("{id}/reopen")]
|
||||
[ProducesOkApiResponseType]
|
||||
public async Task<IActionResult> Reopen(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new ReopenTicketCommand(id), cancellationToken));
|
||||
|
||||
[HttpGet]
|
||||
[ProducesOkApiResponseType<PagedResult<TicketSummaryDto>>]
|
||||
public async Task<IActionResult> ListMine([FromQuery] ListMyTicketsQuery query, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(query, cancellationToken));
|
||||
|
||||
// User view — internal notes are stripped in the query projection.
|
||||
[HttpGet("{id}")]
|
||||
[ProducesOkApiResponseType<TicketThreadDto>]
|
||||
public async Task<IActionResult> Thread(long id, CancellationToken cancellationToken)
|
||||
=> OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: false), cancellationToken));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Baya.Domain.Entities.User;
|
||||
|
||||
namespace Baya.Application.Common;
|
||||
|
||||
/// <summary>
|
||||
/// The internal admin/staff role set. A caller holding any of these is "staff" — the ticket system grants staff
|
||||
/// full read/attach on any ticket (and the right to post internal notes), and admin endpoints authorize against
|
||||
/// the narrowest fitting scope. Kept in one place so every handler asks the same question.
|
||||
/// </summary>
|
||||
public static class StaffRoles
|
||||
{
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[RoleNames.Admin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation, RoleNames.SuperAdmin];
|
||||
|
||||
public static bool IsStaff(IEnumerable<string> roles) => roles.Any(All.Contains);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#nullable enable
|
||||
namespace Baya.Application.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Seam for verifying a partner center's licensing — the MoH establishment permit (پروانه تأسیس) and the
|
||||
/// eNamad trust seal (نماد اعتماد الکترونیکی) — against its authoritative source. There is <b>no public B2B
|
||||
/// API</b> for these today, so the default implementation returns
|
||||
/// <see cref="LicenseVerificationStatus.NeedsManualReview"/>: <c>VerifyPartnerCenter</c> records the human
|
||||
/// admin decision. When a real eNamad / MoH registry endpoint becomes available a real implementation replaces
|
||||
/// this registration and starts returning <see cref="LicenseVerificationStatus.Valid"/>/<see cref="LicenseVerificationStatus.Invalid"/>
|
||||
/// — <c>VerifyPartnerCenter</c> is unchanged.
|
||||
/// </summary>
|
||||
public interface ILicenseVerificationService
|
||||
{
|
||||
Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LicenseVerdict> VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Whether a license can be verified automatically or needs a manual admin decision.</summary>
|
||||
public enum LicenseVerificationStatus
|
||||
{
|
||||
NeedsManualReview,
|
||||
Valid,
|
||||
Invalid
|
||||
}
|
||||
|
||||
/// <summary>Outcome of an <see cref="ILicenseVerificationService"/> check.</summary>
|
||||
/// <param name="Status">Manual today; automated once a registry/API is available.</param>
|
||||
/// <param name="Reason">Human-readable reason for the verdict (esp. for manual/invalid).</param>
|
||||
public readonly record struct LicenseVerdict(LicenseVerificationStatus Status, string Reason);
|
||||
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.PartnerCenters;
|
||||
using Baya.Domain.Entities.PartnerCenters;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>partner_centers</c> aggregate. Writes load tracked rows; reads project to DTOs and <b>never</b> return
|
||||
/// the plaintext/full <c>settlement_iban</c> (masked to last 4). <see cref="ResolveCenterForBookingAsync"/> is
|
||||
/// the single merchant-of-record resolver: invoices + settlement follow the center, not a hardcoded platform.
|
||||
/// </summary>
|
||||
public interface IPartnerCenterRepository
|
||||
{
|
||||
Task AddAsync(PartnerCenter center, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked center — for update/verify/activate. Null if absent.</summary>
|
||||
Task<PartnerCenter?> GetTrackedAsync(long centerId, CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ExistsAsync(long centerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The admin detail view, with the settlement IBAN masked and the sponsored-nurse count. Null if absent.</summary>
|
||||
Task<PartnerCenterDetailDto?> GetDetailAsync(long centerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Admin paginated list (no IBAN), optional active filter, with sponsored-nurse counts.</summary>
|
||||
Task<PagedResult<PartnerCenterListItemDto>> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Resolves which center legally covers a booking (booking → nurse → <c>partner_center_id</c>),
|
||||
/// returning the issuer/settlement decision. Issuer is <c>partner_center</c> only when the sponsoring center
|
||||
/// is merchant-of-record; <c>platform</c> otherwise (incl. an unsponsored nurse). Null if the booking is absent.</summary>
|
||||
Task<CenterForBookingDto?> ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The center's dashboard admin <c>users.id</c> — the portal-scope check. Null if the center is absent.</summary>
|
||||
Task<int?> GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The center dashboard read model (sponsored nurses + booking/invoice counts). Null if absent.</summary>
|
||||
Task<CenterDashboardDto?> GetDashboardAsync(long centerId, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
|
||||
namespace Baya.Application.Contracts.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>tickets</c> aggregate (tickets + participants + messages). Writes load tracked rows; reads project to
|
||||
/// DTOs. The <b>hard visibility boundary</b> lives here: <see cref="GetMessagesAsync"/> only returns
|
||||
/// <c>is_internal</c> messages when <paramref name="includeInternal"/> (the admin view) is true — an internal
|
||||
/// note is filtered out of the projection for the user view, never surfaced downstream.
|
||||
/// </summary>
|
||||
public interface ITicketRepository
|
||||
{
|
||||
Task AddAsync(Ticket ticket, CancellationToken cancellationToken);
|
||||
|
||||
Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken);
|
||||
|
||||
Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked ticket (no includes) — for close/reopen transitions. Null if absent.</summary>
|
||||
Task<Ticket?> GetTrackedAsync(long ticketId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if a ticket already uses this reference code — the collision check before minting.</summary>
|
||||
Task<bool> ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked participant row for a (ticket, user) — active or soft-removed — so an add can resurrect
|
||||
/// a removed row and a remove can soft-stamp it. Null if the user was never on the ticket.</summary>
|
||||
Task<TicketParticipant?> GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if the user is an <b>active</b> participant on the ticket — the authorization backstop for
|
||||
/// reads/posts.</summary>
|
||||
Task<bool> IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if a <c>coordination</c> ticket already exists for the booking — the auto-create idempotency guard.</summary>
|
||||
Task<bool> CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The booking's customer + nurse <c>users.id</c> — the two participants of a coordination ticket.
|
||||
/// Null when the booking is absent.</summary>
|
||||
Task<BookingPartyUserIds?> GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The ticket's header facts (no messages) — for authorization + the thread response header. Null if absent.</summary>
|
||||
Task<TicketHeaderDto?> GetHeaderAsync(long ticketId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True if the booking exists and the given user is its customer or nurse — the tenancy check for
|
||||
/// opening a booking-linked ticket.</summary>
|
||||
Task<bool> IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The active participants on a thread.</summary>
|
||||
Task<IReadOnlyList<TicketParticipantDto>> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The user ids of the active participants (for notification fan-out).</summary>
|
||||
Task<IReadOnlyList<int>> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The ordered thread messages. <paramref name="includeInternal"/> = false (the user view) strips
|
||||
/// every <c>is_internal</c> message in the projection; true (the admin view) returns them.</summary>
|
||||
Task<IReadOnlyList<TicketMessageDto>> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Paginated tickets the user participates in (active membership), filterable by status and
|
||||
/// reference code, newest first.</summary>
|
||||
Task<PagedResult<TicketSummaryDto>> ListMyTicketsAsync(int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>The admin global queue — paginated, filter by status/category, search by reference code, optional
|
||||
/// booking/refund link, newest first.</summary>
|
||||
Task<PagedResult<TicketSummaryDto>> ListForAdminAsync(string? status, string? category, string? referenceCode, long? bookingId, long? refundId, int page, int pageSize, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The customer + nurse owning <c>users.id</c> for a booking.</summary>
|
||||
public readonly record struct BookingPartyUserIds(int CustomerUserId, int NurseUserId);
|
||||
@@ -25,6 +25,8 @@ public interface IUnitOfWork
|
||||
public IPayoutRepository PayoutRepository { get; }
|
||||
public IReviewRepository ReviewRepository { get; }
|
||||
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
|
||||
public ITicketRepository TicketRepository { get; }
|
||||
public IPartnerCenterRepository PartnerCenterRepository { get; }
|
||||
Task CommitAsync();
|
||||
ValueTask RollBackAsync();
|
||||
}
|
||||
|
||||
+6
-1
@@ -28,7 +28,8 @@ internal sealed class SettleBnplOrderCommandHandler(
|
||||
ISettlementSplitProvider settlementSplitProvider,
|
||||
IDistributedLock distributedLock,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
INotificationDispatcher notifications,
|
||||
ISender sender)
|
||||
: IRequestHandler<SettleBnplOrderCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(SettleBnplOrderCommand request, CancellationToken cancellationToken)
|
||||
@@ -121,6 +122,10 @@ internal sealed class SettleBnplOrderCommandHandler(
|
||||
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
|
||||
await NotifyConfirmedAsync(customerUserId, nurseUserId, conversion.BookingId, cancellationToken);
|
||||
|
||||
// Open the booking-coordination ticket once the booking is confirmed (idempotent, one per booking) — b15.
|
||||
if (conversion.Created)
|
||||
await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(conversion.BookingId), cancellationToken);
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -48,6 +48,11 @@ internal sealed class IssueInvoiceCommandHandler(
|
||||
var sequence = await unitOfWork.InvoiceRepository.ReserveNextInvoiceNumberAsync(cancellationToken);
|
||||
var invoiceNumber = $"INV-{sequence:D10}";
|
||||
|
||||
// Merchant-of-record resolution (b15): the invoice issuer + settlement target follow partner_centers, not
|
||||
// a hardcoded platform. GetCenterForBooking is the single resolver — it returns partner_center only when a
|
||||
// merchant-of-record center legally covers the booking, and platform otherwise.
|
||||
var issuer = await unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(request.BookingId, cancellationToken);
|
||||
|
||||
var submission = new InvoiceSubmission(invoiceNumber, request.BookingId, amounts.GrossIrr, amounts.PlatformCommissionIrr, vatIrr);
|
||||
var moadian = await moadianClient.SubmitAsync(submission, cancellationToken);
|
||||
|
||||
@@ -55,7 +60,8 @@ internal sealed class IssueInvoiceCommandHandler(
|
||||
{
|
||||
BookingId = request.BookingId,
|
||||
InvoiceNumber = invoiceNumber,
|
||||
IssuingEntityType = InvoiceIssuingEntityType.Platform,
|
||||
IssuingEntityType = issuer?.IssuingEntityType ?? InvoiceIssuingEntityType.Platform,
|
||||
PartnerCenterId = issuer?.PartnerCenterId,
|
||||
GrossIrr = amounts.GrossIrr,
|
||||
PlatformCommissionIrr = amounts.PlatformCommissionIrr,
|
||||
BnplCommissionIrr = amounts.BnplCommissionIrr,
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
|
||||
|
||||
/// <summary>
|
||||
/// Adds a user to a ticket (staff on any ticket, or the ticket owner). Enforces <c>UNIQUE(ticket_id, user_id)</c>:
|
||||
/// a duplicate active participant is a clean conflict; a previously-removed participant is resurrected (the same
|
||||
/// unique row), never re-inserted. The unique index is the authoritative backstop against a race.
|
||||
/// </summary>
|
||||
internal sealed class AddParticipantCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AddParticipantCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(AddParticipantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } callerId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
|
||||
if (header is null)
|
||||
return OperationResult<bool>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId)
|
||||
return OperationResult<bool>.ForbiddenResult("Only staff or the ticket owner can manage participants.");
|
||||
|
||||
var existing = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, request.UserId, cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.IsActive)
|
||||
return OperationResult<bool>.ConflictResult("That user is already a participant on this ticket.");
|
||||
|
||||
existing.Restore(callerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await unitOfWork.TicketRepository.AddParticipantAsync(new TicketParticipant
|
||||
{
|
||||
TicketId = request.TicketId,
|
||||
UserId = request.UserId,
|
||||
AddedById = callerId
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Lost the UNIQUE(ticket_id, user_id) race — treat as the duplicate it is, not a 500.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return OperationResult<bool>.ConflictResult("That user is already a participant on this ticket.");
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
|
||||
|
||||
public sealed class AddParticipantCommandValidator : AbstractValidator<AddParticipantCommand>
|
||||
{
|
||||
public AddParticipantCommandValidator()
|
||||
{
|
||||
// TicketId is route-supplied (merged via `with`); only the body-supplied UserId is validated.
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.AddParticipant;
|
||||
|
||||
/// <summary>Attaches a user to a ticket. Staff (any ticket) or the ticket owner may add. A duplicate add returns
|
||||
/// a clean conflict, backed by the <c>UNIQUE(ticket_id, user_id)</c> index.</summary>
|
||||
public record AddParticipantCommand(long TicketId, int UserId) : IRequest<OperationResult<bool>>;
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the one booking-scoped coordination ticket + its nurse/customer participants, idempotently. The
|
||||
/// idempotency guard is <c>CoordinationTicketExistsForBookingAsync</c> (a re-confirmation is a no-op). Not an
|
||||
/// end-user path — there is no participant/tenancy gate; the participants are resolved from the booking itself.
|
||||
/// </summary>
|
||||
internal sealed class AutoCreateCoordinationTicketCommandHandler(
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<AutoCreateCoordinationTicketCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(AutoCreateCoordinationTicketCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await unitOfWork.TicketRepository.CoordinationTicketExistsForBookingAsync(request.BookingId, cancellationToken))
|
||||
return OperationResult<bool>.SuccessResult(false);
|
||||
|
||||
var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken);
|
||||
if (parties is not { } p)
|
||||
return OperationResult<bool>.NotFoundResult("Booking not found.");
|
||||
|
||||
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = referenceCode,
|
||||
Category = TicketCategory.Coordination,
|
||||
Subject = "Booking coordination",
|
||||
BookingId = request.BookingId,
|
||||
OpenedById = p.CustomerUserId
|
||||
};
|
||||
ticket.Participants.Add(new TicketParticipant { UserId = p.CustomerUserId, RoleOnTicket = TicketParticipantRole.Customer });
|
||||
ticket.Participants.Add(new TicketParticipant { UserId = p.NurseUserId, RoleOnTicket = TicketParticipantRole.Nurse });
|
||||
|
||||
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
|
||||
{
|
||||
// A concurrent confirmation won the race — the one-per-booking rule holds; treat as no-op.
|
||||
await unitOfWork.RollBackAsync();
|
||||
return OperationResult<bool>.SuccessResult(false);
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket;
|
||||
|
||||
/// <summary>
|
||||
/// On booking confirmation, auto-creates the <c>coordination</c> ticket linked to the booking and adds the nurse
|
||||
/// + customer as participants. Idempotent — exactly one coordination ticket per booking (a re-confirmation must
|
||||
/// not create a second). Invoked by the b9/b10/b12 confirmation flow, not by an end user. Returns <c>true</c>
|
||||
/// when it created the ticket, <c>false</c> when one already existed.
|
||||
/// </summary>
|
||||
public record AutoCreateCoordinationTicketCommand(long BookingId) : IRequest<OperationResult<bool>>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.CloseTicket;
|
||||
|
||||
/// <summary>Closes a ticket (participant or staff). Idempotent — an already-closed ticket is a no-op success. The
|
||||
/// owner trail comes from the audit fields.</summary>
|
||||
internal sealed class CloseTicketCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<CloseTicketCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(CloseTicketCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken);
|
||||
if (ticket is null)
|
||||
return OperationResult<bool>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!StaffRoles.IsStaff(currentUser.Roles) &&
|
||||
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
|
||||
return OperationResult<bool>.ForbiddenResult("You are not a participant on this ticket.");
|
||||
|
||||
if (ticket.IsOpen)
|
||||
{
|
||||
ticket.Close(userId, dateTimeProvider.UtcNow);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.CloseTicket;
|
||||
|
||||
/// <summary>Closes an open ticket (open → closed), stamping who/when. A participant or staff may close.</summary>
|
||||
public record CloseTicketCommand(long TicketId) : IRequest<OperationResult<bool>>;
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Contracts.SupportAlerts;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Baya.Domain.Entities.SupportAlerts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the emergency ticket (assigned nurse or staff) and, when requested, raises an internal
|
||||
/// <c>support_alerts</c> row via the b1 raise API. The ticket + alert are the durable record staff triage; the
|
||||
/// emergency call itself is out-of-platform (a <c>tel:</c> link in the UI), so there is no telephony seam here.
|
||||
/// </summary>
|
||||
internal sealed class LogEmergencyTicketCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
ISupportAlertService supportAlerts)
|
||||
: IRequestHandler<LogEmergencyTicketCommand, OperationResult<OpenTicketResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OpenTicketResult>> Handle(LogEmergencyTicketCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<OpenTicketResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken);
|
||||
if (parties is not { } p)
|
||||
return OperationResult<OpenTicketResult>.NotFoundResult("Booking not found.");
|
||||
|
||||
// Only the assigned nurse (or staff) may log an emergency on the booking.
|
||||
if (!StaffRoles.IsStaff(currentUser.Roles) && p.NurseUserId != userId)
|
||||
return OperationResult<OpenTicketResult>.ForbiddenResult("Only the assigned nurse can log an emergency on this booking.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = referenceCode,
|
||||
Category = TicketCategory.Emergency,
|
||||
Subject = "On-site emergency",
|
||||
BookingId = request.BookingId,
|
||||
OpenedById = userId
|
||||
};
|
||||
ticket.Participants.Add(new TicketParticipant
|
||||
{
|
||||
UserId = userId,
|
||||
RoleOnTicket = TicketRoleResolver.Derive(currentUser.Roles),
|
||||
AddedById = userId
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(request.Body))
|
||||
ticket.Messages.Add(new TicketMessage { SenderId = userId, Body = request.Body, SentAt = now });
|
||||
|
||||
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Self-committing facade — runs after the ticket is committed so the alert references a real ticket id.
|
||||
if (request.RaiseAlert)
|
||||
await supportAlerts.RaiseAsync(
|
||||
SupportAlertType.Emergency, entityType: "ticket", entityId: ticket.Id.ToString(),
|
||||
severity: SupportAlertSeverity.High, bookingId: request.BookingId, cancellationToken: cancellationToken);
|
||||
|
||||
return OperationResult<OpenTicketResult>.SuccessResult(
|
||||
new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category));
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.LogEmergencyTicket;
|
||||
|
||||
/// <summary>
|
||||
/// The <b>operational</b> side of the on-site emergency playbook: after the nurse has called the emergency
|
||||
/// contact (surfaced from the encrypted care instructions, out of platform), this records the aftermath — a
|
||||
/// <c>category=emergency</c> ticket on the booking, and optionally a <c>support_alerts</c> row. It does
|
||||
/// <b>not</b> dial anyone and does <b>not</b> expose any phone number; it does not widen the clinical disclosure.
|
||||
/// </summary>
|
||||
public record LogEmergencyTicketCommand(long BookingId, string? Body, bool RaiseAlert = true)
|
||||
: IRequest<OperationResult<OpenTicketResult>>;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the ticket + the opener participant (+ an optional first message) in one commit. Enforces the link
|
||||
/// tenancy: a booking link requires the opener to be a party to the booking (staff bypass — staff read/attach to
|
||||
/// any ticket); a refund link is staff-only (the b11 dispute paper trail). The <c>reference_code</c> is minted
|
||||
/// once, collision-checked, and backed by the UNIQUE index.
|
||||
/// </summary>
|
||||
internal sealed class OpenTicketCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<OpenTicketCommand, OperationResult<OpenTicketResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<OpenTicketResult>> Handle(OpenTicketCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<OpenTicketResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
|
||||
|
||||
if (request.BookingId is { } bookingId && !isStaff)
|
||||
{
|
||||
// Tenancy: a non-staff opener must be the booking's customer or nurse. A mismatch is a clean 404.
|
||||
var isParty = await unitOfWork.TicketRepository.IsUserPartyToBookingAsync(bookingId, userId, cancellationToken);
|
||||
if (!isParty)
|
||||
return OperationResult<OpenTicketResult>.NotFoundResult("Booking not found.");
|
||||
}
|
||||
|
||||
if (request.RefundId is not null && !isStaff)
|
||||
return OperationResult<OpenTicketResult>.ForbiddenResult("Only staff can anchor a ticket to a refund.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var referenceCode = await TicketReferenceCode.MintAsync(unitOfWork.TicketRepository, cancellationToken);
|
||||
|
||||
var ticket = new Ticket
|
||||
{
|
||||
ReferenceCode = referenceCode,
|
||||
Subject = request.Subject,
|
||||
Category = request.Category,
|
||||
BookingId = request.BookingId,
|
||||
RefundId = request.RefundId,
|
||||
OpenedById = userId
|
||||
};
|
||||
ticket.Participants.Add(new TicketParticipant
|
||||
{
|
||||
UserId = userId,
|
||||
RoleOnTicket = TicketRoleResolver.Derive(currentUser.Roles),
|
||||
AddedById = userId
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Body))
|
||||
ticket.Messages.Add(new TicketMessage
|
||||
{
|
||||
SenderId = userId,
|
||||
Body = request.Body,
|
||||
IsInternal = false,
|
||||
SentAt = now
|
||||
});
|
||||
|
||||
await unitOfWork.TicketRepository.AddAsync(ticket, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<OpenTicketResult>.SuccessResult(
|
||||
new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
|
||||
|
||||
public sealed class OpenTicketCommandValidator : AbstractValidator<OpenTicketCommand>
|
||||
{
|
||||
public OpenTicketCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Category)
|
||||
.NotEmpty()
|
||||
.Must(TicketCategory.IsValid)
|
||||
.WithMessage($"Category must be one of: {string.Join(", ", TicketCategory.All)}.");
|
||||
|
||||
RuleFor(x => x.Subject).MaximumLength(300);
|
||||
RuleFor(x => x.Body).MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.OpenTicket;
|
||||
|
||||
/// <summary>
|
||||
/// Opens a ticket, mints a stable unique <c>reference_code</c>, attaches the optional (both nullable)
|
||||
/// <see cref="BookingId"/>/<see cref="RefundId"/> links, and adds the opener as the first participant. Used by
|
||||
/// the customer/nurse "contact support" flow, the b11 refund flow (which anchors <c>refunds.ticket_id</c> here),
|
||||
/// and <c>LogEmergencyTicket</c>. A booking link requires the opener to be a party to the booking (staff bypass);
|
||||
/// a refund link is staff-only.
|
||||
/// </summary>
|
||||
public record OpenTicketCommand(
|
||||
string Category,
|
||||
string? Subject,
|
||||
string? Body,
|
||||
long? BookingId,
|
||||
long? RefundId) : IRequest<OperationResult<OpenTicketResult>>;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Baya.Domain.Entities.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
|
||||
|
||||
/// <summary>
|
||||
/// Appends a message to a ticket after enforcing the visibility + posting rules: only an active participant (or
|
||||
/// staff) may post; <c>is_internal</c> is staff-only (the hard boundary); a non-staff caller cannot post to a
|
||||
/// closed ticket. A non-internal message notifies the other active participants (in-app only at MVP).
|
||||
/// </summary>
|
||||
internal sealed class PostMessageCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider,
|
||||
INotificationDispatcher notifications)
|
||||
: IRequestHandler<PostMessageCommand, OperationResult<PostMessageResult>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PostMessageResult>> Handle(PostMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PostMessageResult>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
|
||||
|
||||
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
|
||||
if (header is null)
|
||||
return OperationResult<PostMessageResult>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!isStaff && !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
|
||||
return OperationResult<PostMessageResult>.ForbiddenResult("You are not a participant on this ticket.");
|
||||
|
||||
if (request.IsInternal && !isStaff)
|
||||
return OperationResult<PostMessageResult>.ForbiddenResult("Only staff can post an internal note.");
|
||||
|
||||
if (header.Status == TicketStatus.Closed && !isStaff)
|
||||
return OperationResult<PostMessageResult>.ForbiddenResult("This ticket is closed.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow;
|
||||
var message = new TicketMessage
|
||||
{
|
||||
TicketId = request.TicketId,
|
||||
SenderId = userId,
|
||||
Body = request.Body,
|
||||
IsInternal = request.IsInternal,
|
||||
SentAt = now
|
||||
};
|
||||
|
||||
await unitOfWork.TicketRepository.AddMessageAsync(message, cancellationToken);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Notify the other active participants — but never for an internal note (it must not surface to users).
|
||||
if (!message.IsInternal)
|
||||
{
|
||||
var recipients = await unitOfWork.TicketRepository.GetActiveParticipantUserIdsAsync(request.TicketId, cancellationToken);
|
||||
foreach (var recipient in recipients.Where(r => r != userId))
|
||||
await notifications.DispatchAsync(
|
||||
new Notification(recipient, "ticket_message",
|
||||
"New ticket message", $"New message on ticket {header.ReferenceCode}.",
|
||||
$"{{\"ticketId\":{request.TicketId},\"referenceCode\":\"{header.ReferenceCode}\"}}"),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return OperationResult<PostMessageResult>.SuccessResult(
|
||||
new PostMessageResult(message.Id, request.TicketId, message.SentAt));
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
|
||||
|
||||
public sealed class PostMessageCommandValidator : AbstractValidator<PostMessageCommand>
|
||||
{
|
||||
public PostMessageCommandValidator()
|
||||
{
|
||||
// TicketId is route-supplied (merged via `with`), so it is not validated here.
|
||||
RuleFor(x => x.Body).NotEmpty().MaximumLength(4000);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.PostMessage;
|
||||
|
||||
/// <summary>Appends a message to a ticket. Only an active participant (or staff) may post. <see cref="IsInternal"/>
|
||||
/// (an admin-only note) can be set <b>only</b> by staff; a non-staff caller can neither set it nor post to a
|
||||
/// closed ticket.</summary>
|
||||
public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false)
|
||||
: IRequest<OperationResult<PostMessageResult>>;
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.RemoveParticipant;
|
||||
|
||||
/// <summary>Soft-removes a participant (staff on any ticket, or the ticket owner). The unique row survives so a
|
||||
/// later re-add resurrects it.</summary>
|
||||
internal sealed class RemoveParticipantCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<RemoveParticipantCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(RemoveParticipantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } callerId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
|
||||
if (header is null)
|
||||
return OperationResult<bool>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId)
|
||||
return OperationResult<bool>.ForbiddenResult("Only staff or the ticket owner can manage participants.");
|
||||
|
||||
var participant = await unitOfWork.TicketRepository.GetParticipantAsync(request.TicketId, request.UserId, cancellationToken);
|
||||
if (participant is null || !participant.IsActive)
|
||||
return OperationResult<bool>.NotFoundResult("That user is not an active participant on this ticket.");
|
||||
|
||||
participant.Remove(dateTimeProvider.UtcNow);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.RemoveParticipant;
|
||||
|
||||
/// <summary>Detaches a user from a ticket (soft <c>removed_at</c> stamp). Staff (any ticket) or the ticket owner
|
||||
/// may remove.</summary>
|
||||
public record RemoveParticipantCommand(long TicketId, int UserId) : IRequest<OperationResult<bool>>;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.ReopenTicket;
|
||||
|
||||
/// <summary>Reopens a ticket (participant or staff). Idempotent — an already-open ticket is a no-op success.</summary>
|
||||
internal sealed class ReopenTicketCommandHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ReopenTicketCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(ReopenTicketCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<bool>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken);
|
||||
if (ticket is null)
|
||||
return OperationResult<bool>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!StaffRoles.IsStaff(currentUser.Roles) &&
|
||||
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
|
||||
return OperationResult<bool>.ForbiddenResult("You are not a participant on this ticket.");
|
||||
|
||||
if (!ticket.IsOpen)
|
||||
{
|
||||
ticket.Reopen();
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Commands.ReopenTicket;
|
||||
|
||||
/// <summary>Reopens a closed ticket (closed → open). A participant or staff may reopen.</summary>
|
||||
public record ReopenTicketCommand(long TicketId) : IRequest<OperationResult<bool>>;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the header, active participants, and messages of a ticket the caller may read. Internal messages are
|
||||
/// included only in the admin view (staff) — the user view's projection strips them. Authorization: the admin
|
||||
/// view requires staff; the user view requires an active participant (staff may also read the user view).
|
||||
/// </summary>
|
||||
internal sealed class GetTicketThreadQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<GetTicketThreadQuery, OperationResult<TicketThreadDto>>
|
||||
{
|
||||
public async ValueTask<OperationResult<TicketThreadDto>> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<TicketThreadDto>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var isStaff = StaffRoles.IsStaff(currentUser.Roles);
|
||||
|
||||
if (request.AsAdmin && !isStaff)
|
||||
return OperationResult<TicketThreadDto>.ForbiddenResult("Admin ticket view requires staff access.");
|
||||
|
||||
var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken);
|
||||
if (header is null)
|
||||
return OperationResult<TicketThreadDto>.NotFoundResult("Ticket not found.");
|
||||
|
||||
if (!request.AsAdmin && !isStaff &&
|
||||
!await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken))
|
||||
return OperationResult<TicketThreadDto>.ForbiddenResult("You are not a participant on this ticket.");
|
||||
|
||||
// Internal notes surface ONLY in the admin view; the user view never receives them.
|
||||
var includeInternal = request.AsAdmin && isStaff;
|
||||
|
||||
var participants = await unitOfWork.TicketRepository.GetActiveParticipantsAsync(request.TicketId, cancellationToken);
|
||||
var messages = await unitOfWork.TicketRepository.GetMessagesAsync(request.TicketId, includeInternal, cancellationToken);
|
||||
|
||||
return OperationResult<TicketThreadDto>.SuccessResult(new TicketThreadDto(
|
||||
header.Id, header.ReferenceCode, header.Subject, header.Status, header.Category,
|
||||
header.BookingId, header.RefundId, header.OpenedById, header.ClosedAt, participants, messages));
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.GetTicketThread;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ordered thread for a ticket the caller may see. <see cref="AsAdmin"/> selects the view: the
|
||||
/// <b>user</b> view (<c>false</c>) strips every <c>is_internal</c> message in the projection; the <b>admin</b>
|
||||
/// view (<c>true</c>, staff only) returns them. This is enforced at the query layer, never in the UI.
|
||||
/// </summary>
|
||||
public record GetTicketThreadQuery(long TicketId, bool AsAdmin = false)
|
||||
: IRequest<OperationResult<TicketThreadDto>>;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
|
||||
|
||||
internal sealed class ListMyTicketsQueryHandler(
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListMyTicketsQuery, OperationResult<PagedResult<TicketSummaryDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<TicketSummaryDto>>> Handle(ListMyTicketsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentUser.UserId is not { } userId)
|
||||
return OperationResult<PagedResult<TicketSummaryDto>>.UnauthorizedResult("Not authenticated.");
|
||||
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
var result = await unitOfWork.TicketRepository.ListMyTicketsAsync(userId, request.Status, request.ReferenceCode, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.ListMyTickets;
|
||||
|
||||
/// <summary>The caller's tickets (active participation), paginated, filterable by status and reference code, newest first.</summary>
|
||||
public record ListMyTicketsQuery(
|
||||
string? Status = null,
|
||||
string? ReferenceCode = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Common;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
|
||||
|
||||
internal sealed class ListTicketsForAdminQueryHandler(IUnitOfWork unitOfWork)
|
||||
: IRequestHandler<ListTicketsForAdminQuery, OperationResult<PagedResult<TicketSummaryDto>>>
|
||||
{
|
||||
public async ValueTask<OperationResult<PagedResult<TicketSummaryDto>>> Handle(ListTicketsForAdminQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
|
||||
var result = await unitOfWork.TicketRepository.ListForAdminAsync(
|
||||
request.Status, request.Category, request.ReferenceCode, request.BookingId, request.RefundId, page, pageSize, cancellationToken);
|
||||
return OperationResult<PagedResult<TicketSummaryDto>>.SuccessResult(result);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Messaging;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Messaging.Queries.ListTicketsForAdmin;
|
||||
|
||||
/// <summary>The admin global ticket queue, paginated, filter by status/category, search by reference code, optional
|
||||
/// booking/refund link, newest first.</summary>
|
||||
public record ListTicketsForAdminQuery(
|
||||
string? Status = null,
|
||||
string? Category = null,
|
||||
string? ReferenceCode = null,
|
||||
long? BookingId = null,
|
||||
long? RefundId = null,
|
||||
int Page = 1,
|
||||
int PageSize = 50) : IRequest<OperationResult<PagedResult<TicketSummaryDto>>>;
|
||||
@@ -0,0 +1,37 @@
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
|
||||
namespace Baya.Application.Features.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Mints the human-facing, UNIQUE, stable ticket <c>reference_code</c> (e.g. <c>TKT-9F3K2A7Q</c>). Collision is
|
||||
/// astronomically unlikely, but the code is quoted to users and backed by a UNIQUE index, so we collision-check
|
||||
/// against the store and retry — the index is the authoritative backstop, this is the friendly pre-check.
|
||||
/// </summary>
|
||||
public static class TicketReferenceCode
|
||||
{
|
||||
private const string Prefix = "TKT-";
|
||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous 0/O/1/I
|
||||
private const int Length = 8;
|
||||
|
||||
public static async Task<string> MintAsync(ITicketRepository tickets, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
var code = Generate();
|
||||
if (!await tickets.ReferenceCodeExistsAsync(code, cancellationToken))
|
||||
return code;
|
||||
}
|
||||
|
||||
// Extremely improbable; fall back to a longer, still-checked code rather than loop forever.
|
||||
return Prefix + Guid.NewGuid().ToString("N")[..12].ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string Generate()
|
||||
{
|
||||
var bytes = Guid.NewGuid().ToByteArray();
|
||||
var chars = new char[Length];
|
||||
for (var i = 0; i < Length; i++)
|
||||
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
|
||||
return Prefix + new string(chars);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user