diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 7a3427e..7376011 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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) diff --git a/client/jest.setup.ts b/client/jest.setup.ts index 55924c4..2461643 100644 --- a/client/jest.setup.ts +++ b/client/jest.setup.ts @@ -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')); diff --git a/client/messages/en.json b/client/messages/en.json index c084676..03d2762 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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", diff --git a/client/messages/fa.json b/client/messages/fa.json index 3dcf9ae..96daadf 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "ورود به بلینیار", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx new file mode 100644 index 0000000..de37cb6 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx @@ -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 0 ? id : -1} viewerRole="customer" />; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx index d5bbd2e..d85e32c 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/page.tsx @@ -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 ; +/** + * 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 ( + + + + {t('list_title')} + + + {t('list_subtitle')} + + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : isError ? ( + + + {t('list_error')} + + + ) : items.length === 0 ? ( + + + + {t('list_empty_title')} + + + {t('list_empty_body')} + + + ) : ( + + {items.map((item) => ( + + ))} + + )} + + ); +} + +function BookingRow({ item }: { item: BookingListItemDto }) { + const t = useTranslations('booking'); + const tc = useTranslations('common'); + const locale = useLocale(); + const router = useRouter(); + + return ( + + + + + + {item.counterpartyName} + + + {formatShamsiDate(item.scheduledDate, locale)} · {t('session_count', { count: item.sessionCount })} + + + + + + + + {t('list_total')}: {formatIrrToToman(item.amountIrr, locale)} {tc('currency_toman')} + + router.push(`/${locale}${ROUTES.BOOKINGS}/${item.id}`)} + sx={{ m: 0 }} + > + {t('view_booking')} + + + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx new file mode 100644 index 0000000..d0a8892 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/[id]/page.tsx @@ -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 0 ? id : -1} viewerRole="nurse" />; +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx index f03fb89..fc36620 100644 --- a/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx +++ b/client/src/app/[locale]/(private-routes)/nurse/visits/page.tsx @@ -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 ; +/** + * 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 ( + + + + {t('evv_visits_title')} + + + {t('evv_visits_subtitle')} + + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : items.length === 0 ? ( + + + + {t('evv_no_visits')} + + + ) : ( + + {items.map((item) => ( + + ))} + + )} + + ); +} + +function TodayVisitCard({ item, evv }: { item: BookingSessionListItemDto; evv: ReturnType }) { + 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 ( + + evv.checkIn({ sessionId: item.sessionId, bookingId: item.bookingId })} + onCheckOut={() => evv.checkOut({ sessionId: item.sessionId, bookingId: item.bookingId })} + /> + router.push(`/${locale}${ROUTES.NURSE_VISITS}/${item.bookingId}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('view_booking')} + + + ); } diff --git a/client/src/components/booking/BookingDetailView/BookingDetailView.test.tsx b/client/src/components/booking/BookingDetailView/BookingDetailView.test.tsx new file mode 100644 index 0000000..6b9f434 --- /dev/null +++ b/client/src/components/booking/BookingDetailView/BookingDetailView.test.tsx @@ -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( + + + + + , + ); +} + +describe(' — 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()); + }); +}); diff --git a/client/src/components/booking/BookingDetailView/BookingDetailView.tsx b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx new file mode 100644 index 0000000..6de354f --- /dev/null +++ b/client/src/components/booking/BookingDetailView/BookingDetailView.tsx @@ -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 = ({ 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 ; + if (isError || !booking) return ; + + const service = variantName(booking.variantSnapshotJson); + + return ( + + {/* Header — carries the terracotta accent + "نمای پرستار" chip on the nurse view. */} + + + + + {service ?? t('bd_title')} + + {isNurse ? ( + + ) : null} + + + {t('bd_ref', { id: booking.id })} + + + + + + + + + + + + + + + + + {t('sessions_title')} + + evv.checkIn({ sessionId: s.id, bookingId: booking.id }) : undefined} + onCheckOut={isNurse ? (s) => evv.checkOut({ sessionId: s.id, bookingId: booking.id }) : undefined} + /> + + + + + + + ); +}; + +function HeaderFact({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** 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 ( + + + + {lines.map((line) => ( + + {line} + + ))} + + + ); +} + +/** 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; +}) { + const t = useTranslations('booking'); + + if (viewerRole !== 'nurse') { + // Customer: never renders the clinical record and never fires the query. + return ( + + + + + + {t('care_locked_title')} + + + {t('care_locked_body')} + + + + + ); + } + + if (!careEnabled) return null; + if (care.isLoading) return ; + if (care.isError || !care.data) { + return ( + + + {t('care_error')} + + + ); + } + return ; +} + +function NotFoundCard({ title, body }: { title: string; body: string }) { + return ( + + + + {title} + + + {body} + + + ); +} + +function DetailSkeleton() { + return ( + + + + + + + + ); +} + +export default BookingDetailView; diff --git a/client/src/components/booking/BookingDetailView/index.tsx b/client/src/components/booking/BookingDetailView/index.tsx new file mode 100644 index 0000000..b49562e --- /dev/null +++ b/client/src/components/booking/BookingDetailView/index.tsx @@ -0,0 +1,2 @@ +export { default } from './BookingDetailView'; +export type { BookingDetailViewProps } from './BookingDetailView'; diff --git a/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.test.tsx b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.test.tsx new file mode 100644 index 0000000..1995a53 --- /dev/null +++ b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.test.tsx @@ -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( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx new file mode 100644 index 0000000..27dff07 --- /dev/null +++ b/client/src/components/booking/BookingMoneySummary/BookingMoneySummary.tsx @@ -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 = ({ + 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 ( + + + {t('money_title')} + + + + + + + + ); +}; + +function MoneyRow({ + label, + value, + emphasize = false, + accent = false, +}: { + label: string; + value: string; + emphasize?: boolean; + accent?: boolean; +}): ReactNode { + return ( + + + {label} + + + {value} + + + ); +} + +export default BookingMoneySummary; diff --git a/client/src/components/booking/BookingMoneySummary/index.tsx b/client/src/components/booking/BookingMoneySummary/index.tsx new file mode 100644 index 0000000..66515c1 --- /dev/null +++ b/client/src/components/booking/BookingMoneySummary/index.tsx @@ -0,0 +1,2 @@ +export { default } from './BookingMoneySummary'; +export type { BookingMoneySummaryProps } from './BookingMoneySummary'; diff --git a/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.test.tsx b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.test.tsx new file mode 100644 index 0000000..fade464 --- /dev/null +++ b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.test.tsx @@ -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[0]['status']) { + return render( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx new file mode 100644 index 0000000..1c52ee3 --- /dev/null +++ b/client/src/components/booking/BookingStatusTimeline/BookingStatusTimeline.tsx @@ -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 = ({ status }) => { + const t = useTranslations('booking'); + const steps = BOOKING_TIMELINE_ORDER.map((s) => t(`bstatus_${s}`)); + + return ( + + + + {t('timeline_title')} + + + + + {status === 'cancelled' ? ( + + + + {t('bstatus_cancelled')} + + + ) : ( + + )} + + ); +}; + +export default BookingStatusTimeline; diff --git a/client/src/components/booking/BookingStatusTimeline/index.tsx b/client/src/components/booking/BookingStatusTimeline/index.tsx new file mode 100644 index 0000000..a0e4d42 --- /dev/null +++ b/client/src/components/booking/BookingStatusTimeline/index.tsx @@ -0,0 +1,2 @@ +export { default } from './BookingStatusTimeline'; +export type { BookingStatusTimelineProps } from './BookingStatusTimeline'; diff --git a/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.test.tsx b/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.test.tsx new file mode 100644 index 0000000..9aa9817 --- /dev/null +++ b/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.test.tsx @@ -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( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.tsx b/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.tsx new file mode 100644 index 0000000..1dd835c --- /dev/null +++ b/client/src/components/booking/CareInstructionsCard/CareInstructionsCard.tsx @@ -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 = ({ 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 ( + + + + + + {t('care_title')} + + + + {t('care_subtitle')} + + + + {isEmpty ? ( + + {t('care_empty')} + + ) : ( + } sx={{ gap: 1.5 }}> + + + + + + + )} + + ); +}; + +function CareRow({ + icon, + label, + value, + noneLabel, + ltrValue = false, +}: { + icon: string; + label: string; + value: string | null; + noneLabel: string; + ltrValue?: boolean; +}): ReactNode { + return ( + + + + + {label} + + + {value || noneLabel} + + + + ); +} + +export default CareInstructionsCard; diff --git a/client/src/components/booking/CareInstructionsCard/index.tsx b/client/src/components/booking/CareInstructionsCard/index.tsx new file mode 100644 index 0000000..e1dafc3 --- /dev/null +++ b/client/src/components/booking/CareInstructionsCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CareInstructionsCard'; +export type { CareInstructionsCardProps } from './CareInstructionsCard'; diff --git a/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.test.tsx b/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.test.tsx new file mode 100644 index 0000000..6ec089d --- /dev/null +++ b/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.test.tsx @@ -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( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.tsx b/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.tsx new file mode 100644 index 0000000..d47fee8 --- /dev/null +++ b/client/src/components/booking/EvvStatusBanner/EvvStatusBanner.tsx @@ -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 = ({ checkInAtIso, addressMatch }) => { + const t = useTranslations('booking'); + const locale = useLocale(); + const style = styleFor(addressMatch); + const time = formatClock(checkInAtIso, locale); + + return ( + + + + {t(style.messageKey, { time })} + + + ); +}; + +export default EvvStatusBanner; diff --git a/client/src/components/booking/EvvStatusBanner/index.tsx b/client/src/components/booking/EvvStatusBanner/index.tsx new file mode 100644 index 0000000..ae481e2 --- /dev/null +++ b/client/src/components/booking/EvvStatusBanner/index.tsx @@ -0,0 +1,2 @@ +export { default } from './EvvStatusBanner'; +export type { EvvStatusBannerProps, EvvMatchState } from './EvvStatusBanner'; diff --git a/client/src/components/booking/SessionCard/SessionCard.test.tsx b/client/src/components/booking/SessionCard/SessionCard.test.tsx new file mode 100644 index 0000000..83f309b --- /dev/null +++ b/client/src/components/booking/SessionCard/SessionCard.test.tsx @@ -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 = {}) { + return render( + + + , + ); +} + +describe(' 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(); + }); +}); diff --git a/client/src/components/booking/SessionCard/SessionCard.tsx b/client/src/components/booking/SessionCard/SessionCard.tsx new file mode 100644 index 0000000..a1c050f --- /dev/null +++ b/client/src/components/booking/SessionCard/SessionCard.tsx @@ -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 = ({ + 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 ( + + + {title ? ( + + {title} + + ) : null} + + + {t('session_index', { n: sessionIndex })} + + + + + + {dateLabel} · {timeLabel} + + + {showBanner ? : null} + + {checkOutAt ? ( + + {t('evv_checked_out_at', { time: formatClock(checkOutAt, locale) })} + {elapsed ? ` · ${t('session_elapsed', { duration: elapsed })}` : ''} + + ) : null} + + {showPayout && visitPayoutAmount ? ( + + {t('session_payout')}: {formatIrrToToman(visitPayoutAmount, locale)} {tc('currency_toman')} + + ) : null} + + {showEvvControls && status === 'scheduled' ? ( + + {acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_in') : t('evv_check_in')} + + ) : null} + + {showEvvControls && status === 'in_progress' && evvStatus === 'checked_in' ? ( + + {acquiringLocation ? t('evv_acquiring_location') : evvPending ? t('evv_checking_out') : t('evv_check_out')} + + ) : null} + + + ); +}; + +export default SessionCard; diff --git a/client/src/components/booking/SessionCard/index.tsx b/client/src/components/booking/SessionCard/index.tsx new file mode 100644 index 0000000..a54a65b --- /dev/null +++ b/client/src/components/booking/SessionCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './SessionCard'; +export type { SessionCardProps } from './SessionCard'; diff --git a/client/src/components/booking/SessionList/SessionList.test.tsx b/client/src/components/booking/SessionList/SessionList.test.tsx new file mode 100644 index 0000000..374565b --- /dev/null +++ b/client/src/components/booking/SessionList/SessionList.test.tsx @@ -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( + + + , + ); +} + +describe(' 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); + }); +}); diff --git a/client/src/components/booking/SessionList/SessionList.tsx b/client/src/components/booking/SessionList/SessionList.tsx new file mode 100644 index 0000000..62537a2 --- /dev/null +++ b/client/src/components/booking/SessionList/SessionList.tsx @@ -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 = ({ + sessions, + showEvvControls = false, + showPayout = false, + busySessionId = null, + acquiringSessionId = null, + onCheckIn, + onCheckOut, +}) => ( + + {sessions.map((session) => ( + onCheckIn(session) : undefined} + onCheckOut={onCheckOut ? () => onCheckOut(session) : undefined} + /> + ))} + +); + +export default SessionList; diff --git a/client/src/components/booking/SessionList/index.tsx b/client/src/components/booking/SessionList/index.tsx new file mode 100644 index 0000000..686599a --- /dev/null +++ b/client/src/components/booking/SessionList/index.tsx @@ -0,0 +1,2 @@ +export { default } from './SessionList'; +export type { SessionListProps } from './SessionList'; diff --git a/client/src/components/booking/format.ts b/client/src/components/booking/format.ts new file mode 100644 index 0000000..c773720 --- /dev/null +++ b/client/src/components/booking/format.ts @@ -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)}`; +} diff --git a/client/src/components/booking/index.ts b/client/src/components/booking/index.ts new file mode 100644 index 0000000..ed4159a --- /dev/null +++ b/client/src/components/booking/index.ts @@ -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'; diff --git a/client/src/components/booking/statusKind.ts b/client/src/components/booking/statusKind.ts new file mode 100644 index 0000000..1627403 --- /dev/null +++ b/client/src/components/booking/statusKind.ts @@ -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 = { + 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 = { + scheduled: 'info', + in_progress: 'active', + completed: 'verified', + missed: 'rejected', + cancelled: 'neutral', +}; diff --git a/client/src/components/booking/useEvvController.ts b/client/src/components/booking/useEvvController.ts new file mode 100644 index 0000000..b217a5c --- /dev/null +++ b/client/src/components/booking/useEvvController.ts @@ -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(null); + const [busySessionId, setBusySessionId] = useState(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 }; +} diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 022225e..bc3a33f 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -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, }; diff --git a/client/src/services/bookings/apis/clientApi.ts b/client/src/services/bookings/apis/clientApi.ts new file mode 100644 index 0000000..03330b1 --- /dev/null +++ b/client/src/services/bookings/apis/clientApi.ts @@ -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>(`${BOOKINGS}/get/${id}`)), + + listBookings: async (params: BookingListParams): Promise> => { + 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>>(`${BOOKINGS}/list?${query.toString()}`)); + }, + + listTodaySessions: async (params: TodaySessionsParams): Promise> => { + 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>>(`${SESSIONS}/today?${query.toString()}`), + ); + }, + + getSessionEvv: async (sessionId: number) => + unwrap(await clientFetch>(`${SESSIONS}/evv/${sessionId}`)), + + getCareInstructions: async (bookingId: number) => + unwrap(await clientFetch>(`${BOOKINGS}/care_instructions/${bookingId}`)), + + checkInVisit: async (input: CheckInVisitInput) => + unwrap( + await clientFetch>(`${SESSIONS}/check_in/${input.bookingSessionId}`, { + method: 'POST', + body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }), + }), + ), + + checkOutVisit: async (input: CheckOutVisitInput) => + unwrap( + await clientFetch>(`${SESSIONS}/check_out/${input.bookingSessionId}`, { + method: 'POST', + body: JSON.stringify({ latitude: input.latitude, longitude: input.longitude }), + }), + ), +}; diff --git a/client/src/services/bookings/apis/index.ts b/client/src/services/bookings/apis/index.ts new file mode 100644 index 0000000..3cc5509 --- /dev/null +++ b/client/src/services/bookings/apis/index.ts @@ -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; diff --git a/client/src/services/bookings/apis/mockApi.ts b/client/src/services/bookings/apis/mockApi.ts new file mode 100644 index 0000000..c07ae12 --- /dev/null +++ b/client/src/services/bookings/apis/mockApi.ts @@ -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 = {}; +const care: Record = {}; + +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> => { + 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> => { + 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 }; + }, +}; diff --git a/client/src/services/bookings/apis/serverApi.ts b/client/src/services/bookings/apis/serverApi.ts new file mode 100644 index 0000000..e04af28 --- /dev/null +++ b/client/src/services/bookings/apis/serverApi.ts @@ -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 => + unwrap(await serverFetch>(`${BOOKINGS}/get/${id}`)), +}; diff --git a/client/src/services/bookings/constants.ts b/client/src/services/bookings/constants.ts new file mode 100644 index 0000000..639dd8e --- /dev/null +++ b/client/src/services/bookings/constants.ts @@ -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; diff --git a/client/src/services/bookings/evv/locationProvider.ts b/client/src/services/bookings/evv/locationProvider.ts new file mode 100644 index 0000000..f1878f1 --- /dev/null +++ b/client/src/services/bookings/evv/locationProvider.ts @@ -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; +} + +const GEOLOCATION_TIMEOUT_MS = 10_000; + +/** Real provider — `navigator.geolocation.getCurrentPosition`, resolving `null` on any failure. */ +const realLocationProvider: ILocationProvider = { + getCurrentPosition: () => + new Promise((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): ILocationProvider { + return { + getCurrentPosition: () => + new Promise((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); diff --git a/client/src/services/bookings/hooks/useBookingDetail.ts b/client/src/services/bookings/hooks/useBookingDetail.ts new file mode 100644 index 0000000..9bc0962 --- /dev/null +++ b/client/src/services/bookings/hooks/useBookingDetail.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/hooks/useBookingList.ts b/client/src/services/bookings/hooks/useBookingList.ts new file mode 100644 index 0000000..74791c0 --- /dev/null +++ b/client/src/services/bookings/hooks/useBookingList.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/hooks/useBookingSessions.ts b/client/src/services/bookings/hooks/useBookingSessions.ts new file mode 100644 index 0000000..eb154a4 --- /dev/null +++ b/client/src/services/bookings/hooks/useBookingSessions.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/hooks/useCareInstructions.ts b/client/src/services/bookings/hooks/useCareInstructions.ts new file mode 100644 index 0000000..14f7d1c --- /dev/null +++ b/client/src/services/bookings/hooks/useCareInstructions.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/hooks/useCheckInVisit.ts b/client/src/services/bookings/hooks/useCheckInVisit.ts new file mode 100644 index 0000000..852622e --- /dev/null +++ b/client/src/services/bookings/hooks/useCheckInVisit.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/bookings/hooks/useCheckOutVisit.ts b/client/src/services/bookings/hooks/useCheckOutVisit.ts new file mode 100644 index 0000000..8e20f80 --- /dev/null +++ b/client/src/services/bookings/hooks/useCheckOutVisit.ts @@ -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() }); + }, + }); +} diff --git a/client/src/services/bookings/hooks/useSessionEvv.ts b/client/src/services/bookings/hooks/useSessionEvv.ts new file mode 100644 index 0000000..8caba99 --- /dev/null +++ b/client/src/services/bookings/hooks/useSessionEvv.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/hooks/useTodaySessions.ts b/client/src/services/bookings/hooks/useTodaySessions.ts new file mode 100644 index 0000000..9a82b49 --- /dev/null +++ b/client/src/services/bookings/hooks/useTodaySessions.ts @@ -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, + }); +} diff --git a/client/src/services/bookings/index.ts b/client/src/services/bookings/index.ts new file mode 100644 index 0000000..9fadd3b --- /dev/null +++ b/client/src/services/bookings/index.ts @@ -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'; diff --git a/client/src/services/bookings/keys.ts b/client/src/services/bookings/keys.ts new file mode 100644 index 0000000..9a289cb --- /dev/null +++ b/client/src/services/bookings/keys.ts @@ -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, +}; diff --git a/client/src/services/bookings/types.ts b/client/src/services/bookings/types.ts new file mode 100644 index 0000000..283bb59 --- /dev/null +++ b/client/src/services/bookings/types.ts @@ -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`, 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; + listBookings(params: BookingListParams): Promise>; + listTodaySessions(params: TodaySessionsParams): Promise>; + getSessionEvv(sessionId: number): Promise; + getCareInstructions(bookingId: number, viewerRole?: BookingViewerRole): Promise; + checkInVisit(input: CheckInVisitInput): Promise; + checkOutVisit(input: CheckOutVisitInput): Promise; +} diff --git a/client/src/theme/tokens.css b/client/src/theme/tokens.css index d64706c..094cfe5 100644 --- a/client/src/theme/tokens.css +++ b/client/src/theme/tokens.css @@ -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; diff --git a/dev/contracts/domains/messaging-notifications-admin.md b/dev/contracts/domains/messaging-notifications-admin.md new file mode 100644 index 0000000..64431d6 --- /dev/null +++ b/dev/contracts/domains/messaging-notifications-admin.md @@ -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. diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index 5b309f5..8ab880f 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -2217,6 +2217,511 @@ ] } }, + "/api/v1/admin/partner-centers": { + "post": { + "tags": [ + "AdminPartnerCenters" + ], + "summary": "Creates a AdminPartnerCenter", + "operationId": "AdminPartnerCenters_Create", + "requestBody": { + "x-name": "command", + "description": "A AdminPartnerCenter representation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePartnerCenterCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPartnerCenterDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "AdminPartnerCenters" + ], + "operationId": "AdminPartnerCenters_List", + "parameters": [ + { + "name": "IsActive", + "in": "query", + "schema": { + "type": "boolean", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfPartnerCenterListItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin/partner-centers/{id}": { + "patch": { + "tags": [ + "AdminPartnerCenters" + ], + "summary": "Updates a AdminPartnerCenter by unique id", + "operationId": "AdminPartnerCenters_Update", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A AdminPartnerCenter representation", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePartnerCenterCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPartnerCenterDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "AdminPartnerCenters" + ], + "summary": "Retrieves a AdminPartnerCenter by unique id", + "operationId": "AdminPartnerCenters_GetById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "A unique id for the AdminPartnerCenter", + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPartnerCenterDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin/partner-centers/{id}/verify": { + "post": { + "tags": [ + "AdminPartnerCenters" + ], + "operationId": "AdminPartnerCenters_Verify", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPartnerCenterDetailDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin/partner-centers/{id}/sponsor-nurse": { + "post": { + "tags": [ + "AdminPartnerCenters" + ], + "operationId": "AdminPartnerCenters_SponsorNurse", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SponsorNurseCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_payouts/eligible": { "get": { "tags": [ @@ -3171,6 +3676,215 @@ ] } }, + "/api/v1/admin/tickets": { + "get": { + "tags": [ + "AdminTickets" + ], + "operationId": "AdminTickets_List", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "Category", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "ReferenceCode", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 3 + }, + { + "name": "BookingId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 4 + }, + { + "name": "RefundId", + "in": "query", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "x-position": 5 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 6 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 7 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfTicketSummaryDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/admin/tickets/{id}": { + "get": { + "tags": [ + "AdminTickets" + ], + "operationId": "AdminTickets_Thread", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfTicketThreadDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_verifications": { "get": { "tags": [ @@ -6014,6 +6728,83 @@ } } }, + "/api/v1/centers/{id}/dashboard": { + "get": { + "tags": [ + "Centers" + ], + "operationId": "Centers_Dashboard", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCenterDashboardDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/checkout_bnpl/eligibility": { "post": { "tags": [ @@ -7326,6 +8117,83 @@ ] } }, + "/api/v1/internal/bookings/{bookingId}/center": { + "get": { + "tags": [ + "InternalCenters" + ], + "operationId": "InternalCenters_CenterForBooking", + "parameters": [ + { + "name": "bookingId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfCenterForBookingDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/invoices/{bookingId}": { "get": { "tags": [ @@ -11658,6 +12526,757 @@ ] } }, + "/api/v1/tickets": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_Open", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OpenTicketCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfOpenTicketResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_ListMine", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 1 + }, + { + "name": "ReferenceCode", + "in": "query", + "schema": { + "type": "string", + "nullable": true + }, + "x-position": 2 + }, + { + "name": "Page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 3 + }, + { + "name": "PageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 4 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPagedResultOfTicketSummaryDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/emergency": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_Emergency", + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogEmergencyTicketCommand" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfOpenTicketResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}/messages": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_PostMessage", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PostMessageCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfPostMessageResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}/participants": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_AddParticipant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "command", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddParticipantCommand" + } + } + }, + "required": true, + "x-position": 2 + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}/participants/{userId}": { + "delete": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_RemoveParticipant", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + }, + "x-position": 2 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}/close": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_Close", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}/reopen": { + "post": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_Reopen", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/tickets/{id}": { + "get": { + "tags": [ + "Tickets" + ], + "operationId": "Tickets_Thread", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfDictionaryOfStringAndListOfString" + } + } + } + }, + "401": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResult" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResultOfTicketThreadDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/webhooks_bnpl/{provider}": { "post": { "tags": [ @@ -13070,6 +14689,285 @@ } } }, + "ApiResultOfPartnerCenterDetailDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PartnerCenterDetailDto" + } + ] + } + } + } + ] + }, + "PartnerCenterDetailDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "legalEntityType": { + "type": "string", + "nullable": true + }, + "mohEstablishmentPermitNo": { + "type": "string" + }, + "technicalDirectorNurseUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "technicalDirectorLicenseNo": { + "type": "string", + "nullable": true + }, + "enamadCode": { + "type": "string", + "nullable": true + }, + "settlementIbanMasked": { + "type": "string", + "nullable": true + }, + "isMerchantOfRecord": { + "type": "boolean" + }, + "commissionRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "adminUserId": { + "type": "integer", + "format": "int32" + }, + "isActive": { + "type": "boolean" + }, + "verifiedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sponsoredNurseCount": { + "type": "integer", + "format": "int32" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreatePartnerCenterCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "legalEntityType": { + "type": "string", + "nullable": true + }, + "mohEstablishmentPermitNo": { + "type": "string" + }, + "technicalDirectorNurseUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "technicalDirectorLicenseNo": { + "type": "string", + "nullable": true + }, + "enamadCode": { + "type": "string", + "nullable": true + }, + "settlementIban": { + "type": "string", + "nullable": true + }, + "isMerchantOfRecord": { + "type": "boolean" + }, + "commissionRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "adminUserId": { + "type": "integer", + "format": "int32" + } + } + }, + "UpdatePartnerCenterCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "legalEntityType": { + "type": "string", + "nullable": true + }, + "mohEstablishmentPermitNo": { + "type": "string" + }, + "technicalDirectorNurseUserId": { + "type": "integer", + "format": "int32", + "nullable": true + }, + "technicalDirectorLicenseNo": { + "type": "string", + "nullable": true + }, + "enamadCode": { + "type": "string", + "nullable": true + }, + "settlementIban": { + "type": "string", + "nullable": true + }, + "isMerchantOfRecord": { + "type": "boolean" + }, + "commissionRate": { + "type": "number", + "format": "decimal", + "nullable": true + }, + "adminUserId": { + "type": "integer", + "format": "int32" + }, + "id": { + "type": "integer", + "format": "int64" + } + } + }, + "SponsorNurseCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseProfileId": { + "type": "integer", + "format": "int64" + }, + "unlink": { + "type": "boolean" + }, + "centerId": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResultOfPagedResultOfPartnerCenterListItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfPartnerCenterListItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfPartnerCenterListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/PartnerCenterListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "PartnerCenterListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "legalEntityType": { + "type": "string", + "nullable": true + }, + "isMerchantOfRecord": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "verifiedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sponsoredNurseCount": { + "type": "integer", + "format": "int32" + }, + "adminUserId": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfPagedResultOfEligibleNurseEarningsDto": { "allOf": [ { @@ -13849,6 +15747,202 @@ } } }, + "ApiResultOfPagedResultOfTicketSummaryDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfTicketSummaryDto" + } + ] + } + } + } + ] + }, + "PagedResultOfTicketSummaryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/TicketSummaryDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "TicketSummaryDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "referenceCode": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "category": { + "type": "string" + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "refundId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResultOfTicketThreadDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/TicketThreadDto" + } + ] + } + } + } + ] + }, + "TicketThreadDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "referenceCode": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "category": { + "type": "string" + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "refundId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "openedById": { + "type": "integer", + "format": "int32" + }, + "closedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "participants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketParticipantDto" + } + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TicketMessageDto" + } + } + } + }, + "TicketParticipantDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "userId": { + "type": "integer", + "format": "int32" + }, + "roleOnTicket": { + "type": "string", + "nullable": true + } + } + }, + "TicketMessageDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "senderId": { + "type": "integer", + "format": "int32" + }, + "body": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + }, + "sentAt": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResultOfPagedResultOfAdminPendingStepDto": { "allOf": [ { @@ -15661,6 +17755,93 @@ } ] }, + "ApiResultOfCenterDashboardDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CenterDashboardDto" + } + ] + } + } + } + ] + }, + "CenterDashboardDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "centerId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "isMerchantOfRecord": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "settlementIbanMasked": { + "type": "string", + "nullable": true + }, + "sponsoredNurseCount": { + "type": "integer", + "format": "int32" + }, + "sponsoredBookingCount": { + "type": "integer", + "format": "int32" + }, + "invoiceCount": { + "type": "integer", + "format": "int32" + }, + "sponsoredNurses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SponsoredNurseDto" + } + } + } + }, + "SponsoredNurseDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "nurseProfileId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int32" + }, + "isVerified": { + "type": "boolean" + }, + "averageRating": { + "type": "number", + "format": "decimal" + }, + "totalCompletedBookings": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfBnplEligibilityDto": { "allOf": [ { @@ -16283,6 +18464,52 @@ } } }, + "ApiResultOfCenterForBookingDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/CenterForBookingDto" + } + ] + } + } + } + ] + }, + "CenterForBookingDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "issuingEntityType": { + "type": "string" + }, + "partnerCenterId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "partnerCenterName": { + "type": "string", + "nullable": true + }, + "isMerchantOfRecord": { + "type": "boolean" + } + } + }, "ApiResultOfMeResult": { "allOf": [ { @@ -18517,6 +20744,159 @@ } } }, + "ApiResultOfOpenTicketResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/OpenTicketResult" + } + ] + } + } + } + ] + }, + "OpenTicketResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "ticketId": { + "type": "integer", + "format": "int64" + }, + "referenceCode": { + "type": "string" + }, + "status": { + "type": "string" + }, + "category": { + "type": "string" + } + } + }, + "OpenTicketCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "category": { + "type": "string" + }, + "subject": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "refundId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + }, + "LogEmergencyTicketCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64" + }, + "body": { + "type": "string", + "nullable": true + }, + "raiseAlert": { + "type": "boolean" + } + } + }, + "ApiResultOfPostMessageResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PostMessageResult" + } + ] + } + } + } + ] + }, + "PostMessageResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "messageId": { + "type": "integer", + "format": "int64" + }, + "ticketId": { + "type": "integer", + "format": "int64" + }, + "sentAt": { + "type": "string", + "format": "date-time" + } + } + }, + "PostMessageCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "ticketId": { + "type": "integer", + "format": "int64" + }, + "body": { + "type": "string" + }, + "isInternal": { + "type": "boolean" + } + } + }, + "AddParticipantCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "ticketId": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int32" + } + } + }, "ApiResultOfBnplCallbackResult": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index 319120d..78f96dd 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,30 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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` diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-15.md b/dev/shared-working-context/backend/handoff/after-backend-phase-15.md new file mode 100644 index 0000000..3ef7ad4 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-15.md @@ -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"`). diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index f1a0343..9853ece 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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 diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index ceb8239..39066dc 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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 diff --git a/dev/shared-working-context/reports/backend-phase-15-report.md b/dev/shared-working-context/reports/backend-phase-15-report.md new file mode 100644 index 0000000..7bf01ab --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-15-report.md @@ -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). diff --git a/dev/shared-working-context/reports/frontend-phase-8-report.md b/dev/shared-working-context/reports/frontend-phase-8-report.md new file mode 100644 index 0000000..fada3a4 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-8-report.md @@ -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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 413898b..db7f557 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | 🟡 | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 3c9981b..3b23fe4 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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 diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminPartnerCentersController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminPartnerCentersController.cs new file mode 100644 index 0000000..412a435 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminPartnerCentersController.cs @@ -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; + +/// +/// Admin partner-center console (admin/super_admin). Creates/updates/verifies licensed centers and sponsors +/// nurses. The settlement IBAN is encrypted at rest and returned masked (last 4). Internal-only, audited. +/// +[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] + public async Task Create(CreatePartnerCenterCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPatch("{id}")] + [ProducesOkApiResponseType] + public async Task Update(long id, UpdatePartnerCenterCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { Id = id }, cancellationToken)); + + [HttpPost("{id}/verify")] + [ProducesOkApiResponseType] + public async Task Verify(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new VerifyPartnerCenterCommand(id), cancellationToken)); + + [HttpPost("{id}/sponsor-nurse")] + [ProducesOkApiResponseType] + public async Task SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken)); + + [HttpGet] + [ProducesOkApiResponseType>] + public async Task List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + [HttpGet("{id}")] + [ProducesOkApiResponseType] + public async Task GetById(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetPartnerCenterByIdQuery(id), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminTicketsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminTicketsController.cs new file mode 100644 index 0000000..2543bf3 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminTicketsController.cs @@ -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; + +/// Admin ticket console (support/admin): the global queue and the admin thread view (internal +/// notes included). Internal-only, RBAC-gated, audited. +[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>] + public async Task List([FromQuery] ListTicketsForAdminQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); + + // Admin view — internal notes ARE returned. + [HttpGet("{id}")] + [ProducesOkApiResponseType] + public async Task Thread(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: true), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/CentersController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/CentersController.cs new file mode 100644 index 0000000..cf5b58a --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/CentersController.cs @@ -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; + +/// 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. +[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] + public async Task Dashboard(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetCenterDashboardQuery(id), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs new file mode 100644 index 0000000..6cf4074 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs @@ -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; + +/// Internal/admin resolver: which center legally covers a booking (invoice issuer + settlement target). +/// The single merchant-of-record decision — invoices and settlement follow partner_centers. +[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] + public async Task CenterForBooking(long bookingId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetCenterForBookingQuery(bookingId), cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/TicketsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/TicketsController.cs new file mode 100644 index 0000000..7232f35 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/TicketsController.cs @@ -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; + +/// +/// 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. +/// +[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] + public async Task Open(OpenTicketCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("emergency")] + [ProducesOkApiResponseType] + public async Task Emergency(LogEmergencyTicketCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command, cancellationToken)); + + [HttpPost("{id}/messages")] + [ProducesOkApiResponseType] + public async Task PostMessage(long id, PostMessageCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken)); + + [HttpPost("{id}/participants")] + [ProducesOkApiResponseType] + public async Task AddParticipant(long id, AddParticipantCommand command, CancellationToken cancellationToken) + => OperationResult(await sender.Send(command with { TicketId = id }, cancellationToken)); + + [HttpDelete("{id}/participants/{userId}")] + [ProducesOkApiResponseType] + public async Task RemoveParticipant(long id, int userId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new RemoveParticipantCommand(id, userId), cancellationToken)); + + [HttpPost("{id}/close")] + [ProducesOkApiResponseType] + public async Task Close(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new CloseTicketCommand(id), cancellationToken)); + + [HttpPost("{id}/reopen")] + [ProducesOkApiResponseType] + public async Task Reopen(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ReopenTicketCommand(id), cancellationToken)); + + [HttpGet] + [ProducesOkApiResponseType>] + public async Task 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] + public async Task Thread(long id, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetTicketThreadQuery(id, AsAdmin: false), cancellationToken)); +} diff --git a/server/src/Core/Baya.Application/Common/StaffRoles.cs b/server/src/Core/Baya.Application/Common/StaffRoles.cs new file mode 100644 index 0000000..0356498 --- /dev/null +++ b/server/src/Core/Baya.Application/Common/StaffRoles.cs @@ -0,0 +1,16 @@ +using Baya.Domain.Entities.User; + +namespace Baya.Application.Common; + +/// +/// 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. +/// +public static class StaffRoles +{ + public static readonly IReadOnlyList All = + [RoleNames.Admin, RoleNames.Support, RoleNames.Finance, RoleNames.Moderation, RoleNames.SuperAdmin]; + + public static bool IsStaff(IEnumerable roles) => roles.Any(All.Contains); +} diff --git a/server/src/Core/Baya.Application/Contracts/Common/ILicenseVerificationService.cs b/server/src/Core/Baya.Application/Contracts/Common/ILicenseVerificationService.cs new file mode 100644 index 0000000..e8c1138 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Common/ILicenseVerificationService.cs @@ -0,0 +1,31 @@ +#nullable enable +namespace Baya.Application.Contracts.Common; + +/// +/// Seam for verifying a partner center's licensing — the MoH establishment permit (پروانه تأسیس) and the +/// eNamad trust seal (نماد اعتماد الکترونیکی) — against its authoritative source. There is no public B2B +/// API for these today, so the default implementation returns +/// : VerifyPartnerCenter records the human +/// admin decision. When a real eNamad / MoH registry endpoint becomes available a real implementation replaces +/// this registration and starts returning / +/// — VerifyPartnerCenter is unchanged. +/// +public interface ILicenseVerificationService +{ + Task VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default); + + Task VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default); +} + +/// Whether a license can be verified automatically or needs a manual admin decision. +public enum LicenseVerificationStatus +{ + NeedsManualReview, + Valid, + Invalid +} + +/// Outcome of an check. +/// Manual today; automated once a registry/API is available. +/// Human-readable reason for the verdict (esp. for manual/invalid). +public readonly record struct LicenseVerdict(LicenseVerificationStatus Status, string Reason); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IPartnerCenterRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IPartnerCenterRepository.cs new file mode 100644 index 0000000..5e0f38d --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IPartnerCenterRepository.cs @@ -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; + +/// +/// The partner_centers aggregate. Writes load tracked rows; reads project to DTOs and never return +/// the plaintext/full settlement_iban (masked to last 4). is +/// the single merchant-of-record resolver: invoices + settlement follow the center, not a hardcoded platform. +/// +public interface IPartnerCenterRepository +{ + Task AddAsync(PartnerCenter center, CancellationToken cancellationToken); + + /// Tracked center — for update/verify/activate. Null if absent. + Task GetTrackedAsync(long centerId, CancellationToken cancellationToken); + + Task ExistsAsync(long centerId, CancellationToken cancellationToken); + + /// The admin detail view, with the settlement IBAN masked and the sponsored-nurse count. Null if absent. + Task GetDetailAsync(long centerId, CancellationToken cancellationToken); + + /// Admin paginated list (no IBAN), optional active filter, with sponsored-nurse counts. + Task> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken); + + /// Resolves which center legally covers a booking (booking → nurse → partner_center_id), + /// returning the issuer/settlement decision. Issuer is partner_center only when the sponsoring center + /// is merchant-of-record; platform otherwise (incl. an unsponsored nurse). Null if the booking is absent. + Task ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken); + + /// The center's dashboard admin users.id — the portal-scope check. Null if the center is absent. + Task GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken); + + /// The center dashboard read model (sponsored nurses + booking/invoice counts). Null if absent. + Task GetDashboardAsync(long centerId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ITicketRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ITicketRepository.cs new file mode 100644 index 0000000..7cad63b --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ITicketRepository.cs @@ -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; + +/// +/// The tickets aggregate (tickets + participants + messages). Writes load tracked rows; reads project to +/// DTOs. The hard visibility boundary lives here: only returns +/// is_internal messages when (the admin view) is true — an internal +/// note is filtered out of the projection for the user view, never surfaced downstream. +/// +public interface ITicketRepository +{ + Task AddAsync(Ticket ticket, CancellationToken cancellationToken); + + Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken); + + Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken); + + /// Tracked ticket (no includes) — for close/reopen transitions. Null if absent. + Task GetTrackedAsync(long ticketId, CancellationToken cancellationToken); + + /// True if a ticket already uses this reference code — the collision check before minting. + Task ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken); + + /// 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. + Task GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken); + + /// True if the user is an active participant on the ticket — the authorization backstop for + /// reads/posts. + Task IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken); + + /// True if a coordination ticket already exists for the booking — the auto-create idempotency guard. + Task CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken); + + /// The booking's customer + nurse users.id — the two participants of a coordination ticket. + /// Null when the booking is absent. + Task GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken); + + /// The ticket's header facts (no messages) — for authorization + the thread response header. Null if absent. + Task GetHeaderAsync(long ticketId, CancellationToken cancellationToken); + + /// True if the booking exists and the given user is its customer or nurse — the tenancy check for + /// opening a booking-linked ticket. + Task IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken); + + /// The active participants on a thread. + Task> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken); + + /// The user ids of the active participants (for notification fan-out). + Task> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken); + + /// The ordered thread messages. = false (the user view) strips + /// every is_internal message in the projection; true (the admin view) returns them. + Task> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken); + + /// Paginated tickets the user participates in (active membership), filterable by status and + /// reference code, newest first. + Task> ListMyTicketsAsync(int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken); + + /// The admin global queue — paginated, filter by status/category, search by reference code, optional + /// booking/refund link, newest first. + Task> ListForAdminAsync(string? status, string? category, string? referenceCode, long? bookingId, long? refundId, int page, int pageSize, CancellationToken cancellationToken); +} + +/// The customer + nurse owning users.id for a booking. +public readonly record struct BookingPartyUserIds(int CustomerUserId, int NurseUserId); diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index a09bc1e..6d1741d 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -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(); } diff --git a/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs index 9614524..dc03313 100644 --- a/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Bnpl/Commands/SettleBnplOrder/SettleBnplOrderCommand.Handler.cs @@ -28,7 +28,8 @@ internal sealed class SettleBnplOrderCommandHandler( ISettlementSplitProvider settlementSplitProvider, IDistributedLock distributedLock, IDateTimeProvider dateTimeProvider, - INotificationDispatcher notifications) + INotificationDispatcher notifications, + ISender sender) : IRequestHandler> { public async ValueTask> 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.SuccessResult(true); } diff --git a/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs index ae426e5..fa0f5e4 100644 --- a/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs @@ -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, diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Handler.cs new file mode 100644 index 0000000..eadcf49 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Handler.cs @@ -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; + +/// +/// Adds a user to a ticket (staff on any ticket, or the ticket owner). Enforces UNIQUE(ticket_id, user_id): +/// 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. +/// +internal sealed class AddParticipantCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(AddParticipantCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } callerId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken); + if (header is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId) + return OperationResult.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.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.ConflictResult("That user is already a participant on this ticket."); + } + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Validator.cs new file mode 100644 index 0000000..bc88bef --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Messaging.Commands.AddParticipant; + +public sealed class AddParticipantCommandValidator : AbstractValidator +{ + public AddParticipantCommandValidator() + { + // TicketId is route-supplied (merged via `with`); only the body-supplied UserId is validated. + RuleFor(x => x.UserId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.cs new file mode 100644 index 0000000..25edeff --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/AddParticipant/AddParticipantCommand.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Messaging.Commands.AddParticipant; + +/// 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 UNIQUE(ticket_id, user_id) index. +public record AddParticipantCommand(long TicketId, int UserId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.Handler.cs new file mode 100644 index 0000000..f329225 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.Handler.cs @@ -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; + +/// +/// Creates the one booking-scoped coordination ticket + its nurse/customer participants, idempotently. The +/// idempotency guard is CoordinationTicketExistsForBookingAsync (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. +/// +internal sealed class AutoCreateCoordinationTicketCommandHandler( + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(AutoCreateCoordinationTicketCommand request, CancellationToken cancellationToken) + { + if (await unitOfWork.TicketRepository.CoordinationTicketExistsForBookingAsync(request.BookingId, cancellationToken)) + return OperationResult.SuccessResult(false); + + var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken); + if (parties is not { } p) + return OperationResult.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.SuccessResult(false); + } + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.cs new file mode 100644 index 0000000..ff4e07d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/AutoCreateCoordinationTicket/AutoCreateCoordinationTicketCommand.cs @@ -0,0 +1,13 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket; + +/// +/// On booking confirmation, auto-creates the coordination 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 true +/// when it created the ticket, false when one already existed. +/// +public record AutoCreateCoordinationTicketCommand(long BookingId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.Handler.cs new file mode 100644 index 0000000..8003c11 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.Handler.cs @@ -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; + +/// Closes a ticket (participant or staff). Idempotent — an already-closed ticket is a no-op success. The +/// owner trail comes from the audit fields. +internal sealed class CloseTicketCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(CloseTicketCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken); + if (ticket is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && + !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken)) + return OperationResult.ForbiddenResult("You are not a participant on this ticket."); + + if (ticket.IsOpen) + { + ticket.Close(userId, dateTimeProvider.UtcNow); + await unitOfWork.CommitAsync(); + } + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.cs new file mode 100644 index 0000000..4582f5f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/CloseTicket/CloseTicketCommand.cs @@ -0,0 +1,8 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Messaging.Commands.CloseTicket; + +/// Closes an open ticket (open → closed), stamping who/when. A participant or staff may close. +public record CloseTicketCommand(long TicketId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.Handler.cs new file mode 100644 index 0000000..991fd53 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.Handler.cs @@ -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; + +/// +/// Opens the emergency ticket (assigned nurse or staff) and, when requested, raises an internal +/// support_alerts row via the b1 raise API. The ticket + alert are the durable record staff triage; the +/// emergency call itself is out-of-platform (a tel: link in the UI), so there is no telephony seam here. +/// +internal sealed class LogEmergencyTicketCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider, + ISupportAlertService supportAlerts) + : IRequestHandler> +{ + public async ValueTask> Handle(LogEmergencyTicketCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var parties = await unitOfWork.TicketRepository.GetBookingPartyUserIdsAsync(request.BookingId, cancellationToken); + if (parties is not { } p) + return OperationResult.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.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.SuccessResult( + new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.cs new file mode 100644 index 0000000..6ceb919 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/LogEmergencyTicket/LogEmergencyTicketCommand.cs @@ -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; + +/// +/// The operational 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 +/// category=emergency ticket on the booking, and optionally a support_alerts row. It does +/// not dial anyone and does not expose any phone number; it does not widen the clinical disclosure. +/// +public record LogEmergencyTicketCommand(long BookingId, string? Body, bool RaiseAlert = true) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Handler.cs new file mode 100644 index 0000000..beabd5f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Handler.cs @@ -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; + +/// +/// 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 reference_code is minted +/// once, collision-checked, and backed by the UNIQUE index. +/// +internal sealed class OpenTicketCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(OpenTicketCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.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.NotFoundResult("Booking not found."); + } + + if (request.RefundId is not null && !isStaff) + return OperationResult.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.SuccessResult( + new OpenTicketResult(ticket.Id, ticket.ReferenceCode, ticket.Status, ticket.Category)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Validator.cs new file mode 100644 index 0000000..e95af65 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.Validator.cs @@ -0,0 +1,18 @@ +using Baya.Domain.Entities.Messaging; +using FluentValidation; + +namespace Baya.Application.Features.Messaging.Commands.OpenTicket; + +public sealed class OpenTicketCommandValidator : AbstractValidator +{ + 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); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.cs new file mode 100644 index 0000000..b7cc862 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/OpenTicket/OpenTicketCommand.cs @@ -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; + +/// +/// Opens a ticket, mints a stable unique reference_code, attaches the optional (both nullable) +/// / links, and adds the opener as the first participant. Used by +/// the customer/nurse "contact support" flow, the b11 refund flow (which anchors refunds.ticket_id here), +/// and LogEmergencyTicket. A booking link requires the opener to be a party to the booking (staff bypass); +/// a refund link is staff-only. +/// +public record OpenTicketCommand( + string Category, + string? Subject, + string? Body, + long? BookingId, + long? RefundId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Handler.cs new file mode 100644 index 0000000..750b401 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Handler.cs @@ -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; + +/// +/// Appends a message to a ticket after enforcing the visibility + posting rules: only an active participant (or +/// staff) may post; is_internal 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). +/// +internal sealed class PostMessageCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> Handle(PostMessageCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var isStaff = StaffRoles.IsStaff(currentUser.Roles); + + var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken); + if (header is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!isStaff && !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken)) + return OperationResult.ForbiddenResult("You are not a participant on this ticket."); + + if (request.IsInternal && !isStaff) + return OperationResult.ForbiddenResult("Only staff can post an internal note."); + + if (header.Status == TicketStatus.Closed && !isStaff) + return OperationResult.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.SuccessResult( + new PostMessageResult(message.Id, request.TicketId, message.SentAt)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Validator.cs new file mode 100644 index 0000000..1737175 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Messaging.Commands.PostMessage; + +public sealed class PostMessageCommandValidator : AbstractValidator +{ + public PostMessageCommandValidator() + { + // TicketId is route-supplied (merged via `with`), so it is not validated here. + RuleFor(x => x.Body).NotEmpty().MaximumLength(4000); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.cs new file mode 100644 index 0000000..be55e22 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/PostMessage/PostMessageCommand.cs @@ -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; + +/// Appends a message to a ticket. Only an active participant (or staff) may post. +/// (an admin-only note) can be set only by staff; a non-staff caller can neither set it nor post to a +/// closed ticket. +public record PostMessageCommand(long TicketId, string Body, bool IsInternal = false) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.Handler.cs new file mode 100644 index 0000000..74fc9ea --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.Handler.cs @@ -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; + +/// Soft-removes a participant (staff on any ticket, or the ticket owner). The unique row survives so a +/// later re-add resurrects it. +internal sealed class RemoveParticipantCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(RemoveParticipantCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } callerId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken); + if (header is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && header.OpenedById != callerId) + return OperationResult.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.NotFoundResult("That user is not an active participant on this ticket."); + + participant.Remove(dateTimeProvider.UtcNow); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.cs new file mode 100644 index 0000000..bb351f2 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/RemoveParticipant/RemoveParticipantCommand.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Messaging.Commands.RemoveParticipant; + +/// Detaches a user from a ticket (soft removed_at stamp). Staff (any ticket) or the ticket owner +/// may remove. +public record RemoveParticipantCommand(long TicketId, int UserId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.Handler.cs new file mode 100644 index 0000000..05b23e0 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.Handler.cs @@ -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; + +/// Reopens a ticket (participant or staff). Idempotent — an already-open ticket is a no-op success. +internal sealed class ReopenTicketCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(ReopenTicketCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var ticket = await unitOfWork.TicketRepository.GetTrackedAsync(request.TicketId, cancellationToken); + if (ticket is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && + !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken)) + return OperationResult.ForbiddenResult("You are not a participant on this ticket."); + + if (!ticket.IsOpen) + { + ticket.Reopen(); + await unitOfWork.CommitAsync(); + } + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.cs b/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.cs new file mode 100644 index 0000000..69bc046 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Commands/ReopenTicket/ReopenTicketCommand.cs @@ -0,0 +1,8 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Messaging.Commands.ReopenTicket; + +/// Reopens a closed ticket (closed → open). A participant or staff may reopen. +public record ReopenTicketCommand(long TicketId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.Handler.cs new file mode 100644 index 0000000..802fd90 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.Handler.cs @@ -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; + +/// +/// 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). +/// +internal sealed class GetTicketThreadQueryHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetTicketThreadQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var isStaff = StaffRoles.IsStaff(currentUser.Roles); + + if (request.AsAdmin && !isStaff) + return OperationResult.ForbiddenResult("Admin ticket view requires staff access."); + + var header = await unitOfWork.TicketRepository.GetHeaderAsync(request.TicketId, cancellationToken); + if (header is null) + return OperationResult.NotFoundResult("Ticket not found."); + + if (!request.AsAdmin && !isStaff && + !await unitOfWork.TicketRepository.IsActiveParticipantAsync(request.TicketId, userId, cancellationToken)) + return OperationResult.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.SuccessResult(new TicketThreadDto( + header.Id, header.ReferenceCode, header.Subject, header.Status, header.Category, + header.BookingId, header.RefundId, header.OpenedById, header.ClosedAt, participants, messages)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.cs new file mode 100644 index 0000000..16ea8fb --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/GetTicketThread/GetTicketThreadQuery.cs @@ -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; + +/// +/// Returns the ordered thread for a ticket the caller may see. selects the view: the +/// user view (false) strips every is_internal message in the projection; the admin +/// view (true, staff only) returns them. This is enforced at the query layer, never in the UI. +/// +public record GetTicketThreadQuery(long TicketId, bool AsAdmin = false) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.Handler.cs new file mode 100644 index 0000000..559c57c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> Handle(ListMyTicketsQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.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>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.cs new file mode 100644 index 0000000..cd31ebe --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListMyTickets/ListMyTicketsQuery.cs @@ -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; + +/// The caller's tickets (active participation), paginated, filterable by status and reference code, newest first. +public record ListMyTicketsQuery( + string? Status = null, + string? ReferenceCode = null, + int Page = 1, + int PageSize = 50) : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.Handler.cs new file mode 100644 index 0000000..22bc7ea --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.Handler.cs @@ -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>> +{ + public async ValueTask>> 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>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.cs b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.cs new file mode 100644 index 0000000..d28c0f4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/Queries/ListTicketsForAdmin/ListTicketsForAdminQuery.cs @@ -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; + +/// The admin global ticket queue, paginated, filter by status/category, search by reference code, optional +/// booking/refund link, newest first. +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>>; diff --git a/server/src/Core/Baya.Application/Features/Messaging/TicketReferenceCode.cs b/server/src/Core/Baya.Application/Features/Messaging/TicketReferenceCode.cs new file mode 100644 index 0000000..5287415 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/TicketReferenceCode.cs @@ -0,0 +1,37 @@ +using Baya.Application.Contracts.Persistence; + +namespace Baya.Application.Features.Messaging; + +/// +/// Mints the human-facing, UNIQUE, stable ticket reference_code (e.g. TKT-9F3K2A7Q). 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. +/// +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 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); + } +} diff --git a/server/src/Core/Baya.Application/Features/Messaging/TicketRoleResolver.cs b/server/src/Core/Baya.Application/Features/Messaging/TicketRoleResolver.cs new file mode 100644 index 0000000..5d774fd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Messaging/TicketRoleResolver.cs @@ -0,0 +1,19 @@ +using Baya.Application.Common; +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.User; + +namespace Baya.Application.Features.Messaging; + +/// Derives the display role_on_ticket label from a user's platform roles (staff → admin, nurse +/// → nurse, else customer). Purely a label — never an authorization source. +public static class TicketRoleResolver +{ + public static string Derive(IReadOnlyList roles) + { + if (StaffRoles.IsStaff(roles)) + return TicketParticipantRole.Admin; + if (roles.Contains(RoleNames.Nurse)) + return TicketParticipantRole.Nurse; + return TicketParticipantRole.Customer; + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Handler.cs new file mode 100644 index 0000000..b23ea25 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Handler.cs @@ -0,0 +1,38 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Baya.Domain.Entities.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter; + +/// Persists a new partner center (inactive). is set in +/// plaintext on the entity and encrypted by the EF converter on save — it is never returned in plaintext (the +/// detail read masks it). +internal sealed class CreatePartnerCenterCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(CreatePartnerCenterCommand request, CancellationToken cancellationToken) + { + var center = new PartnerCenter + { + Name = request.Name, + LegalEntityType = request.LegalEntityType, + MohEstablishmentPermitNo = request.MohEstablishmentPermitNo, + TechnicalDirectorNurseUserId = request.TechnicalDirectorNurseUserId, + TechnicalDirectorLicenseNo = request.TechnicalDirectorLicenseNo, + EnamadCode = request.EnamadCode, + SettlementIban = string.IsNullOrWhiteSpace(request.SettlementIban) ? null : request.SettlementIban, + IsMerchantOfRecord = request.IsMerchantOfRecord, + CommissionRate = request.CommissionRate, + AdminUserId = request.AdminUserId + }; + + await unitOfWork.PartnerCenterRepository.AddAsync(center, cancellationToken); + await unitOfWork.CommitAsync(); + + var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken); + return OperationResult.SuccessResult(detail!); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Validator.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Validator.cs new file mode 100644 index 0000000..124b475 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.Validator.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter; + +public sealed class CreatePartnerCenterCommandValidator : AbstractValidator +{ + public CreatePartnerCenterCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(300); + RuleFor(x => x.LegalEntityType).MaximumLength(30); + RuleFor(x => x.MohEstablishmentPermitNo).NotEmpty().MaximumLength(100); + RuleFor(x => x.TechnicalDirectorLicenseNo).MaximumLength(100); + RuleFor(x => x.EnamadCode).MaximumLength(100); + RuleFor(x => x.AdminUserId).GreaterThan(0); + + // The center's cut is a fraction in [0, 1) — never ≥ 1, never negative. + RuleFor(x => x.CommissionRate) + .InclusiveBetween(0m, 0.9999m) + .When(x => x.CommissionRate.HasValue); + + // A merchant-of-record center is the settlement target, so it MUST carry a settlement IBAN. + RuleFor(x => x.SettlementIban) + .NotEmpty() + .MaximumLength(34) + .When(x => x.IsMerchantOfRecord) + .WithMessage("A merchant-of-record center requires a settlement IBAN."); + + RuleFor(x => x.SettlementIban).MaximumLength(34).When(x => !x.IsMerchantOfRecord); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.cs new file mode 100644 index 0000000..2d7482f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/CreatePartnerCenter/CreatePartnerCenterCommand.cs @@ -0,0 +1,24 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter; + +/// +/// Registers a licensed partner center (admin-only). is required when +/// is set and is encrypted at rest before persisting (never plaintext); +/// is the center's own cut (0–1), separate from platform_fee_rate. The +/// center is created inactive — VerifyPartnerCenter records the licensing approval and activates it. +/// +public record CreatePartnerCenterCommand( + string Name, + string? LegalEntityType, + string MohEstablishmentPermitNo, + int? TechnicalDirectorNurseUserId, + string? TechnicalDirectorLicenseNo, + string? EnamadCode, + string? SettlementIban, + bool IsMerchantOfRecord, + decimal? CommissionRate, + int AdminUserId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Handler.cs new file mode 100644 index 0000000..e7951a4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Handler.cs @@ -0,0 +1,41 @@ +#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.PartnerCenters.Commands.SponsorNurse; + +/// +/// Sets (or clears) the nurse's sponsoring center. Authorization: staff, or the center's own dashboard account +/// (a center may sponsor within itself). A link points nurse_profiles.partner_center_id at the center; +/// unlinking sets it back to null (once Balinyaar holds its own permit). +/// +internal sealed class SponsorNurseCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(SponsorNurseCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var centerAdminUserId = await unitOfWork.PartnerCenterRepository.GetAdminUserIdAsync(request.CenterId, cancellationToken); + if (centerAdminUserId is null) + return OperationResult.NotFoundResult("Partner center not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && centerAdminUserId != userId) + return OperationResult.ForbiddenResult("Only staff or the center's own account can sponsor nurses here."); + + var nurse = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(request.NurseProfileId, cancellationToken); + if (nurse is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + nurse.PartnerCenterId = request.Unlink ? null : request.CenterId; + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Validator.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Validator.cs new file mode 100644 index 0000000..c2f304d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.PartnerCenters.Commands.SponsorNurse; + +public sealed class SponsorNurseCommandValidator : AbstractValidator +{ + public SponsorNurseCommandValidator() + { + // CenterId is route-supplied; only the body-supplied nurse id is validated. + RuleFor(x => x.NurseProfileId).GreaterThan(0); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.cs new file mode 100644 index 0000000..5c3d421 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/SponsorNurse/SponsorNurseCommand.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.SponsorNurse; + +/// Links (or, when , unlinks) a nurse to a sponsoring center by setting +/// nurse_profiles.partner_center_id. is route-supplied. Staff, or the center's own +/// dashboard account, may sponsor within that center. +public record SponsorNurseCommand(long NurseProfileId, bool Unlink = false, long CenterId = 0) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Handler.cs new file mode 100644 index 0000000..4dfe716 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Handler.cs @@ -0,0 +1,34 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter; + +internal sealed class UpdatePartnerCenterCommandHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(UpdatePartnerCenterCommand request, CancellationToken cancellationToken) + { + var center = await unitOfWork.PartnerCenterRepository.GetTrackedAsync(request.Id, cancellationToken); + if (center is null) + return OperationResult.NotFoundResult("Partner center not found."); + + center.Name = request.Name; + center.LegalEntityType = request.LegalEntityType; + center.MohEstablishmentPermitNo = request.MohEstablishmentPermitNo; + center.TechnicalDirectorNurseUserId = request.TechnicalDirectorNurseUserId; + center.TechnicalDirectorLicenseNo = request.TechnicalDirectorLicenseNo; + center.EnamadCode = request.EnamadCode; + center.SettlementIban = string.IsNullOrWhiteSpace(request.SettlementIban) ? null : request.SettlementIban; + center.IsMerchantOfRecord = request.IsMerchantOfRecord; + center.CommissionRate = request.CommissionRate; + center.AdminUserId = request.AdminUserId; + + await unitOfWork.CommitAsync(); + + var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken); + return OperationResult.SuccessResult(detail!); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Validator.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Validator.cs new file mode 100644 index 0000000..e0e539d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.Validator.cs @@ -0,0 +1,29 @@ +using FluentValidation; + +namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter; + +public sealed class UpdatePartnerCenterCommandValidator : AbstractValidator +{ + public UpdatePartnerCenterCommandValidator() + { + // Id is route-supplied (merged via `with`), so it is not validated here. + RuleFor(x => x.Name).NotEmpty().MaximumLength(300); + RuleFor(x => x.LegalEntityType).MaximumLength(30); + RuleFor(x => x.MohEstablishmentPermitNo).NotEmpty().MaximumLength(100); + RuleFor(x => x.TechnicalDirectorLicenseNo).MaximumLength(100); + RuleFor(x => x.EnamadCode).MaximumLength(100); + RuleFor(x => x.AdminUserId).GreaterThan(0); + + RuleFor(x => x.CommissionRate) + .InclusiveBetween(0m, 0.9999m) + .When(x => x.CommissionRate.HasValue); + + RuleFor(x => x.SettlementIban) + .NotEmpty() + .MaximumLength(34) + .When(x => x.IsMerchantOfRecord) + .WithMessage("A merchant-of-record center requires a settlement IBAN."); + + RuleFor(x => x.SettlementIban).MaximumLength(34).When(x => !x.IsMerchantOfRecord); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.cs new file mode 100644 index 0000000..9607eaf --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/UpdatePartnerCenter/UpdatePartnerCenterCommand.cs @@ -0,0 +1,21 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter; + +/// Updates a partner center's editable fields (admin-only, replace semantics). is +/// route-supplied. A merchant-of-record center must still carry a settlement IBAN. +public record UpdatePartnerCenterCommand( + string Name, + string? LegalEntityType, + string MohEstablishmentPermitNo, + int? TechnicalDirectorNurseUserId, + string? TechnicalDirectorLicenseNo, + string? EnamadCode, + string? SettlementIban, + bool IsMerchantOfRecord, + decimal? CommissionRate, + int AdminUserId, + long Id = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.Handler.cs new file mode 100644 index 0000000..e3ef25b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.Handler.cs @@ -0,0 +1,45 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter; + +/// +/// Runs the (mocked) licensing checks behind and records the admin's +/// approval. An explicit Invalid verdict on the establishment permit or eNamad blocks activation; a +/// Valid or NeedsManualReview verdict lets the human admin's decision stand (this command is that +/// decision). The seam call is the swap point for a real registry/API later — this handler is unchanged then. +/// +internal sealed class VerifyPartnerCenterCommandHandler( + IUnitOfWork unitOfWork, + ILicenseVerificationService licenseVerification, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(VerifyPartnerCenterCommand request, CancellationToken cancellationToken) + { + var center = await unitOfWork.PartnerCenterRepository.GetTrackedAsync(request.Id, cancellationToken); + if (center is null) + return OperationResult.NotFoundResult("Partner center not found."); + + var permitVerdict = await licenseVerification.VerifyEstablishmentPermitAsync(center.MohEstablishmentPermitNo, cancellationToken); + if (permitVerdict.Status == LicenseVerificationStatus.Invalid) + return OperationResult.FailureResult("moh_establishment_permit_no", permitVerdict.Reason); + + if (!string.IsNullOrWhiteSpace(center.EnamadCode)) + { + var enamadVerdict = await licenseVerification.VerifyENamadAsync(center.EnamadCode, cancellationToken); + if (enamadVerdict.Status == LicenseVerificationStatus.Invalid) + return OperationResult.FailureResult("enamad_code", enamadVerdict.Reason); + } + + center.Verify(dateTimeProvider.UtcNow); + await unitOfWork.CommitAsync(); + + var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(center.Id, cancellationToken); + return OperationResult.SuccessResult(detail!); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.cs new file mode 100644 index 0000000..be914c7 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Commands/VerifyPartnerCenter/VerifyPartnerCenterCommand.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter; + +/// Records the licensing approval for a center and activates it (sets verified_at + is_active). +/// At MVP the eNamad / MoH check runs behind ILicenseVerificationService and returns NeedsManualReview, +/// so this command IS the admin's recorded decision; an Invalid verdict blocks activation. +public record VerifyPartnerCenterCommand(long Id = 0) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs new file mode 100644 index 0000000..eb25ab9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs @@ -0,0 +1,32 @@ +#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.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard; + +/// Authorizes the dashboard to the center's own account (or staff) and returns its read model. +internal sealed class GetCenterDashboardQueryHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetCenterDashboardQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var centerAdminUserId = await unitOfWork.PartnerCenterRepository.GetAdminUserIdAsync(request.CenterId, cancellationToken); + if (centerAdminUserId is null) + return OperationResult.NotFoundResult("Partner center not found."); + + if (!StaffRoles.IsStaff(currentUser.Roles) && centerAdminUserId != userId) + return OperationResult.ForbiddenResult("This dashboard belongs to another center."); + + var dashboard = await unitOfWork.PartnerCenterRepository.GetDashboardAsync(request.CenterId, cancellationToken); + return OperationResult.SuccessResult(dashboard!); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.cs new file mode 100644 index 0000000..44f4195 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.cs @@ -0,0 +1,10 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterDashboard; + +/// The center dashboard, scoped to the center's own admin_user_id (staff may also read it): +/// sponsored nurses + booking/invoice counts + the (masked) settlement summary. +public record GetCenterDashboardQuery(long CenterId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.Handler.cs new file mode 100644 index 0000000..b9742b4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.Handler.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking; + +internal sealed class GetCenterForBookingQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetCenterForBookingQuery request, CancellationToken cancellationToken) + { + var resolution = await unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(request.BookingId, cancellationToken); + return resolution is null + ? OperationResult.NotFoundResult("Booking not found.") + : OperationResult.SuccessResult(resolution); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.cs new file mode 100644 index 0000000..5057ea3 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterForBooking/GetCenterForBookingQuery.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetCenterForBooking; + +/// Resolves which center legally covers a booking and the resulting invoice-issuer / settlement decision. +/// This is the single resolver b11's commission-invoice issuance calls — invoices and settlement follow +/// partner_centers, never a hardcoded platform. +public record GetCenterForBookingQuery(long BookingId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.Handler.cs new file mode 100644 index 0000000..868a98f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.Handler.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById; + +internal sealed class GetPartnerCenterByIdQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle(GetPartnerCenterByIdQuery request, CancellationToken cancellationToken) + { + var detail = await unitOfWork.PartnerCenterRepository.GetDetailAsync(request.Id, cancellationToken); + return detail is null + ? OperationResult.NotFoundResult("Partner center not found.") + : OperationResult.SuccessResult(detail); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.cs new file mode 100644 index 0000000..5c0de55 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetPartnerCenterById/GetPartnerCenterByIdQuery.cs @@ -0,0 +1,9 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.GetPartnerCenterById; + +/// Admin detail view of a partner center — the settlement IBAN is returned masked (last 4), never plaintext. +public record GetPartnerCenterByIdQuery(long Id) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.Handler.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.Handler.cs new file mode 100644 index 0000000..5410116 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.Handler.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters; + +internal sealed class ListPartnerCentersQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle(ListPartnerCentersQuery request, CancellationToken cancellationToken) + { + var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize); + var result = await unitOfWork.PartnerCenterRepository.ListAsync(request.IsActive, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.cs b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.cs new file mode 100644 index 0000000..1fea3af --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PartnerCenters/Queries/ListPartnerCenters/ListPartnerCentersQuery.cs @@ -0,0 +1,10 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Mediator; + +namespace Baya.Application.Features.PartnerCenters.Queries.ListPartnerCenters; + +/// Admin paginated list of partner centers (no IBAN), optional active filter, with sponsored-nurse counts. +public record ListPartnerCentersQuery(bool? IsActive = null, int Page = 1, int PageSize = 50) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs index 2210a34..5bba9e3 100644 --- a/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Payments/Commands/ConfirmPaymentAndPostLedger/ConfirmPaymentAndPostLedgerCommand.Handler.cs @@ -20,7 +20,8 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler( IPaymentProvider paymentProvider, ISettlementSplitProvider settlementSplitProvider, IVariantSnapshotSerializer variantSnapshotSerializer, - INotificationDispatcher notifications) + INotificationDispatcher notifications, + ISender sender) : IRequestHandler> { public async ValueTask> Handle(ConfirmPaymentAndPostLedgerCommand request, CancellationToken cancellationToken) @@ -86,6 +87,11 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler( if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId) await NotifyConfirmedAsync(customerUserId, nurseUserId, booking, cancellationToken); + // Open the booking-coordination ticket (nurse + customer) once the booking is confirmed. Idempotent — + // one coordination ticket per booking; a replayed confirm is a no-op (b15). + if (conversion.Created) + await sender.Send(new Messaging.Commands.AutoCreateCoordinationTicket.AutoCreateCoordinationTicketCommand(booking), cancellationToken); + return OperationResult.SuccessResult(true); } diff --git a/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs index 2ed0061..b01bab0 100644 --- a/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs @@ -6,8 +6,10 @@ using Baya.Application.Contracts.Configuration; using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Persistence; using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Messaging.Commands.OpenTicket; using Baya.Application.Models.Common; using Baya.Application.Models.Refunds; +using Baya.Domain.Entities.Messaging; using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.Refunds; using Baya.Domain.Entities.SupportAlerts; @@ -34,23 +36,31 @@ internal sealed class CreateRefundCommandHandler( IBnplProvider bnplProvider, INursePayoutStatus nursePayoutStatus, ISupportAlertService supportAlerts, - INotificationDispatcher notifications) + INotificationDispatcher notifications, + ISender sender) : IRequestHandler> { public async ValueTask> Handle(CreateRefundCommand request, CancellationToken cancellationToken) { - // Ticket link is required, but config-gated off until b15 ships the tickets table (the FK is nullable - // now for exactly this forward-dep). Read the gate through the typed config accessor, never hardcoded. - var ticketRequired = await platformConfig.GetConfig("refund_ticket_required", cancellationToken); - if (ticketRequired && request.TicketId is null) - return OperationResult.FailureResult("ticket_id", "A support ticket is required to issue a refund."); - await using var _ = await distributedLock.AcquireAsync($"booking:{request.BookingId}:refund", cancellationToken); var context = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken); if (context is null) return OperationResult.NotFoundResult("No captured payment exists for this booking to refund."); + // Every admin refund hangs off a ticket (the dispute paper trail). b15 ships the ticket system this link + // targets: if the caller didn't supply one, open a category=refund ticket now (staff bypass on the booking + // link), so refunds.ticket_id is always non-null. Only done once the booking's captured payment exists. + var ticketId = request.TicketId; + if (ticketId is null) + { + var opened = await sender.Send( + new OpenTicketCommand(TicketCategory.Refund, "Refund", null, request.BookingId, null), cancellationToken); + if (!opened.IsSuccess) + return OperationResult.FailureResult("ticket", "Could not open a refund ticket to anchor this refund."); + ticketId = opened.Result.TicketId; + } + var decomposition = ResolveDecomposition(request, context); if (decomposition is null) return OperationResult.FailureResult( @@ -79,7 +89,7 @@ internal sealed class CreateRefundCommandHandler( PaymentTransactionId = context.PaymentTransactionId, BookingId = context.BookingId, RequestedByCustomerId = context.CustomerId, - TicketId = request.TicketId, + TicketId = ticketId, Amount = amount, PlatformFeeRefundedIrr = platformFeeRefunded, NursePayoutRefundedIrr = nursePayoutRefunded, diff --git a/server/src/Core/Baya.Application/Models/Messaging/MessagingProjections.cs b/server/src/Core/Baya.Application/Models/Messaging/MessagingProjections.cs new file mode 100644 index 0000000..70b66a3 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Messaging/MessagingProjections.cs @@ -0,0 +1,58 @@ +#nullable enable +namespace Baya.Application.Models.Messaging; + +/// A ticket row in a list (no messages). Used by ListMyTickets and the admin queue. +public record TicketSummaryDto( + long Id, + string ReferenceCode, + string? Subject, + string Status, + string Category, + long? BookingId, + long? RefundId, + DateTimeOffset CreatedAt); + +/// One message in a thread. IsInternal is only ever true in the admin view — the user +/// view never contains an internal message (it is stripped in the query projection). +public record TicketMessageDto( + long Id, + int SenderId, + string Body, + bool IsInternal, + DateTimeOffset SentAt); + +/// An active participant on a thread. +public record TicketParticipantDto( + int UserId, + string? RoleOnTicket); + +/// The full ordered thread the caller may see. In the user view internal messages are absent; +/// the admin view returns them. +public record TicketThreadDto( + long Id, + string ReferenceCode, + string? Subject, + string Status, + string Category, + long? BookingId, + long? RefundId, + int OpenedById, + DateTimeOffset? ClosedAt, + IReadOnlyList Participants, + IReadOnlyList Messages); + +/// The header facts of a ticket (no messages) — used for authorization + the thread response header. +public record TicketHeaderDto( + long Id, + string ReferenceCode, + string? Subject, + string Status, + string Category, + long? BookingId, + long? RefundId, + int OpenedById, + DateTimeOffset? ClosedAt); + +public record OpenTicketResult(long TicketId, string ReferenceCode, string Status, string Category); + +public record PostMessageResult(long MessageId, long TicketId, DateTimeOffset SentAt); diff --git a/server/src/Core/Baya.Application/Models/PartnerCenters/PartnerCenterProjections.cs b/server/src/Core/Baya.Application/Models/PartnerCenters/PartnerCenterProjections.cs new file mode 100644 index 0000000..d79f7e5 --- /dev/null +++ b/server/src/Core/Baya.Application/Models/PartnerCenters/PartnerCenterProjections.cs @@ -0,0 +1,65 @@ +#nullable enable +namespace Baya.Application.Models.PartnerCenters; + +/// A partner center in the admin list (never carries the settlement IBAN), with its sponsored-nurse count. +public record PartnerCenterListItemDto( + long Id, + string Name, + string? LegalEntityType, + bool IsMerchantOfRecord, + bool IsActive, + DateTimeOffset? VerifiedAt, + int SponsoredNurseCount, + int AdminUserId); + +/// The admin detail view of a partner center. is the last-4 mask — +/// the plaintext/full IBAN is never returned. +public record PartnerCenterDetailDto( + long Id, + string Name, + string? LegalEntityType, + string MohEstablishmentPermitNo, + int? TechnicalDirectorNurseUserId, + string? TechnicalDirectorLicenseNo, + string? EnamadCode, + string? SettlementIbanMasked, + bool IsMerchantOfRecord, + decimal? CommissionRate, + int AdminUserId, + bool IsActive, + DateTimeOffset? VerifiedAt, + int SponsoredNurseCount, + DateTimeOffset CreatedAt); + +/// The issuer/settlement decision for a booking. is platform or +/// partner_center; when a merchant-of-record center legally covers the booking it is the invoice issuer +/// and settlement target. This is the single resolver b11's invoice issuance calls. +public record CenterForBookingDto( + long BookingId, + string IssuingEntityType, + long? PartnerCenterId, + string? PartnerCenterName, + bool IsMerchantOfRecord); + +/// A nurse sponsored by a center (for the center dashboard). +public record SponsoredNurseDto( + long NurseProfileId, + int UserId, + bool IsVerified, + decimal AverageRating, + int TotalCompletedBookings); + +/// The center dashboard read model — scoped to the center's admin_user_id: the sponsored nurses, +/// the count of bookings its nurses served, and the settlement/invoice summary (only meaningful when the center +/// is merchant-of-record). Surfaces b13 payout / b11 invoice read models filtered to the center; it does not +/// re-implement that logic. +public record CenterDashboardDto( + long CenterId, + string Name, + bool IsMerchantOfRecord, + bool IsActive, + string? SettlementIbanMasked, + int SponsoredNurseCount, + int SponsoredBookingCount, + int InvoiceCount, + IReadOnlyList SponsoredNurses); diff --git a/server/src/Core/Baya.Domain/Entities/Messaging/Ticket.cs b/server/src/Core/Baya.Domain/Entities/Messaging/Ticket.cs new file mode 100644 index 0000000..a2ec499 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Messaging/Ticket.cs @@ -0,0 +1,63 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Messaging; + +/// +/// The root of all post-booking communication. There is deliberately no live chat and no direct +/// nurse↔customer side-channel: nurse and customer coordinate here, under full admin visibility, and admin-only +/// is_internal notes (on ) never leak to users. A ticket optionally links a +/// and/or a both nullable: a pure support ticket has +/// neither. is the human-facing support id: minted once (collision-checked, UNIQUE +/// index backstop) and never mutated. Status transitions go through /. +/// +public class Ticket : BaseEntity +{ + /// Human-facing support id (e.g. TKT-3F9K2A). Minted once, UNIQUE, stable, quoted to users. + public string ReferenceCode { get; set; } = string.Empty; + + public string? Subject { get; set; } + + /// Guarded — mutated only through /. Defaults to + /// . + public string Status { get; private set; } = TicketStatus.Open; + + /// A code. + public string Category { get; set; } = TicketCategory.Support; + + /// Optional link to the booking this ticket coordinates/anchors. NULL for a pure support ticket. + public long? BookingId { get; set; } + + /// Optional link to the refund this ticket anchors (b11's dispute paper trail). NULL otherwise. + public long? RefundId { get; set; } + + /// The users.id that opened the ticket (first participant). + public int OpenedById { get; set; } + + public DateTimeOffset? ClosedAt { get; private set; } + + public int? ClosedById { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection Participants { get; set; } = new List(); + public ICollection Messages { get; set; } = new List(); + + public bool IsOpen => Status == TicketStatus.Open; + + /// Closes the thread and stamps who/when — the owner trail also comes from the audit fields. + public void Close(int closedById, DateTimeOffset now) + { + Status = TicketStatus.Closed; + ClosedAt = now; + ClosedById = closedById; + } + + /// Reopens a closed thread, clearing the close stamp. + public void Reopen() + { + Status = TicketStatus.Open; + ClosedAt = null; + ClosedById = null; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Messaging/TicketCodes.cs b/server/src/Core/Baya.Domain/Entities/Messaging/TicketCodes.cs new file mode 100644 index 0000000..17e018d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Messaging/TicketCodes.cs @@ -0,0 +1,39 @@ +namespace Baya.Domain.Entities.Messaging; + +/// The lifecycle of a , persisted as these stable snake_case codes (never a C# +/// enum member name). A ticket is born and can be closed/reopened by a participant or admin. +public static class TicketStatus +{ + public const string Open = "open"; + public const string Closed = "closed"; + + public static readonly IReadOnlyList All = [Open, Closed]; + + public static bool IsValid(string status) => All.Contains(status); +} + +/// What a ticket is for. is the auto-created, booking-scoped logistics +/// thread; is a free-standing help request; anchors an admin refund +/// (b11); records the aftermath of an on-site emergency call. +public static class TicketCategory +{ + public const string Coordination = "coordination"; + public const string Support = "support"; + public const string Refund = "refund"; + public const string Emergency = "emergency"; + + public static readonly IReadOnlyList All = [Coordination, Support, Refund, Emergency]; + + public static bool IsValid(string category) => All.Contains(category); +} + +/// The role a participant plays on a thread — derived from the user's platform role, used only to label +/// the thread in the UI. Not an authorization source (authorization is participation + admin). +public static class TicketParticipantRole +{ + public const string Customer = "customer"; + public const string Nurse = "nurse"; + public const string Admin = "admin"; + + public static readonly IReadOnlyList All = [Customer, Nurse, Admin]; +} diff --git a/server/src/Core/Baya.Domain/Entities/Messaging/TicketMessage.cs b/server/src/Core/Baya.Domain/Entities/Messaging/TicketMessage.cs new file mode 100644 index 0000000..4d2c23b --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Messaging/TicketMessage.cs @@ -0,0 +1,29 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Messaging; + +/// +/// One message on a ticket thread. is the hard visibility boundary: an +/// admin-only internal note that must never appear in any user-facing query, payload, or join — a non-admin can +/// neither set nor read it. This is enforced in the query projection (the user view of a thread strips every +/// internal message), not in the UI. +/// +public class TicketMessage : BaseEntity +{ + public long TicketId { get; set; } + public Ticket Ticket { get; set; } = null!; + + /// The users.id that sent the message. + public int SenderId { get; set; } + + public string Body { get; set; } = string.Empty; + + /// Admin-only note. Default false. The hard visibility boundary — stripped from every + /// user-facing thread read; only the admin view returns it. + public bool IsInternal { get; set; } + + public DateTimeOffset SentAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Messaging/TicketParticipant.cs b/server/src/Core/Baya.Domain/Entities/Messaging/TicketParticipant.cs new file mode 100644 index 0000000..258116d --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Messaging/TicketParticipant.cs @@ -0,0 +1,38 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Messaging; + +/// +/// A user on a ticket thread. Participation (plus admin) is the authorization boundary: only an active +/// participant (or an admin) may read or post. UNIQUE(ticket_id, user_id) is the authoritative backstop +/// against adding a user twice — a duplicate add is a clean OperationResult conflict, never a raw DB +/// exception. Removal is a soft stamp (so the unique row survives and a later re-add +/// resurrects it) rather than a hard delete. +/// +public class TicketParticipant : BaseEntity +{ + public long TicketId { get; set; } + public Ticket Ticket { get; set; } = null!; + + public int UserId { get; set; } + + /// A label (derived), nullable. + public string? RoleOnTicket { get; set; } + + public int? AddedById { get; set; } + + /// Soft-remove stamp. null = an active participant; non-null = detached from the thread. + public DateTimeOffset? RemovedAt { get; private set; } + + public bool IsActive => RemovedAt is null; + + public void Remove(DateTimeOffset now) => RemovedAt = now; + + /// Re-attaches a previously-removed participant (the same UNIQUE row), clearing the removal stamp. + public void Restore(int? addedById) + { + RemovedAt = null; + AddedById = addedById; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/PartnerCenters/PartnerCenter.cs b/server/src/Core/Baya.Domain/Entities/PartnerCenters/PartnerCenter.cs new file mode 100644 index 0000000..fbca1ef --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/PartnerCenters/PartnerCenter.cs @@ -0,0 +1,62 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.PartnerCenters; + +/// +/// A licensed home-nursing center (مرکز مشاوره و ارائه مراقبت‌های پرستاری در منزل) that sponsors nurses +/// and, at launch, is plausibly the merchant-of-record (the Asanism go-to-market model). When +/// is set it — not Balinyaar and not the nurse — is the legal +/// invoice issuer and the IPG settlement target: invoices and settlement follow the center, resolved once via +/// GetCenterForBooking. Distinct from the future (DEFERRED) organizations employer model — a +/// center sponsors *for legality*, it does not *employ*. Marked so every admin state +/// change appends an immutable audit_logs row (the encrypted is redacted in +/// the diff). +/// +public class PartnerCenter : BaseEntity, IAuditable +{ + public string Name { get; set; } = string.Empty; + + public string? LegalEntityType { get; set; } + + /// پروانه تأسیس — the MoH establishment permit number that makes the operation legal. + public string MohEstablishmentPermitNo { get; set; } = string.Empty; + + /// مسئول فنی — the technical director nurse's users.id. Nullable. + public int? TechnicalDirectorNurseUserId { get; set; } + + public string? TechnicalDirectorLicenseNo { get; set; } + + /// نماد اعتماد الکترونیکی — the eNamad trust seal code (clears the BNPL onboarding gate). + public string? EnamadCode { get; set; } + + /// The center's settlement IBAN — only when merchant-of-record. Encrypted at rest via + /// IFieldEncryptor (converter wired in ApplicationDbContext); never stored, logged, or + /// projected in plaintext; masked (last 4) in any read. Redacted from the audit diff. + [AuditRedacted] + public string? SettlementIban { get; set; } + + public bool IsMerchantOfRecord { get; set; } + + /// The center's own cut (fraction, 0–1), separate from platform_fee_rate. Nullable. + public decimal? CommissionRate { get; set; } + + /// The users.id that owns the center's dashboard account. + public int AdminUserId { get; set; } + + /// Guarded — flipped only through /. + public bool IsActive { get; private set; } + + public DateTimeOffset? VerifiedAt { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + /// Records the (manual, at MVP) licensing approval and activates the center. + public void Verify(DateTimeOffset now) + { + VerifiedAt = now; + IsActive = true; + } + + public void SetActive(bool active) => IsActive = active; +} diff --git a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs index 4d10ee8..e2b89f9 100644 --- a/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs +++ b/server/src/Core/Baya.Domain/Entities/SupportAlerts/SupportAlert.cs @@ -49,9 +49,13 @@ public static class SupportAlertType /// track recovery. Iranian IBAN transfers are irreversible, so this is always worth a human look. public const string NurseClawback = "nurse_clawback"; + /// An on-site emergency was logged by the assigned nurse (b15) — the aftermath of the emergency-call + /// playbook. Always worth a human review. + public const string Emergency = "emergency"; + public static readonly IReadOnlyList All = [ - LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback + LowRating, EvvNoShow, EvvLocationMismatch, VerificationExpired, SharedSim, PaymentAnomaly, FraudSignal, NurseClawback, Emergency ]; } diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockLicenseVerificationService.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockLicenseVerificationService.cs new file mode 100644 index 0000000..27dc9a9 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockLicenseVerificationService.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Default — and the mock. The MoH establishment-permit registry and +/// eNamad have no public B2B API, so at MVP licensing is a manual admin approval: every call returns +/// , and VerifyPartnerCenter records the human +/// decision. Set to have the mock return +/// (test the auto-approve path). When a real registry/API becomes +/// available, a real implementation replaces this registration and starts returning real verdicts — callers are +/// unchanged. +/// +public sealed class MockLicenseVerificationService(IOptions options) : ILicenseVerificationService +{ + private LicenseVerificationOptions Options => options.Value.LicenseVerification; + + public Task VerifyEstablishmentPermitAsync(string permitNo, CancellationToken cancellationToken = default) + => Task.FromResult(Verdict($"establishment permit '{permitNo}'")); + + public Task VerifyENamadAsync(string enamadCode, CancellationToken cancellationToken = default) + => Task.FromResult(Verdict($"eNamad '{enamadCode}'")); + + private LicenseVerdict Verdict(string subject) + => Options.AutoApprove + ? new LicenseVerdict(LicenseVerificationStatus.Valid, $"Auto-approved (mock) for {subject}.") + : new LicenseVerdict(LicenseVerificationStatus.NeedsManualReview, + $"No automated registry for {subject}; requires a manual admin decision."); +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index bcd62b0..54cfc70 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -21,6 +21,19 @@ public sealed class SeamOptions public CurrencyOptions Currency { get; set; } = new(); public BankTransferOptions BankTransfer { get; set; } = new(); public ReviewModerationOptions ReviewModeration { get; set; } = new(); + public LicenseVerificationOptions LicenseVerification { get; set; } = new(); +} + +/// +/// Tunes the mock ILicenseVerificationService (b15 partner-center eNamad / MoH establishment-permit +/// check). By default every check returns NeedsManualReview so VerifyPartnerCenter records the +/// human admin decision. Set to have the mock return Valid (test the +/// auto-approve path). The real eNamad / MoH registry adapter ignores these knobs. +/// +public sealed class LicenseVerificationOptions +{ + /// When true, permit/eNamad checks auto-approve (return Valid) instead of requiring a manual decision. + public bool AutoApprove { get; set; } } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 15a37ed..f6e7469 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -87,6 +87,12 @@ public static class ServiceCollectionExtension // keeps decision authority + the human override, so the real impl never touches the handler. services.AddSingleton(); + // Partner-center licensing (backend-phase-15). eNamad / MoH establishment-permit registries have no + // public B2B API, so the mock returns NeedsManualReview (manual admin approval at MVP; config can force + // auto-approve for tests). A real registry/API client swaps in by a registration change only — + // VerifyPartnerCenter records the decision and is never touched. + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs index abf0ebb..f6bd216 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs @@ -172,5 +172,12 @@ public class ApplicationDbContext: IdentityDbContext p.IbanSnapshot).HasConversion(encrypted); }); + + // b15 partner-center settlement account: the center's IBAN (only when merchant-of-record) is encrypted + // at rest through the same seam and never serialized in plaintext — reads mask it to the last 4 digits. + modelBuilder.Entity(builder => + { + builder.Property(c => c.SettlementIban).HasConversion(encrypted); + }); } } \ No newline at end of file diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketConfig.cs new file mode 100644 index 0000000..653fa5e --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketConfig.cs @@ -0,0 +1,43 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.Refunds; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig; + +/// +/// tickets — the root of all post-booking communication. UNIQUE(reference_code) backs the stable +/// human-facing support id; the status and (status, created_at) indexes serve the admin queue; +/// the booking_id/refund_id indexes serve the "tickets for this booking/refund" lookups. Both +/// links are optional (nullable FK) — a pure support ticket has neither. +/// +internal sealed class TicketConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Tickets", "messaging"); + + builder.Property(t => t.ReferenceCode).HasMaxLength(40).IsRequired(); + builder.Property(t => t.Subject).HasMaxLength(300); + builder.Property(t => t.Status).HasMaxLength(20).IsRequired(); + builder.Property(t => t.Category).HasMaxLength(30).IsRequired(); + + builder.HasIndex(t => t.ReferenceCode).IsUnique(); + builder.HasIndex(t => t.Status); + builder.HasIndex(t => t.BookingId); + builder.HasIndex(t => t.RefundId); + builder.HasIndex(t => new { t.Status, t.CreatedAt }); + + builder.HasOne().WithMany().HasForeignKey(t => t.OpenedById).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(t => t.ClosedById).IsRequired(false); + builder.HasOne().WithMany().HasForeignKey(t => t.BookingId).IsRequired(false); + builder.HasOne().WithMany().HasForeignKey(t => t.RefundId).IsRequired(false); + + builder.HasMany(t => t.Participants).WithOne(p => p.Ticket).HasForeignKey(p => p.TicketId).IsRequired(); + builder.HasMany(t => t.Messages).WithOne(m => m.Ticket).HasForeignKey(m => m.TicketId).IsRequired(); + + builder.HasQueryFilter(t => t.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketMessageConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketMessageConfig.cs new file mode 100644 index 0000000..07294b5 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketMessageConfig.cs @@ -0,0 +1,27 @@ +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig; + +/// +/// ticket_messages — individual messages. is_internal (default 0) is the hard visibility boundary +/// (admin-only note); the (ticket_id, sent_at) index serves the ordered thread read. +/// +internal sealed class TicketMessageConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TicketMessages", "messaging"); + + builder.Property(m => m.Body).HasMaxLength(4000).IsRequired(); + builder.Property(m => m.IsInternal).HasDefaultValue(false); + + builder.HasIndex(m => new { m.TicketId, m.SentAt }); + + builder.HasOne().WithMany().HasForeignKey(m => m.SenderId).IsRequired(); + + builder.HasQueryFilter(m => m.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketParticipantConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketParticipantConfig.cs new file mode 100644 index 0000000..5429813 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/MessagingConfig/TicketParticipantConfig.cs @@ -0,0 +1,28 @@ +using Baya.Domain.Entities.Messaging; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.MessagingConfig; + +/// +/// ticket_participants — who is on a thread. UNIQUE(ticket_id, user_id) is the authoritative +/// backstop against adding a user twice (a duplicate add is a clean conflict, never a raw DB error); removal is +/// a soft removed_at stamp, so the unique row survives and a re-add resurrects it. The +/// (user_id, ticket_id) index serves ListMyTickets. +/// +internal sealed class TicketParticipantConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("TicketParticipants", "messaging"); + + builder.Property(p => p.RoleOnTicket).HasMaxLength(20); + + builder.HasIndex(p => new { p.TicketId, p.UserId }).IsUnique(); + builder.HasIndex(p => new { p.UserId, p.TicketId }); + + builder.HasOne().WithMany().HasForeignKey(p => p.UserId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(p => p.AddedById).IsRequired(false); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PartnerCentersConfig/PartnerCenterConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PartnerCentersConfig/PartnerCenterConfig.cs new file mode 100644 index 0000000..8f3a7a2 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/PartnerCentersConfig/PartnerCenterConfig.cs @@ -0,0 +1,45 @@ +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.PartnerCenters; +using Baya.Domain.Entities.User; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.PartnerCentersConfig; + +/// +/// partner_centers — the licensed sponsor center. settlement_iban is encrypted at rest (converter +/// wired in ApplicationDbContext) and masked in reads; commission_rate is the center's own cut +/// (separate from platform_fee_rate). Indexes on is_active (the active-center list) and +/// admin_user_id (the center portal scope). The 1:N sponsorship to nurse_profiles is configured +/// here (adds the nurse_profiles.partner_center_id FK in place, without forking a parallel table). +/// +internal sealed class PartnerCenterConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PartnerCenters", "partner"); + + builder.Property(c => c.Name).HasMaxLength(300).IsRequired(); + builder.Property(c => c.LegalEntityType).HasMaxLength(30); + builder.Property(c => c.MohEstablishmentPermitNo).HasMaxLength(100).IsRequired(); + builder.Property(c => c.TechnicalDirectorLicenseNo).HasMaxLength(100); + builder.Property(c => c.EnamadCode).HasMaxLength(100); + builder.Property(c => c.SettlementIban).HasMaxLength(256); // ciphertext is longer than the 34-char plaintext + builder.Property(c => c.CommissionRate).HasPrecision(5, 4); + + builder.HasIndex(c => c.IsActive); + builder.HasIndex(c => c.AdminUserId); + + builder.HasOne().WithMany().HasForeignKey(c => c.AdminUserId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(c => c.TechnicalDirectorNurseUserId).IsRequired(false); + + // 1:N sponsorship — adds nurse_profiles.partner_center_id FK in place (nullable; NULL once Balinyaar + // holds its own permit). No inverse navigation on NurseProfile (kept lean). + builder.HasMany() + .WithOne() + .HasForeignKey(n => n.PartnerCenterId) + .IsRequired(false); + + builder.HasQueryFilter(c => c.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.Designer.cs new file mode 100644 index 0000000..a1f978f --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.Designer.cs @@ -0,0 +1,5935 @@ +// +using System; +using Baya.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260709232741_MessagingAndPartnerCenters")] + partial class MessagingAndPartnerCenters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("PropsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("UserId"); + + b.ToTable("SystemEvents", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ActorUserId") + .HasColumnType("int"); + + b.Property("ChangedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("ActorUserId"); + + b.HasIndex("OccurredAt"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("AuditLogs", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("CallbackPayloadJson") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("nvarchar(5)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EligibilityStatus") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ExternalPaymentToken") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ExternalTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstallmentCount") + .HasColumnType("tinyint"); + + b.Property("MerchantOfRecord") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OrderAmountIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ProviderCommissionReversedAmount") + .HasColumnType("bigint"); + + b.Property("RefundChannel") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RevertTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RevertedAmountIrr") + .HasColumnType("bigint"); + + b.Property("RevertedAt") + .HasColumnType("datetime2"); + + b.Property("SettledAmountIrr") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalPaymentToken") + .HasFilter("[ExternalPaymentToken] IS NOT NULL"); + + b.HasIndex("PaymentTransactionId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("BnplTransactions", "payments", t => + { + t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BalinyaarCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CancellationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("CancellationRefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("CancelledAt") + .HasColumnType("datetime2"); + + b.Property("CancelledBy") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("ConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisputeWindowEndsAt") + .HasColumnType("datetime2"); + + b.Property("GrossPriceIrr") + .HasColumnType("bigint"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NursePayoutAmount") + .HasColumnType("bigint"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("PspFeeAmount") + .HasColumnType("bigint"); + + b.Property("RefundableAmountIrr") + .HasColumnType("bigint"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionCount") + .HasColumnType("smallint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.Property("VariantSnapshotJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingRequestId") + .IsUnique(); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("DisputeWindowEndsAt"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.ToTable("Bookings", "booking", t => + { + t.HasCheckConstraint("CK_Bookings_AmountSplit", "[GrossPriceIrr] = [BalinyaarCommissionIrr] + [NursePayoutAmount] AND [GrossPriceIrr] >= 0 AND [BalinyaarCommissionIrr] >= 0 AND [NursePayoutAmount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Allergies") + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CurrentConditions") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("EmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Medications") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SpecialInstructions") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.ToTable("BookingCareInstructions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerAddressId") + .HasColumnType("bigint"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("CustomerNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("NurseRejectionReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NurseResponseDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("PaymentDeadlineAt") + .HasColumnType("datetime2"); + + b.Property("RequestedDate") + .HasColumnType("date"); + + b.Property("RequestedTimeEnd") + .HasColumnType("time"); + + b.Property("RequestedTimeStart") + .HasColumnType("time"); + + b.Property("RequiredCaregiverGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CustomerAddressId"); + + b.HasIndex("PatientId"); + + b.HasIndex("VariantId"); + + b.HasIndex("CustomerId", "Status"); + + b.HasIndex("NurseId", "Status"); + + b.HasIndex("Status", "NurseResponseDeadlineAt"); + + b.HasIndex("Status", "PaymentDeadlineAt"); + + b.ToTable("BookingRequests", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationEventId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutEligibleAt") + .HasColumnType("datetime2"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTimeEnd") + .HasColumnType("time"); + + b.Property("ScheduledTimeStart") + .HasColumnType("time"); + + b.Property("SessionIndex") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VisitPayoutAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId", "SessionIndex"); + + b.HasIndex("Status", "ScheduledDate"); + + b.ToTable("BookingSessions", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.CancellationPolicy", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AppliesTo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FeeAmountIrr") + .HasColumnType("bigint"); + + b.Property("FeeRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("HoursBeforeStartMax") + .HasColumnType("int"); + + b.Property("HoursBeforeStartMin") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefundPercentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("AppliesTo", "IsActive"); + + b.ToTable("CancellationPolicies", "booking"); + + b.HasData( + new + { + Id = 1L, + AppliesTo = "customer", + Code = "standard_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMin = 24, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 2L, + AppliesTo = "customer", + Code = "standard_inside_24h", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + HoursBeforeStartMax = 24, + IsActive = true, + RefundPercentage = 50m + }, + new + { + Id = 3L, + AppliesTo = "nurse", + Code = "nurse_no_show", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + FeeRate = 0m, + IsActive = true, + RefundPercentage = 100m + }, + new + { + Id = 4L, + AppliesTo = "admin", + Code = "admin_cancellation", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + FeeAmountIrr = 0L, + IsActive = true, + RefundPercentage = 100m + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingSessionId") + .HasColumnType("bigint"); + + b.Property("CheckInAddressMatch") + .HasColumnType("bit"); + + b.Property("CheckInAt") + .HasColumnType("datetime2"); + + b.Property("CheckInDistanceMeters") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("CheckInLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckInLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutAt") + .HasColumnType("datetime2"); + + b.Property("CheckOutLat") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CheckOutLng") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("BookingSessionId") + .IsUnique(); + + b.HasIndex("CheckInAddressMatch"); + + b.ToTable("VisitVerifications", "booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OptionSetHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SessionCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId"); + + b.HasIndex("NurseId", "IsActive"); + + b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet") + .HasFilter("[DeletedAt] IS NULL"); + + b.ToTable("NurseServiceVariants", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("OptionValueId") + .HasColumnType("bigint"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId"); + + b.HasIndex("OptionValueId"); + + b.HasIndex("VariantId", "OptionGroupId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group"); + + b.ToTable("NurseServiceVariantOptions", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DescriptionEn") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DescriptionFa") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IconKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ServiceCategories", "catalog"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Elderly Care", + NameFa = "مراقبت از سالمند", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Post-Surgery Recovery", + NameFa = "مراقبت پس از جراحی", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Infant Care", + NameFa = "مراقبت از نوزاد", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chronic Illness Management", + NameFa = "مدیریت بیماری مزمن", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Companionship", + NameFa = "همراهی و مراقبت روزمره", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ServiceCategoryId", "SortOrder"); + + b.ToTable("ServiceOptionGroups", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("OptionGroupId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("OptionGroupId", "SortOrder"); + + b.ToTable("ServiceOptionValues", "catalog"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("PlatformConfigs", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "Balinyaar commission rate on the booking gross (fraction).", + Key = "platform_fee_rate", + Value = "0.15" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "VAT rate applied to the commission line only (fraction).", + Key = "vat_rate", + Value = "0.10" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours after check-out a booking can be disputed.", + Key = "dispute_window_hours", + Value = "72" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes a family has to pay before a pending booking expires.", + Key = "booking_payment_deadline_minutes", + Value = "30" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours a nurse has to accept/decline a booking request.", + Key = "nurse_response_deadline_hours", + Value = "24" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Weekly payout cadence in days.", + Key = "nurse_payout_interval_days", + Value = "7" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Allowed EVV check-in distance from the care address.", + Key = "evv_location_tolerance_meters", + Value = "200" + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "A review at or below this rating raises a support alert.", + Key = "min_rating_for_support_alert", + Value = "2" + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "Who is merchant of record for BNPL orders (platform|nurse).", + Key = "bnpl_merchant_of_record", + Value = "platform" + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "BNPL provider commission rate (fraction).", + Key = "bnpl_provider_commission_rate", + Value = "0.07" + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "string", + Description = "When BNPL settles funds to the platform (immediate|deferred).", + Key = "bnpl_settlement_timing", + Value = "immediate" + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "json", + Description = "Tiered cancellation refund policy: refund_percent by hours before the visit.", + Key = "cancellation_tiers", + Value = "[{\"min_hours_before\":48,\"refund_percent\":100},{\"min_hours_before\":24,\"refund_percent\":50},{\"min_hours_before\":0,\"refund_percent\":0}]" + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Seconds a phone must wait before another OTP can be requested.", + Key = "auth_otp_resend_seconds", + Value = "120" + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Wrong-code attempts allowed before OTP verification is refused until a fresh code.", + Key = "auth_otp_max_attempts", + Value = "5" + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Refresh-token session lifetime in days.", + Key = "auth_session_ttl_days", + Value = "30" + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between credential-expiry scans (the scheduled cron is deferred; the scan is admin-triggered today).", + Key = "verification_expiry_scan_cadence_hours", + Value = "24" + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Minutes after a session's scheduled start with no EVV check-in before it is flagged a no-show.", + Key = "no_show_threshold_minutes", + Value = "60" + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Hours between no-show sweeps (the scheduled cron is deferred; the sweep is admin-triggered today).", + Key = "no_show_scan_cadence_hours", + Value = "1" + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Whether a refund must link a support ticket (b11). Off until b15 ships the tickets table.", + Key = "refund_ticket_required", + Value = "false" + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "int", + Description = "Business days shown as the customer BNPL refund ETA (b11).", + Key = "bnpl_refund_eta_business_days", + Value = "10" + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "Ops/testing override that forces the post-payout clawback path for refunds (b11); b13 replaces the derivation.", + Key = "refund_assume_nurse_paid", + Value = "false" + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "decimal", + Description = "IRR net-amount threshold above which a payout is routed via SATNA (real-time) instead of PAYA (batch) (b13).", + Key = "payout_satna_threshold_irr", + Value = "1000000000" + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + DataType = "bool", + Description = "When on, a BNPL-paid booking is payout-eligible only after its provider settlement is received (b13; default off — the DEFERRED settled_at guard).", + Key = "require_bnpl_settlement_for_payout", + Value = "false" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("ProvinceId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("ProvinceId", "SortOrder"); + + b.ToTable("Cities", "geo"); + + b.HasData( + new + { + Id = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + ProvinceId = 1L, + SortOrder = 1 + }, + new + { + Id = 102L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Karaj", + NameFa = "کرج", + ProvinceId = 2L, + SortOrder = 1 + }, + new + { + Id = 103L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + ProvinceId = 3L, + SortOrder = 1 + }, + new + { + Id = 104L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shiraz", + NameFa = "شیراز", + ProvinceId = 4L, + SortOrder = 1 + }, + new + { + Id = 105L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mashhad", + NameFa = "مشهد", + ProvinceId = 5L, + SortOrder = 1 + }, + new + { + Id = 106L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tabriz", + NameFa = "تبریز", + ProvinceId = 6L, + SortOrder = 1 + }, + new + { + Id = 107L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Urmia", + NameFa = "ارومیه", + ProvinceId = 7L, + SortOrder = 1 + }, + new + { + Id = 108L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ahvaz", + NameFa = "اهواز", + ProvinceId = 8L, + SortOrder = 1 + }, + new + { + Id = 109L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + ProvinceId = 9L, + SortOrder = 1 + }, + new + { + Id = 110L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + ProvinceId = 10L, + SortOrder = 1 + }, + new + { + Id = 111L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Rasht", + NameFa = "رشت", + ProvinceId = 11L, + SortOrder = 1 + }, + new + { + Id = 112L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sari", + NameFa = "ساری", + ProvinceId = 12L, + SortOrder = 1 + }, + new + { + Id = 113L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Arak", + NameFa = "اراک", + ProvinceId = 13L, + SortOrder = 1 + }, + new + { + Id = 114L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + ProvinceId = 14L, + SortOrder = 1 + }, + new + { + Id = 115L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + ProvinceId = 15L, + SortOrder = 1 + }, + new + { + Id = 116L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + ProvinceId = 16L, + SortOrder = 1 + }, + new + { + Id = 117L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bojnord", + NameFa = "بجنورد", + ProvinceId = 17L, + SortOrder = 1 + }, + new + { + Id = 118L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Birjand", + NameFa = "بیرجند", + ProvinceId = 18L, + SortOrder = 1 + }, + new + { + Id = 119L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + ProvinceId = 19L, + SortOrder = 1 + }, + new + { + Id = 120L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sanandaj", + NameFa = "سنندج", + ProvinceId = 20L, + SortOrder = 1 + }, + new + { + Id = 121L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khorramabad", + NameFa = "خرم‌آباد", + ProvinceId = 21L, + SortOrder = 1 + }, + new + { + Id = 122L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gorgan", + NameFa = "گرگان", + ProvinceId = 22L, + SortOrder = 1 + }, + new + { + Id = 123L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bandar Abbas", + NameFa = "بندرعباس", + ProvinceId = 23L, + SortOrder = 1 + }, + new + { + Id = 124L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + ProvinceId = 24L, + SortOrder = 1 + }, + new + { + Id = 125L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + ProvinceId = 25L, + SortOrder = 1 + }, + new + { + Id = 126L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + ProvinceId = 26L, + SortOrder = 1 + }, + new + { + Id = 127L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + ProvinceId = 27L, + SortOrder = 1 + }, + new + { + Id = 128L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zahedan", + NameFa = "زاهدان", + ProvinceId = 28L, + SortOrder = 1 + }, + new + { + Id = 129L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Shahrekord", + NameFa = "شهرکرد", + ProvinceId = 29L, + SortOrder = 1 + }, + new + { + Id = 130L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yasuj", + NameFa = "یاسوج", + ProvinceId = 30L, + SortOrder = 1 + }, + new + { + Id = 131L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + ProvinceId = 31L, + SortOrder = 1 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("CityId", "SortOrder"); + + b.ToTable("Districts", "geo"); + + b.HasData( + new + { + Id = 1001L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 1", + NameFa = "منطقه ۱", + SortOrder = 1 + }, + new + { + Id = 1002L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 2", + NameFa = "منطقه ۲", + SortOrder = 2 + }, + new + { + Id = 1003L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 3", + NameFa = "منطقه ۳", + SortOrder = 3 + }, + new + { + Id = 1004L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 4", + NameFa = "منطقه ۴", + SortOrder = 4 + }, + new + { + Id = 1005L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 5", + NameFa = "منطقه ۵", + SortOrder = 5 + }, + new + { + Id = 1006L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 6", + NameFa = "منطقه ۶", + SortOrder = 6 + }, + new + { + Id = 1007L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 7", + NameFa = "منطقه ۷", + SortOrder = 7 + }, + new + { + Id = 1008L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 8", + NameFa = "منطقه ۸", + SortOrder = 8 + }, + new + { + Id = 1009L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 9", + NameFa = "منطقه ۹", + SortOrder = 9 + }, + new + { + Id = 1010L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 10", + NameFa = "منطقه ۱۰", + SortOrder = 10 + }, + new + { + Id = 1011L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 11", + NameFa = "منطقه ۱۱", + SortOrder = 11 + }, + new + { + Id = 1012L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 12", + NameFa = "منطقه ۱۲", + SortOrder = 12 + }, + new + { + Id = 1013L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 13", + NameFa = "منطقه ۱۳", + SortOrder = 13 + }, + new + { + Id = 1014L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 14", + NameFa = "منطقه ۱۴", + SortOrder = 14 + }, + new + { + Id = 1015L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 15", + NameFa = "منطقه ۱۵", + SortOrder = 15 + }, + new + { + Id = 1016L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 16", + NameFa = "منطقه ۱۶", + SortOrder = 16 + }, + new + { + Id = 1017L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 17", + NameFa = "منطقه ۱۷", + SortOrder = 17 + }, + new + { + Id = 1018L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 18", + NameFa = "منطقه ۱۸", + SortOrder = 18 + }, + new + { + Id = 1019L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 19", + NameFa = "منطقه ۱۹", + SortOrder = 19 + }, + new + { + Id = 1020L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 20", + NameFa = "منطقه ۲۰", + SortOrder = 20 + }, + new + { + Id = 1021L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 21", + NameFa = "منطقه ۲۱", + SortOrder = 21 + }, + new + { + Id = 1022L, + CityId = 101L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "District 22", + NameFa = "منطقه ۲۲", + SortOrder = 22 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("NurseId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("NurseId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseServiceAreas_Nurse_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.ToTable("NurseServiceAreas", "geo"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameEn") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("Provinces", "geo"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Tehran", + NameFa = "تهران", + SortOrder = 1 + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Alborz", + NameFa = "البرز", + SortOrder = 2 + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Isfahan", + NameFa = "اصفهان", + SortOrder = 3 + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Fars", + NameFa = "فارس", + SortOrder = 4 + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Razavi Khorasan", + NameFa = "خراسان رضوی", + SortOrder = 5 + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "East Azerbaijan", + NameFa = "آذربایجان شرقی", + SortOrder = 6 + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "West Azerbaijan", + NameFa = "آذربایجان غربی", + SortOrder = 7 + }, + new + { + Id = 8L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Khuzestan", + NameFa = "خوزستان", + SortOrder = 8 + }, + new + { + Id = 9L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qom", + NameFa = "قم", + SortOrder = 9 + }, + new + { + Id = 10L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kerman", + NameFa = "کرمان", + SortOrder = 10 + }, + new + { + Id = 11L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Gilan", + NameFa = "گیلان", + SortOrder = 11 + }, + new + { + Id = 12L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Mazandaran", + NameFa = "مازندران", + SortOrder = 12 + }, + new + { + Id = 13L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Markazi", + NameFa = "مرکزی", + SortOrder = 13 + }, + new + { + Id = 14L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ardabil", + NameFa = "اردبیل", + SortOrder = 14 + }, + new + { + Id = 15L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Qazvin", + NameFa = "قزوین", + SortOrder = 15 + }, + new + { + Id = 16L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kermanshah", + NameFa = "کرمانشاه", + SortOrder = 16 + }, + new + { + Id = 17L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "North Khorasan", + NameFa = "خراسان شمالی", + SortOrder = 17 + }, + new + { + Id = 18L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "South Khorasan", + NameFa = "خراسان جنوبی", + SortOrder = 18 + }, + new + { + Id = 19L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hamadan", + NameFa = "همدان", + SortOrder = 19 + }, + new + { + Id = 20L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kurdistan", + NameFa = "کردستان", + SortOrder = 20 + }, + new + { + Id = 21L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Lorestan", + NameFa = "لرستان", + SortOrder = 21 + }, + new + { + Id = 22L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Golestan", + NameFa = "گلستان", + SortOrder = 22 + }, + new + { + Id = 23L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Hormozgan", + NameFa = "هرمزگان", + SortOrder = 23 + }, + new + { + Id = 24L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Bushehr", + NameFa = "بوشهر", + SortOrder = 24 + }, + new + { + Id = 25L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Zanjan", + NameFa = "زنجان", + SortOrder = 25 + }, + new + { + Id = 26L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Semnan", + NameFa = "سمنان", + SortOrder = 26 + }, + new + { + Id = 27L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Yazd", + NameFa = "یزد", + SortOrder = 27 + }, + new + { + Id = 28L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Sistan and Baluchestan", + NameFa = "سیستان و بلوچستان", + SortOrder = 28 + }, + new + { + Id = 29L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Chaharmahal and Bakhtiari", + NameFa = "چهارمحال و بختیاری", + SortOrder = 29 + }, + new + { + Id = 30L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Kohgiluyeh and Boyer-Ahmad", + NameFa = "کهگیلویه و بویراحمد", + SortOrder = 30 + }, + new + { + Id = 31L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + NameEn = "Ilam", + NameFa = "ایلام", + SortOrder = 31 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Holidays.IranianHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("HolidayDate") + .HasColumnType("date"); + + b.Property("IsBankClosed") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("HolidayDate") + .IsUnique(); + + b.ToTable("IranianHolidays", "ops"); + + b.HasData( + new + { + Id = 1L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 2, 11), + IsBankClosed = true, + NameFa = "پیروزی انقلاب اسلامی", + Type = "national" + }, + new + { + Id = 2L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 21), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 3L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 22), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 4L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 23), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 5L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 3, 24), + IsBankClosed = true, + NameFa = "نوروز", + Type = "national" + }, + new + { + Id = 6L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 4, 1), + IsBankClosed = true, + NameFa = "روز طبیعت (سیزده‌به‌در)", + Type = "official" + }, + new + { + Id = 7L, + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + HolidayDate = new DateOnly(2026, 6, 26), + IsBankClosed = true, + NameFa = "عید سعید قربان", + Type = "religious" + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddressLine") + .HasColumnType("nvarchar(max)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("Latitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasPrecision(9, 6) + .HasColumnType("decimal(9,6)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PostalCode") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecipientPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("CustomerId") + .IsUnique() + .HasDatabaseName("UX_CustomerAddresses_Customer_Primary") + .HasFilter("[IsPrimary] = 1 AND [DeletedAt] IS NULL"); + + b.HasIndex("DistrictId"); + + b.ToTable("CustomerAddresses", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DefaultEmergencyContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("DefaultEmergencyContactPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("CustomerProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountHolderFromBank") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("AccountHolderName") + .HasColumnType("nvarchar(max)"); + + b.Property("BankName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Iban") + .HasColumnType("nvarchar(max)"); + + b.Property("IbanHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("MatchedNationalId") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OwnershipVendorRef") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("IbanHash") + .IsUnique(); + + b.HasIndex("NurseId") + .IsUnique() + .HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary") + .HasFilter("[IsPrimary] = 1"); + + b.HasIndex("VerifiedByAdminId"); + + b.ToTable("NurseBankAccounts", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .ValueGeneratedOnAdd() + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)") + .HasDefaultValue(0m); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EducationField") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("EducationLevel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAcceptingBookings") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsVerified") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("SpecializationsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("TotalCompletedBookings") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("TotalReviews") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("YearsOfExperience") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("PartnerCenterId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NurseProfiles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("BloodType") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Gender") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("InitialMedicalNotes") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Patients", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BnplCommissionIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrossIrr") + .HasColumnType("bigint"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("IssuingEntityType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("MoadianReferenceNumber") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("MoadianStatus") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PartnerCenterId") + .HasColumnType("bigint"); + + b.Property("PdfStorageKey") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PlatformCommissionIrr") + .HasColumnType("bigint"); + + b.Property("VatIrr") + .HasColumnType("bigint"); + + b.Property("VatRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("Invoices", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.InvoiceNumberSequence", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NextValue") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("InvoiceNumberSequences", "payments"); + + b.HasData( + new + { + Id = 1, + NextValue = 1L + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ClosedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ClosedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OpenedById") + .HasColumnType("int"); + + b.Property("ReferenceCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("ClosedById"); + + b.HasIndex("OpenedById"); + + b.HasIndex("ReferenceCode") + .IsUnique(); + + b.HasIndex("RefundId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Tickets", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsInternal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SenderId") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("TicketId", "SentAt"); + + b.ToTable("TicketMessages", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RemovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RoleOnTicket") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddedById"); + + b.HasIndex("TicketId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "TicketId"); + + b.ToTable("TicketParticipants", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ReadAt") + .HasColumnType("datetimeoffset"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsRead", "CreatedAt"); + + b.ToTable("Notifications", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("CommissionRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EnamadCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsMerchantOfRecord") + .HasColumnType("bit"); + + b.Property("LegalEntityType") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("MohEstablishmentPermitNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("SettlementIban") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TechnicalDirectorLicenseNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TechnicalDirectorNurseUserId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("IsActive"); + + b.HasIndex("TechnicalDirectorNurseUserId"); + + b.ToTable("PartnerCenters", "partner"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); + + b.Property("Memo") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("SourceRefId") + .HasColumnType("bigint"); + + b.Property("SourceRefType") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TransactionGroupId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("TransactionGroupId"); + + b.HasIndex("AccountType", "NurseId"); + + b.HasIndex("SourceRefType", "SourceRefId"); + + b.ToTable("LedgerEntries", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentGateway", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive", "Priority"); + + b.ToTable("PaymentGateways", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("BookingRequestId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("CustomerId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GatewayId") + .HasColumnType("bigint"); + + b.Property("GatewayReferenceCode") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayResponseCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("GatewayResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("GatewayTransactionId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsInstallment") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique() + .HasFilter("[Status] = 'succeeded' AND [BookingId] IS NOT NULL"); + + b.HasIndex("CustomerId"); + + b.HasIndex("GatewayId"); + + b.HasIndex("GatewayReferenceCode") + .IsUnique() + .HasFilter("[GatewayReferenceCode] IS NOT NULL"); + + b.HasIndex("BookingId", "Status"); + + b.HasIndex("BookingRequestId", "Status"); + + b.ToTable("PaymentTransactions", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentWebhookEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ExternalEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProviderCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReceivedAt") + .HasColumnType("datetime2"); + + b.Property("RelatedPaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("SignatureValid") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("ProviderCode", "ExternalEventId") + .IsUnique(); + + b.ToTable("PaymentWebhookEvents", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("BankAccountId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BookingCount") + .HasColumnType("int"); + + b.Property("ClawbackAppliedIrr") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrossEarningsIrr") + .HasColumnType("bigint"); + + b.Property("IbanSnapshot") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NetAmountIrr") + .HasColumnType("bigint"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TransferReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("BankAccountId"); + + b.HasIndex("BatchId"); + + b.HasIndex("NurseId"); + + b.HasIndex("Status"); + + b.ToTable("NursePayouts", "payouts", t => + { + t.HasCheckConstraint("CK_NursePayouts_NetSplit", "[NetAmountIrr] = [GrossEarningsIrr] - [ClawbackAppliedIrr] AND [GrossEarningsIrr] >= 0 AND [ClawbackAppliedIrr] >= 0 AND [NetAmountIrr] >= 0 AND [Amount] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FailureNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("InitiatedByAdminId") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutCount") + .HasColumnType("int"); + + b.Property("PeriodEnd") + .HasColumnType("date"); + + b.Property("PeriodStart") + .HasColumnType("date"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ProcessingDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("TotalAmount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InitiatedByAdminId"); + + b.HasIndex("ProcessingDate"); + + b.HasIndex("Status"); + + b.ToTable("NursePayoutBatches", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("PayoutAmountIrr") + .HasColumnType("bigint"); + + b.Property("PayoutId") + .HasColumnType("bigint"); + + b.Property("SessionId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("PayoutId"); + + b.HasIndex("SessionId"); + + b.ToTable("NursePayoutBookingLinks", "payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AmountIrr") + .HasColumnType("bigint"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("OriginalPayoutId") + .HasColumnType("bigint"); + + b.Property("RecoveredInPayoutId") + .HasColumnType("bigint"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("ResolutionNotes") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ResolvedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseId"); + + b.HasIndex("OriginalPayoutId"); + + b.HasIndex("RecoveredInPayoutId"); + + b.HasIndex("RefundId") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("NurseClawbacks", "payments"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("ApprovedByAdminId") + .HasColumnType("int"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CancellationPolicyCode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpectedCustomerRefundEta") + .HasColumnType("date"); + + b.Property("ExternalRevertReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("GatewayRefundReference") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NursePayoutRefundedIrr") + .HasColumnType("bigint"); + + b.Property("PaymentTransactionId") + .HasColumnType("bigint"); + + b.Property("PlatformFeeRefundedIrr") + .HasColumnType("bigint"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("ReasonCategory") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ReasonNotes") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("RefundChannel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RefundPercentage") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RefundPercentageApplied") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RejectedReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequestedByCustomerId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("PaymentTransactionId"); + + b.HasIndex("RequestedByCustomerId"); + + b.HasIndex("Status"); + + b.HasIndex("TicketId"); + + b.ToTable("Refunds", "payments", t => + { + t.HasCheckConstraint("CK_Refunds_LegSplit", "[Amount] = [PlatformFeeRefundedIrr] + [NursePayoutRefundedIrr] AND [Amount] >= 0 AND [PlatformFeeRefundedIrr] >= 0 AND [NursePayoutRefundedIrr] >= 0"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodyEncrypted") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseProfileId") + .HasColumnType("bigint"); + + b.Property("PatientId") + .HasColumnType("bigint"); + + b.Property("RecordedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("NurseProfileId"); + + b.HasIndex("PatientId", "RecordedAt") + .HasDatabaseName("IX_PatientCareRecords_Patient_RecordedAt"); + + b.ToTable("PatientCareRecords", "reviews"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CustomerProfileId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModeratedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModeratedById") + .HasColumnType("int"); + + b.Property("ModerationReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("ModerationStatus") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseProfileId") + .HasColumnType("bigint"); + + b.Property("Rating") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("BookingId") + .IsUnique(); + + b.HasIndex("CustomerProfileId"); + + b.HasIndex("ModerationStatus"); + + b.HasIndex("NurseProfileId", "ModerationStatus"); + + b.ToTable("Reviews", "reviews", t => + { + t.HasCheckConstraint("CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5"); + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("ReviewTagMasterId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ReviewTagMasterId"); + + b.HasIndex("ReviewId", "ReviewTagMasterId") + .IsUnique() + .HasDatabaseName("UX_ReviewTagLinks_Review_Tag"); + + b.ToTable("ReviewTagLinks", "reviews"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("LabelEn") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LabelFa") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("ReviewTagsMasters", "reviews"); + + b.HasData( + new + { + Id = 1L, + Code = "punctual", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Punctual", + LabelFa = "وقت‌شناس", + SortOrder = 1 + }, + new + { + Id = 2L, + Code = "professional", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Professional", + LabelFa = "حرفه‌ای", + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "clean", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Clean", + LabelFa = "تمیز و بهداشتی", + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "kind", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Kind", + LabelFa = "مهربان", + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "communicative", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + IsActive = true, + LabelEn = "Communicative", + LabelFa = "خوش‌برخورد", + SortOrder = 5 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AverageRating") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("CityId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DistrictId") + .HasColumnType("bigint"); + + b.Property("IsSearchable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseGender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("Price") + .HasColumnType("bigint"); + + b.Property("PriceUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ServiceCategoryId") + .HasColumnType("bigint"); + + b.Property("TotalCompletedBookings") + .HasColumnType("int"); + + b.Property("TotalReviews") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VariantId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NurseId"); + + b.HasIndex("VariantId", "CityId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity") + .HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("VariantId", "CityId", "DistrictId") + .IsUnique() + .HasDatabaseName("UX_NurseSearchIndex_Variant_City_District") + .HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL"); + + b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId") + .HasDatabaseName("IX_NurseSearchIndex_Search"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" }); + + b.ToTable("NurseSearchIndices", "search"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OwnerUserId") + .HasColumnType("int"); + + b.Property("ResolutionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("ResolvedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ReviewId") + .HasColumnType("bigint"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("Status"); + + b.HasIndex("Type"); + + b.ToTable("SupportAlerts", "ops"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedClaim") + .HasColumnType("datetime2"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("UserId"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("GeneratedCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalId") + .HasColumnType("nvarchar(max)"); + + b.Property("NationalIdVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("PhoneVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("ShahkarVerifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.HasIndex("PhoneHash") + .IsUnique() + .HasFilter("[PhoneHash] IS NOT NULL"); + + b.ToTable("Users", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("LoggedOn") + .HasColumnType("datetime2"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("IsValid") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("CreatedUserRoleDate") + .HasColumnType("datetime2"); + + b.Property("GrantedAt") + .HasColumnType("datetimeoffset"); + + b.Property("GrantedById") + .HasColumnType("int"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("GrantedById"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeviceInfo") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsRevoked") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RevokedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("RefreshTokenHash") + .IsUnique(); + + b.HasIndex("UserId", "IsRevoked"); + + b.ToTable("UserSessions", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.Property("UserId") + .HasColumnType("int"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("GeneratedTime") + .HasColumnType("datetime2"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", "usr"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("CredentialNumber") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("CredentialType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExpiresAt") + .HasColumnType("date"); + + b.Property("HolderNameSnapshot") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IssuedAt") + .HasColumnType("date"); + + b.Property("IssuingAuthority") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("VerificationMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VerificationSource") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("VerifiedByAdminId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VerifiedByAdminId"); + + b.HasIndex("NurseId", "CredentialType"); + + b.ToTable("NurseCredentials", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("InternalNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseId") + .HasColumnType("bigint"); + + b.Property("RejectedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ReviewedByAdminId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("SubmittedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SuspendedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("NurseId") + .IsUnique(); + + b.HasIndex("ReviewedByAdminId"); + + b.ToTable("NurseVerifications", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint"); + + b.Property("IntegrityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("ObjectStorageKey") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("StepId") + .HasColumnType("bigint"); + + b.Property("UploadedByUserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StepId"); + + b.HasIndex("UploadedByUserId"); + + b.ToTable("VerificationDocuments", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalResponseJson") + .HasColumnType("nvarchar(max)"); + + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("NurseVerificationId") + .HasColumnType("bigint"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StepTypeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("StepTypeId"); + + b.HasIndex("NurseVerificationId", "StepTypeId") + .IsUnique() + .HasDatabaseName("UX_VerificationSteps_Verification_StepType"); + + b.ToTable("VerificationSteps", "verif"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutomationProvider") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("IsAutomated") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("IsRequired") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsActive", "SortOrder"); + + b.ToTable("VerificationStepTypes", "verif"); + + b.HasData( + new + { + Id = 1L, + AutomationProvider = "identity_kyc_vendor", + Code = "identity_kyc", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "National-ID validity, name match and photo/video liveness via an Iranian e-KYC vendor.", + DisplayName = "Identity Verification (KYC)", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 1 + }, + new + { + Id = 2L, + AutomationProvider = "shahkar", + Code = "shahkar_match", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "Confirms the login SIM is registered to the nurse's own national ID (شاهکار).", + DisplayName = "Shahkar Phone Binding", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 2 + }, + new + { + Id = 3L, + Code = "moh_competency_license", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "پروانه صلاحیت حرفه‌ای — the MoH-mandated in-home nursing licence (bundles the criminal-record screen). Manual today.", + DisplayName = "MoH Professional Competency License", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 3 + }, + new + { + Id = 4L, + Code = "ino_membership", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "نظام پرستاری membership cross-check (ino.ir). Manual today.", + DisplayName = "Nursing Organization (INO) Membership", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 4 + }, + new + { + Id = 5L, + Code = "criminal_record", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "عدم سوء پیشینه — consent-gated, nurse-uploaded, time-limited (reverts on expiry).", + DisplayName = "Criminal Record Certificate", + IsActive = true, + IsAutomated = false, + IsRequired = true, + SortOrder = 5 + }, + new + { + Id = 6L, + AutomationProvider = "sheba", + Code = "bank_account_verification", + CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), + Description = "استعلام شبا — the payout IBAN owner's national ID must equal the verified nurse national ID.", + DisplayName = "Bank Account (IBAN) Ownership", + IsActive = true, + IsAutomated = true, + IsRequired = true, + SortOrder = 6 + }); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Analytics.SystemEvent", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Audit.AuditLog", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ActorUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Booking.Booking", "BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", null) + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", null) + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingCareInstruction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithOne("CareInstructions") + .HasForeignKey("Baya.Domain.Entities.Booking.BookingCareInstruction", "BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingRequest", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerAddress", "CustomerAddress") + .WithMany() + .HasForeignKey("CustomerAddressId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", "Patient") + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("CustomerAddress"); + + b.Navigation("Nurse"); + + b.Navigation("Patient"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", "Booking") + .WithMany("Sessions") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.VisitVerification", b => + { + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", "Session") + .WithOne("Verification") + .HasForeignKey("Baya.Domain.Entities.Booking.VisitVerification", "BookingSessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Session"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("Variants") + .HasForeignKey("ServiceCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Nurse"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany() + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue") + .WithMany() + .HasForeignKey("OptionValueId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany("Options") + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + + b.Navigation("OptionValue"); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory") + .WithMany("OptionGroups") + .HasForeignKey("ServiceCategoryId"); + + b.Navigation("ServiceCategory"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b => + { + b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup") + .WithMany("Values") + .HasForeignKey("OptionGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OptionGroup"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.HasOne("Baya.Domain.Entities.Geography.Province", "Province") + .WithMany("Cities") + .HasForeignKey("ProvinceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Province"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.District", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany("Districts") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.NurseServiceArea", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerAddress", b => + { + b.HasOne("Baya.Domain.Entities.Geography.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Geography.District", "District") + .WithMany() + .HasForeignKey("DistrictId"); + + b.Navigation("City"); + + b.Navigation("Customer"); + + b.Navigation("District"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany("BankAccounts") + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null) + .WithMany() + .HasForeignKey("PartnerCenterId"); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithOne() + .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b => + { + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer") + .WithMany("Patients") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Invoices.Invoice", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ClosedById"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OpenedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AddedById"); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Participants") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("TechnicalDirectorNurseUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payments.PaymentTransaction", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null) + .WithMany() + .HasForeignKey("BookingRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentGateway", null) + .WithMany() + .HasForeignKey("GatewayId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseBankAccount", null) + .WithMany() + .HasForeignKey("BankAccountId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayoutBatch", "Batch") + .WithMany("Payouts") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("InitiatedByAdminId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payouts.NursePayout", null) + .WithMany("BookingLinks") + .HasForeignKey("PayoutId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Booking.BookingSession", null) + .WithMany() + .HasForeignKey("SessionId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.NurseClawback", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Refunds.Refund", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null) + .WithMany() + .HasForeignKey("PaymentTransactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("RequestedByCustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.Patient", null) + .WithMany() + .HasForeignKey("PatientId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", null) + .WithMany() + .HasForeignKey("CustomerProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagLink", b => + { + b.HasOne("Baya.Domain.Entities.Reviews.Review", "Review") + .WithMany("TagLinks") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Reviews.ReviewTagMaster", "Tag") + .WithMany("Links") + .HasForeignKey("ReviewTagMasterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Review"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant") + .WithMany() + .HasForeignKey("VariantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Variant"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OwnerUserId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.RoleClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserClaim", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserLogin", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRefreshToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserRole", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("GrantedById"); + + b.HasOne("Baya.Domain.Entities.User.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserSession", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.UserToken", b => + { + b.HasOne("Baya.Domain.Entities.User.User", "User") + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseCredential", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("VerifiedByAdminId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse") + .WithMany() + .HasForeignKey("NurseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ReviewedByAdminId"); + + b.Navigation("Nurse"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationDocument", b => + { + b.HasOne("Baya.Domain.Entities.Verification.VerificationStep", "Step") + .WithMany("Documents") + .HasForeignKey("StepId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UploadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Step"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.HasOne("Baya.Domain.Entities.Verification.NurseVerification", "NurseVerification") + .WithMany("Steps") + .HasForeignKey("NurseVerificationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Verification.VerificationStepType", "StepType") + .WithMany("Steps") + .HasForeignKey("StepTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("NurseVerification"); + + b.Navigation("StepType"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b => + { + b.Navigation("CareInstructions"); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Booking.BookingSession", b => + { + b.Navigation("Verification"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b => + { + b.Navigation("Options"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b => + { + b.Navigation("OptionGroups"); + + b.Navigation("Variants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b => + { + b.Navigation("Districts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Geography.Province", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b => + { + b.Navigation("Patients"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => + { + b.Navigation("BankAccounts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => + { + b.Navigation("BookingLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBatch", b => + { + b.Navigation("Payouts"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.Review", b => + { + b.Navigation("TagLinks"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Reviews.ReviewTagMaster", b => + { + b.Navigation("Links"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.Role", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.User.User", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("Sessions"); + + b.Navigation("Tokens"); + + b.Navigation("UserRefreshTokens"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.NurseVerification", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStep", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Verification.VerificationStepType", b => + { + b.Navigation("Steps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.cs new file mode 100644 index 0000000..27f784b --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709232741_MessagingAndPartnerCenters.cs @@ -0,0 +1,332 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class MessagingAndPartnerCenters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "partner"); + + migrationBuilder.EnsureSchema( + name: "messaging"); + + migrationBuilder.CreateTable( + name: "PartnerCenters", + schema: "partner", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + LegalEntityType = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: true), + MohEstablishmentPermitNo = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + TechnicalDirectorNurseUserId = table.Column(type: "int", nullable: true), + TechnicalDirectorLicenseNo = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + EnamadCode = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + SettlementIban = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + IsMerchantOfRecord = table.Column(type: "bit", nullable: false), + CommissionRate = table.Column(type: "decimal(5,4)", precision: 5, scale: 4, nullable: true), + AdminUserId = table.Column(type: "int", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + VerifiedAt = table.Column(type: "datetimeoffset", nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PartnerCenters", x => x.Id); + table.ForeignKey( + name: "FK_PartnerCenters_Users_AdminUserId", + column: x => x.AdminUserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PartnerCenters_Users_TechnicalDirectorNurseUserId", + column: x => x.TechnicalDirectorNurseUserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + }); + + migrationBuilder.CreateTable( + name: "Tickets", + schema: "messaging", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ReferenceCode = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Subject = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: true), + Status = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + Category = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + BookingId = table.Column(type: "bigint", nullable: true), + RefundId = table.Column(type: "bigint", nullable: true), + OpenedById = table.Column(type: "int", nullable: false), + ClosedAt = table.Column(type: "datetimeoffset", nullable: true), + ClosedById = table.Column(type: "int", nullable: true), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Tickets", x => x.Id); + table.ForeignKey( + name: "FK_Tickets_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Tickets_Refunds_RefundId", + column: x => x.RefundId, + principalSchema: "payments", + principalTable: "Refunds", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_Tickets_Users_ClosedById", + column: x => x.ClosedById, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + table.ForeignKey( + name: "FK_Tickets_Users_OpenedById", + column: x => x.OpenedById, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TicketMessages", + schema: "messaging", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TicketId = table.Column(type: "bigint", nullable: false), + SenderId = table.Column(type: "int", nullable: false), + Body = table.Column(type: "nvarchar(4000)", maxLength: 4000, nullable: false), + IsInternal = table.Column(type: "bit", nullable: false, defaultValue: false), + SentAt = table.Column(type: "datetimeoffset", nullable: false), + DeletedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TicketMessages", x => x.Id); + table.ForeignKey( + name: "FK_TicketMessages_Tickets_TicketId", + column: x => x.TicketId, + principalSchema: "messaging", + principalTable: "Tickets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TicketMessages_Users_SenderId", + column: x => x.SenderId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TicketParticipants", + schema: "messaging", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TicketId = table.Column(type: "bigint", nullable: false), + UserId = table.Column(type: "int", nullable: false), + RoleOnTicket = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + AddedById = table.Column(type: "int", nullable: true), + RemovedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + ModifiedAt = table.Column(type: "datetimeoffset", nullable: true), + CreatedById = table.Column(type: "int", nullable: true), + ModifiedById = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TicketParticipants", x => x.Id); + table.ForeignKey( + name: "FK_TicketParticipants_Tickets_TicketId", + column: x => x.TicketId, + principalSchema: "messaging", + principalTable: "Tickets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TicketParticipants_Users_AddedById", + column: x => x.AddedById, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId"); + table.ForeignKey( + name: "FK_TicketParticipants_Users_UserId", + column: x => x.UserId, + principalSchema: "usr", + principalTable: "Users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_NurseProfiles_PartnerCenterId", + schema: "usr", + table: "NurseProfiles", + column: "PartnerCenterId"); + + migrationBuilder.CreateIndex( + name: "IX_PartnerCenters_AdminUserId", + schema: "partner", + table: "PartnerCenters", + column: "AdminUserId"); + + migrationBuilder.CreateIndex( + name: "IX_PartnerCenters_IsActive", + schema: "partner", + table: "PartnerCenters", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_PartnerCenters_TechnicalDirectorNurseUserId", + schema: "partner", + table: "PartnerCenters", + column: "TechnicalDirectorNurseUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TicketMessages_SenderId", + schema: "messaging", + table: "TicketMessages", + column: "SenderId"); + + migrationBuilder.CreateIndex( + name: "IX_TicketMessages_TicketId_SentAt", + schema: "messaging", + table: "TicketMessages", + columns: new[] { "TicketId", "SentAt" }); + + migrationBuilder.CreateIndex( + name: "IX_TicketParticipants_AddedById", + schema: "messaging", + table: "TicketParticipants", + column: "AddedById"); + + migrationBuilder.CreateIndex( + name: "IX_TicketParticipants_TicketId_UserId", + schema: "messaging", + table: "TicketParticipants", + columns: new[] { "TicketId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TicketParticipants_UserId_TicketId", + schema: "messaging", + table: "TicketParticipants", + columns: new[] { "UserId", "TicketId" }); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_BookingId", + schema: "messaging", + table: "Tickets", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_ClosedById", + schema: "messaging", + table: "Tickets", + column: "ClosedById"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_OpenedById", + schema: "messaging", + table: "Tickets", + column: "OpenedById"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_ReferenceCode", + schema: "messaging", + table: "Tickets", + column: "ReferenceCode", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_RefundId", + schema: "messaging", + table: "Tickets", + column: "RefundId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_Status", + schema: "messaging", + table: "Tickets", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_Status_CreatedAt", + schema: "messaging", + table: "Tickets", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.AddForeignKey( + name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId", + schema: "usr", + table: "NurseProfiles", + column: "PartnerCenterId", + principalSchema: "partner", + principalTable: "PartnerCenters", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_NurseProfiles_PartnerCenters_PartnerCenterId", + schema: "usr", + table: "NurseProfiles"); + + migrationBuilder.DropTable( + name: "PartnerCenters", + schema: "partner"); + + migrationBuilder.DropTable( + name: "TicketMessages", + schema: "messaging"); + + migrationBuilder.DropTable( + name: "TicketParticipants", + schema: "messaging"); + + migrationBuilder.DropTable( + name: "Tickets", + schema: "messaging"); + + migrationBuilder.DropIndex( + name: "IX_NurseProfiles_PartnerCenterId", + schema: "usr", + table: "NurseProfiles"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 6fad541..8d3e788 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2716,6 +2716,8 @@ namespace Baya.Infrastructure.Persistence.Migrations b.HasKey("Id"); + b.HasIndex("PartnerCenterId"); + b.HasIndex("UserId") .IsUnique(); @@ -2885,6 +2887,182 @@ namespace Baya.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BookingId") + .HasColumnType("bigint"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ClosedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ClosedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("OpenedById") + .HasColumnType("int"); + + b.Property("ReferenceCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RefundId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.HasIndex("ClosedById"); + + b.HasIndex("OpenedById"); + + b.HasIndex("ReferenceCode") + .IsUnique(); + + b.HasIndex("RefundId"); + + b.HasIndex("Status"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Tickets", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("IsInternal") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("SenderId") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetimeoffset"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("TicketId", "SentAt"); + + b.ToTable("TicketMessages", "messaging"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AddedById") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("RemovedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RoleOnTicket") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("TicketId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddedById"); + + b.HasIndex("TicketId", "UserId") + .IsUnique(); + + b.HasIndex("UserId", "TicketId"); + + b.ToTable("TicketParticipants", "messaging"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => { b.Property("Id") @@ -2930,6 +3108,85 @@ namespace Baya.Infrastructure.Persistence.Migrations b.ToTable("Notifications", "ops"); }); + modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("CommissionRate") + .HasPrecision(5, 4) + .HasColumnType("decimal(5,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("int"); + + b.Property("DeletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("EnamadCode") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsMerchantOfRecord") + .HasColumnType("bit"); + + b.Property("LegalEntityType") + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + b.Property("ModifiedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedById") + .HasColumnType("int"); + + b.Property("MohEstablishmentPermitNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("SettlementIban") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TechnicalDirectorLicenseNo") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("TechnicalDirectorNurseUserId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("IsActive"); + + b.HasIndex("TechnicalDirectorNurseUserId"); + + b.ToTable("PartnerCenters", "partner"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => { b.Property("Id") @@ -5078,6 +5335,10 @@ namespace Baya.Infrastructure.Persistence.Migrations modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b => { + b.HasOne("Baya.Domain.Entities.PartnerCenters.PartnerCenter", null) + .WithMany() + .HasForeignKey("PartnerCenterId"); + b.HasOne("Baya.Domain.Entities.User.User", "User") .WithOne() .HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId") @@ -5107,6 +5368,65 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.HasOne("Baya.Domain.Entities.Booking.Booking", null) + .WithMany() + .HasForeignKey("BookingId"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("ClosedById"); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("OpenedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Refunds.Refund", null) + .WithMany() + .HasForeignKey("RefundId"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketMessage", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("Baya.Domain.Entities.Messaging.TicketParticipant", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AddedById"); + + b.HasOne("Baya.Domain.Entities.Messaging.Ticket", "Ticket") + .WithMany("Participants") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ticket"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b => { b.HasOne("Baya.Domain.Entities.User.User", null) @@ -5116,6 +5436,19 @@ namespace Baya.Infrastructure.Persistence.Migrations .IsRequired(); }); + modelBuilder.Entity("Baya.Domain.Entities.PartnerCenters.PartnerCenter", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("TechnicalDirectorNurseUserId"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Payments.LedgerEntry", b => { b.HasOne("Baya.Domain.Entities.Booking.Booking", null) @@ -5530,6 +5863,13 @@ namespace Baya.Infrastructure.Persistence.Migrations b.Navigation("BankAccounts"); }); + modelBuilder.Entity("Baya.Domain.Entities.Messaging.Ticket", b => + { + b.Navigation("Messages"); + + b.Navigation("Participants"); + }); + modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayout", b => { b.Navigation("BookingLinks"); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs index e2b7731..fdc60e9 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -29,6 +29,8 @@ public class UnitOfWork : IUnitOfWork public IPayoutRepository PayoutRepository { get; } public IReviewRepository ReviewRepository { get; } public IPatientCareRecordRepository PatientCareRecordRepository { get; } + public ITicketRepository TicketRepository { get; } + public IPartnerCenterRepository PartnerCenterRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -56,6 +58,8 @@ public class UnitOfWork : IUnitOfWork PayoutRepository = new PayoutRepository(_db); ReviewRepository = new ReviewRepository(_db); PatientCareRecordRepository = new PatientCareRecordRepository(_db); + TicketRepository = new TicketRepository(_db); + PartnerCenterRepository = new PartnerCenterRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs new file mode 100644 index 0000000..6eed0b1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs @@ -0,0 +1,145 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.PartnerCenters; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Invoices; +using Baya.Domain.Entities.PartnerCenters; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class PartnerCenterRepository : BaseAsyncRepository, IPartnerCenterRepository +{ + private const int DashboardNurseCap = 50; + + public PartnerCenterRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(PartnerCenter center, CancellationToken cancellationToken) => base.AddAsync(center); + + public Task GetTrackedAsync(long centerId, CancellationToken cancellationToken) + => Entities.FirstOrDefaultAsync(c => c.Id == centerId, cancellationToken); + + public Task ExistsAsync(long centerId, CancellationToken cancellationToken) + => Entities.AnyAsync(c => c.Id == centerId, cancellationToken); + + public async Task GetDetailAsync(long centerId, CancellationToken cancellationToken) + { + // Project (the converter decrypts settlement_iban here); the plaintext IBAN is masked in memory below and + // never leaves this method. + var raw = await Entities.AsNoTracking() + .Where(c => c.Id == centerId) + .Select(c => new + { + c.Id, c.Name, c.LegalEntityType, c.MohEstablishmentPermitNo, c.TechnicalDirectorNurseUserId, + c.TechnicalDirectorLicenseNo, c.EnamadCode, c.SettlementIban, c.IsMerchantOfRecord, c.CommissionRate, + c.AdminUserId, c.IsActive, c.VerifiedAt, c.CreatedAt, + SponsoredCount = DbContext.Set().Count(n => n.PartnerCenterId == c.Id) + }) + .FirstOrDefaultAsync(cancellationToken); + + if (raw is null) + return null; + + return new PartnerCenterDetailDto( + raw.Id, raw.Name, raw.LegalEntityType, raw.MohEstablishmentPermitNo, raw.TechnicalDirectorNurseUserId, + raw.TechnicalDirectorLicenseNo, raw.EnamadCode, + string.IsNullOrEmpty(raw.SettlementIban) ? null : Mask.IbanTail(raw.SettlementIban), + raw.IsMerchantOfRecord, raw.CommissionRate, raw.AdminUserId, raw.IsActive, raw.VerifiedAt, + raw.SponsoredCount, raw.CreatedAt); + } + + public async Task> ListAsync(bool? isActive, int page, int pageSize, CancellationToken cancellationToken) + { + var query = Entities.AsNoTracking().AsQueryable(); + if (isActive is { } active) + query = query.Where(c => c.IsActive == active); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(c => c.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(c => new PartnerCenterListItemDto( + c.Id, c.Name, c.LegalEntityType, c.IsMerchantOfRecord, c.IsActive, c.VerifiedAt, + DbContext.Set().Count(n => n.PartnerCenterId == c.Id), + c.AdminUserId)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task ResolveCenterForBookingAsync(long bookingId, CancellationToken cancellationToken) + { + var booking = await DbContext.Set().AsNoTracking() + .Where(b => b.Id == bookingId) + .Select(b => new { b.NurseId }) + .FirstOrDefaultAsync(cancellationToken); + + if (booking is null) + return null; + + var centerId = await DbContext.Set().AsNoTracking() + .Where(n => n.Id == booking.NurseId) + .Select(n => n.PartnerCenterId) + .FirstOrDefaultAsync(cancellationToken); + + if (centerId is { } id) + { + var center = await Entities.AsNoTracking() + .Where(c => c.Id == id) + .Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord }) + .FirstOrDefaultAsync(cancellationToken); + + // Merchant-of-record center → it is the invoice issuer + settlement target. Otherwise the platform + // issues (a non-MoR sponsor does not change the issuer). + if (center is { IsMerchantOfRecord: true }) + return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.PartnerCenter, center.Id, center.Name, true); + } + + return new CenterForBookingDto(bookingId, InvoiceIssuingEntityType.Platform, null, null, false); + } + + public Task GetAdminUserIdAsync(long centerId, CancellationToken cancellationToken) + => Entities.AsNoTracking() + .Where(c => c.Id == centerId) + .Select(c => (int?)c.AdminUserId) + .FirstOrDefaultAsync(cancellationToken); + + public async Task GetDashboardAsync(long centerId, CancellationToken cancellationToken) + { + var center = await Entities.AsNoTracking() + .Where(c => c.Id == centerId) + .Select(c => new { c.Id, c.Name, c.IsMerchantOfRecord, c.IsActive, c.SettlementIban }) + .FirstOrDefaultAsync(cancellationToken); + + if (center is null) + return null; + + var sponsoredNurseCount = await DbContext.Set().AsNoTracking() + .CountAsync(n => n.PartnerCenterId == centerId, cancellationToken); + + var sponsoredNurses = await DbContext.Set().AsNoTracking() + .Where(n => n.PartnerCenterId == centerId) + .OrderBy(n => n.Id) + .Take(DashboardNurseCap) + .Select(n => new SponsoredNurseDto(n.Id, n.UserId, n.IsVerified, n.AverageRating, n.TotalCompletedBookings)) + .ToListAsync(cancellationToken); + + var sponsoredBookingCount = await DbContext.Set().AsNoTracking() + .CountAsync(b => DbContext.Set().Any(n => n.Id == b.NurseId && n.PartnerCenterId == centerId), cancellationToken); + + var invoiceCount = await DbContext.Set().AsNoTracking() + .CountAsync(i => i.PartnerCenterId == centerId, cancellationToken); + + return new CenterDashboardDto( + center.Id, center.Name, center.IsMerchantOfRecord, center.IsActive, + string.IsNullOrEmpty(center.SettlementIban) ? null : Mask.IbanTail(center.SettlementIban), + sponsoredNurseCount, sponsoredBookingCount, invoiceCount, sponsoredNurses); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/TicketRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/TicketRepository.cs new file mode 100644 index 0000000..e6e3ba1 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/TicketRepository.cs @@ -0,0 +1,158 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Messaging; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Messaging; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class TicketRepository : BaseAsyncRepository, ITicketRepository +{ + public TicketRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(Ticket ticket, CancellationToken cancellationToken) => base.AddAsync(ticket); + + public async Task AddMessageAsync(TicketMessage message, CancellationToken cancellationToken) + => await DbContext.Set().AddAsync(message, cancellationToken); + + public async Task AddParticipantAsync(TicketParticipant participant, CancellationToken cancellationToken) + => await DbContext.Set().AddAsync(participant, cancellationToken); + + public Task GetTrackedAsync(long ticketId, CancellationToken cancellationToken) + => Entities.FirstOrDefaultAsync(t => t.Id == ticketId, cancellationToken); + + public Task ReferenceCodeExistsAsync(string referenceCode, CancellationToken cancellationToken) + => Entities.AnyAsync(t => t.ReferenceCode == referenceCode, cancellationToken); + + public Task GetParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken) + => DbContext.Set().FirstOrDefaultAsync(p => p.TicketId == ticketId && p.UserId == userId, cancellationToken); + + public Task IsActiveParticipantAsync(long ticketId, int userId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .AnyAsync(p => p.TicketId == ticketId && p.UserId == userId && p.RemovedAt == null, cancellationToken); + + public Task CoordinationTicketExistsForBookingAsync(long bookingId, CancellationToken cancellationToken) + => Entities.AnyAsync(t => t.BookingId == bookingId && t.Category == TicketCategory.Coordination, cancellationToken); + + public async Task GetBookingPartyUserIdsAsync(long bookingId, CancellationToken cancellationToken) + { + var parties = await DbContext.Set().AsNoTracking() + .Where(b => b.Id == bookingId) + .Select(b => new + { + CustomerUserId = DbContext.Set() + .Where(c => c.Id == b.CustomerId).Select(c => (int?)c.UserId).FirstOrDefault(), + NurseUserId = DbContext.Set() + .Where(n => n.Id == b.NurseId).Select(n => (int?)n.UserId).FirstOrDefault() + }) + .FirstOrDefaultAsync(cancellationToken); + + if (parties is null || parties.CustomerUserId is not { } customerUserId || parties.NurseUserId is not { } nurseUserId) + return null; + + return new BookingPartyUserIds(customerUserId, nurseUserId); + } + + public Task GetHeaderAsync(long ticketId, CancellationToken cancellationToken) + => Entities.AsNoTracking() + .Where(t => t.Id == ticketId) + .Select(t => new TicketHeaderDto( + t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.OpenedById, t.ClosedAt)) + .FirstOrDefaultAsync(cancellationToken); + + public Task IsUserPartyToBookingAsync(long bookingId, int userId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(b => b.Id == bookingId) + .AnyAsync(b => + DbContext.Set().Any(c => c.Id == b.CustomerId && c.UserId == userId) || + DbContext.Set().Any(n => n.Id == b.NurseId && n.UserId == userId), + cancellationToken); + + public async Task> GetActiveParticipantsAsync(long ticketId, CancellationToken cancellationToken) + => await DbContext.Set().AsNoTracking() + .Where(p => p.TicketId == ticketId && p.RemovedAt == null) + .OrderBy(p => p.Id) + .Select(p => new TicketParticipantDto(p.UserId, p.RoleOnTicket)) + .ToListAsync(cancellationToken); + + public async Task> GetActiveParticipantUserIdsAsync(long ticketId, CancellationToken cancellationToken) + => await DbContext.Set().AsNoTracking() + .Where(p => p.TicketId == ticketId && p.RemovedAt == null) + .Select(p => p.UserId) + .ToListAsync(cancellationToken); + + public async Task> GetMessagesAsync(long ticketId, bool includeInternal, CancellationToken cancellationToken) + { + var query = DbContext.Set().AsNoTracking().Where(m => m.TicketId == ticketId); + + // The hard visibility boundary: the user view strips every internal message in the projection. + if (!includeInternal) + query = query.Where(m => !m.IsInternal); + + // Order by the monotonic identity, not sent_at: it matches send order and, unlike a DateTimeOffset + // ORDER BY, the SQLite test provider can translate it. + return await query + .OrderBy(m => m.Id) + .Select(m => new TicketMessageDto(m.Id, m.SenderId, m.Body, m.IsInternal, m.SentAt)) + .ToListAsync(cancellationToken); + } + + public async Task> ListMyTicketsAsync( + int userId, string? status, string? referenceCode, int page, int pageSize, CancellationToken cancellationToken) + { + var participantTickets = DbContext.Set().AsNoTracking() + .Where(p => p.UserId == userId && p.RemovedAt == null) + .Select(p => p.TicketId); + + var query = Entities.AsNoTracking().Where(t => participantTickets.Contains(t.Id)); + query = ApplyTicketFilters(query, status, referenceCode); + + return await PageAsync(query, page, pageSize, cancellationToken); + } + + public async Task> ListForAdminAsync( + string? status, string? category, string? referenceCode, long? bookingId, long? refundId, + int page, int pageSize, CancellationToken cancellationToken) + { + var query = Entities.AsNoTracking().AsQueryable(); + query = ApplyTicketFilters(query, status, referenceCode); + + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(t => t.Category == category); + if (bookingId is { } b) + query = query.Where(t => t.BookingId == b); + if (refundId is { } r) + query = query.Where(t => t.RefundId == r); + + return await PageAsync(query, page, pageSize, cancellationToken); + } + + private static IQueryable ApplyTicketFilters(IQueryable query, string? status, string? referenceCode) + { + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(t => t.Status == status); + if (!string.IsNullOrWhiteSpace(referenceCode)) + query = query.Where(t => t.ReferenceCode == referenceCode); + return query; + } + + private static async Task> PageAsync( + IQueryable query, int page, int pageSize, CancellationToken cancellationToken) + { + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(t => t.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(t => new TicketSummaryDto( + t.Id, t.ReferenceCode, t.Subject, t.Status, t.Category, t.BookingId, t.RefundId, t.CreatedAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Tests/Baya.Test.Api/MessagingApiTests.cs b/server/src/Tests/Baya.Test.Api/MessagingApiTests.cs new file mode 100644 index 0000000..91d099a --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/MessagingApiTests.cs @@ -0,0 +1,107 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Baya.Application.Contracts.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +/// +/// HTTP-pipeline coverage for the b15 ticket system. The load-bearing assertion is the is_internal hard +/// boundary: an admin internal note is stripped from the user thread view and present in the admin thread view. +/// Also covers participant uniqueness (a duplicate add is a 409, not a 500) and auth gates. +/// +public class MessagingApiTests(BayaApiFactory factory) : IClassFixture +{ + private static async Task UserIdAsync(BayaApiFactory factory, string phone) + { + using var scope = factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + var user = await users.GetUserByPhoneNumber(phone); + return user!.Id; + } + + [Fact] + public async Task Tickets_Unauthenticated_Returns401() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/tickets"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task InternalNote_IsHiddenInUserView_ShownInAdminView() + { + var user = factory.CreateClient(); + var login = await AuthTestClient.LoginAsync(factory, user, "09120010001"); + AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!); + + // Open a pure support ticket (no booking/refund link) and post a normal message. + var open = await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "Help", body = "My first message" }); + Assert.Equal(HttpStatusCode.OK, open.StatusCode); + var opened = await AuthTestClient.ReadDataAsync(open); + var ticketId = opened.GetProperty("ticketId").GetInt64(); + Assert.StartsWith("TKT-", opened.GetProperty("referenceCode").GetString()); + + // Admin (staff) posts an internal note. + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09120010009"); + var internalPost = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/messages", new { body = "internal only", isInternal = true }); + Assert.Equal(HttpStatusCode.OK, internalPost.StatusCode); + + // User view — the internal note is absent (only the original message). + var userThread = await AuthTestClient.ReadDataAsync(await user.GetAsync($"/api/v1/tickets/{ticketId}")); + var userMessages = userThread.GetProperty("messages").EnumerateArray().ToList(); + Assert.Single(userMessages); + Assert.All(userMessages, m => Assert.False(m.GetProperty("isInternal").GetBoolean())); + + // Admin view — the internal note IS present. + var adminThread = await AuthTestClient.ReadDataAsync(await admin.GetAsync($"/api/v1/admin/tickets/{ticketId}")); + var adminMessages = adminThread.GetProperty("messages").EnumerateArray().ToList(); + Assert.Equal(2, adminMessages.Count); + Assert.Contains(adminMessages, m => m.GetProperty("isInternal").GetBoolean()); + } + + [Fact] + public async Task NonAdmin_CannotSetInternal() + { + var user = factory.CreateClient(); + var login = await AuthTestClient.LoginAsync(factory, user, "09120020001"); + AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!); + + var opened = await AuthTestClient.ReadDataAsync( + await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "x", body = "hi" })); + var ticketId = opened.GetProperty("ticketId").GetInt64(); + + var response = await user.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/messages", new { body = "sneaky", isInternal = true }); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task AddParticipant_DuplicateIsConflict_NotServerError() + { + var user = factory.CreateClient(); + var login = await AuthTestClient.LoginAsync(factory, user, "09120030001"); + AuthTestClient.UseBearer(user, login.GetProperty("accessToken").GetString()!); + + var opened = await AuthTestClient.ReadDataAsync( + await user.PostAsJsonAsync("/api/v1/tickets", new { category = "support", subject = "x", body = "hi" })); + var ticketId = opened.GetProperty("ticketId").GetInt64(); + + // A second user to attach. + await AuthTestClient.CreateUserWithOtpCodeAsync(factory, "09120030002"); + var otherUserId = await UserIdAsync(factory, "09120030002"); + + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09120030009"); + + var first = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/participants", new { userId = otherUserId }); + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + + var duplicate = await admin.PostAsJsonAsync($"/api/v1/tickets/{ticketId}/participants", new { userId = otherUserId }); + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode); + + var removed = await admin.DeleteAsync($"/api/v1/tickets/{ticketId}/participants/{otherUserId}"); + Assert.Equal(HttpStatusCode.OK, removed.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Api/PartnerCentersApiTests.cs b/server/src/Tests/Baya.Test.Api/PartnerCentersApiTests.cs new file mode 100644 index 0000000..4fdbef5 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/PartnerCentersApiTests.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Net.Http.Json; +using Baya.Application.Contracts.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Api; + +/// +/// HTTP-pipeline coverage for the b15 partner-center console. The load-bearing assertion is that the settlement +/// IBAN is returned masked (last 4), never plaintext, on both the create response and the detail read. +/// Also covers the admin RBAC gate. +/// +public class PartnerCentersApiTests(BayaApiFactory factory) : IClassFixture +{ + private const string FullIban = "IR062960000000100324200001"; + + private static async Task UserIdAsync(BayaApiFactory factory, string phone) + { + using var scope = factory.Services.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService(); + return (await users.GetUserByPhoneNumber(phone))!.Id; + } + + [Fact] + public async Task List_Unauthenticated_Returns401() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/admin/partner-centers"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task NonAdmin_IsForbidden() + { + var customer = factory.CreateClient(); + var login = await AuthTestClient.LoginAsync(factory, customer, "09120040001"); + AuthTestClient.UseBearer(customer, login.GetProperty("accessToken").GetString()!); + + var response = await customer.GetAsync("/api/v1/admin/partner-centers"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task CreateMerchantOfRecord_MasksSettlementIban() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09120040009"); + + var create = await admin.PostAsJsonAsync("/api/v1/admin/partner-centers", new + { + name = "Asanism Center", + legalEntityType = "llc", + mohEstablishmentPermitNo = "MOH-12345", + enamadCode = "EN-999", + settlementIban = FullIban, + isMerchantOfRecord = true, + commissionRate = 0.05m, + adminUserId = await UserIdAsync(factory, "09120040009") + }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + + var created = await AuthTestClient.ReadDataAsync(create); + var centerId = created.GetProperty("id").GetInt64(); + var masked = created.GetProperty("settlementIbanMasked").GetString(); + + Assert.NotNull(masked); + Assert.DoesNotContain("0032420", masked); // no interior digits + Assert.EndsWith("0001", masked); + Assert.NotEqual(FullIban, masked); + + // The detail read masks it too — the plaintext IBAN is never serialized. + var detail = await AuthTestClient.ReadDataAsync(await admin.GetAsync($"/api/v1/admin/partner-centers/{centerId}")); + Assert.EndsWith("0001", detail.GetProperty("settlementIbanMasked").GetString()); + Assert.False(detail.GetProperty("isActive").GetBoolean()); // created inactive until verified + } + + [Fact] + public async Task Verify_ActivatesTheCenter() + { + var admin = factory.CreateClient(); + await AdminTestClient.AuthenticateAsync(factory, admin, "09120050009"); + + var created = await AuthTestClient.ReadDataAsync(await admin.PostAsJsonAsync("/api/v1/admin/partner-centers", new + { + name = "Center B", + mohEstablishmentPermitNo = "MOH-55555", + isMerchantOfRecord = false, + adminUserId = await UserIdAsync(factory, "09120050009") + })); + var centerId = created.GetProperty("id").GetInt64(); + + var verify = await admin.PostAsync($"/api/v1/admin/partner-centers/{centerId}/verify", null); + Assert.Equal(HttpStatusCode.OK, verify.StatusCode); + + var detail = await AuthTestClient.ReadDataAsync(verify); + Assert.True(detail.GetProperty("isActive").GetBoolean()); + Assert.True(detail.GetProperty("verifiedAt").ValueKind is System.Text.Json.JsonValueKind.String); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs index a6044cc..8ad1e06 100644 --- a/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Bnpl/BnplHandlerTests.cs @@ -129,7 +129,7 @@ public class BnplHandlerTests private static SettleBnplOrderCommandHandler SettleHandler(BnplTestHost host, decimal commissionRate) => new(host.UnitOfWork, host.Resolver(commissionRate), host.Config(), host.Serializer, - host.Settlement, host.Lock, host.Clock(Now), host.Notifications()); + host.Settlement, host.Lock, host.Clock(Now), host.Notifications(), TestSenders.WithTicketHooks()); private static long Leg(IReadOnlyList legs, string account, string direction) => legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr); diff --git a/server/src/Tests/Baya.Test.Foundation/PartnerCenters/CenterForBookingTests.cs b/server/src/Tests/Baya.Test.Foundation/PartnerCenters/CenterForBookingTests.cs new file mode 100644 index 0000000..34ac2a6 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/PartnerCenters/CenterForBookingTests.cs @@ -0,0 +1,139 @@ +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Invoices; +using Baya.Domain.Entities.PartnerCenters; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.PartnerCenters; + +/// +/// The merchant-of-record resolver: GetCenterForBooking must return the sponsoring center as the invoice +/// issuer + settlement target ONLY when that center is merchant-of-record, and platform otherwise (incl. +/// an unsponsored nurse). Foreign keys are disabled so the test can seed just the three rows the join touches. +/// +public sealed class CenterForBookingTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ApplicationDbContext _db; + private readonly UnitOfWork _unitOfWork; + + public CenterForBookingTests() + { + _connection = new SqliteConnection("DataSource=:memory:;Foreign Keys=False"); + _connection.Open(); + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + _db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + _db.Database.EnsureCreated(); + _unitOfWork = new UnitOfWork(_db); + } + + private long SeedNurse(long? partnerCenterId) + { + var nurse = new NurseProfile + { + UserId = 0, + Bio = string.Empty, + EducationLevel = string.Empty, + EducationField = string.Empty, + SpecializationsJson = "[]", + PartnerCenterId = partnerCenterId + }; + _db.Set().Add(nurse); + _db.SaveChanges(); + return nurse.Id; + } + + private long SeedBooking(long nurseId) + { + var booking = new BookingEntity + { + BookingRequestId = 0, + CustomerId = 0, + NurseId = nurseId, + PatientId = 0, + VariantId = 0, + CustomerAddressId = 0, + VariantSnapshotJson = "{}", + AddressSnapshotJson = "{}", + GrossPriceIrr = 0, + BalinyaarCommissionIrr = 0, + NursePayoutAmount = 0 + }; + _db.Set().Add(booking); + _db.SaveChanges(); + return booking.Id; + } + + private long SeedCenter(bool merchantOfRecord) + { + var center = new PartnerCenter + { + Name = "Center", + MohEstablishmentPermitNo = "MOH-1", + AdminUserId = 0, + IsMerchantOfRecord = merchantOfRecord + }; + _db.Set().Add(center); + _db.SaveChanges(); + return center.Id; + } + + [Fact] + public async Task MerchantOfRecordCenter_IsTheIssuerAndSettlementTarget() + { + var centerId = SeedCenter(merchantOfRecord: true); + var nurseId = SeedNurse(centerId); + var bookingId = SeedBooking(nurseId); + + var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(InvoiceIssuingEntityType.PartnerCenter, result!.IssuingEntityType); + Assert.Equal(centerId, result.PartnerCenterId); + Assert.True(result.IsMerchantOfRecord); + } + + [Fact] + public async Task NonMerchantSponsor_FallsBackToPlatform() + { + var centerId = SeedCenter(merchantOfRecord: false); + var nurseId = SeedNurse(centerId); + var bookingId = SeedBooking(nurseId); + + var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(InvoiceIssuingEntityType.Platform, result!.IssuingEntityType); + Assert.Null(result.PartnerCenterId); + } + + [Fact] + public async Task UnsponsoredNurse_IsPlatform() + { + var nurseId = SeedNurse(partnerCenterId: null); + var bookingId = SeedBooking(nurseId); + + var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(bookingId, CancellationToken.None); + + Assert.NotNull(result); + Assert.Equal(InvoiceIssuingEntityType.Platform, result!.IssuingEntityType); + Assert.Null(result.PartnerCenterId); + } + + [Fact] + public async Task MissingBooking_ReturnsNull() + { + var result = await _unitOfWork.PartnerCenterRepository.ResolveCenterForBookingAsync(999, CancellationToken.None); + Assert.Null(result); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs index cf7c66b..f01ee42 100644 --- a/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Payments/InitiatePaymentTests.cs @@ -43,7 +43,7 @@ public class InitiatePaymentTests var first = await Initiate(host).Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( - host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(first.Result.TransactionId), CancellationToken.None); // A repeat initiate for an already-paid booking is a 409, not a second attempt. diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs index 82f7d35..7d7df39 100644 --- a/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Payments/NursePayableBalanceTests.cs @@ -21,7 +21,7 @@ public class NursePayableBalanceTests var init = await initiate.Handle(new InitiatePaymentCommand(requestId, null), CancellationToken.None); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( - host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); await confirm.Handle(new ConfirmPaymentAndPostLedgerCommand(init.Result.TransactionId), CancellationToken.None); var query = new GetNursePayableBalanceQueryHandler(host.AsNurse(), host.UnitOfWork); diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs index 9d36cf5..63979d4 100644 --- a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentConfirmTests.cs @@ -18,7 +18,7 @@ public class PaymentConfirmTests => new(host.AsCustomer(), host.UnitOfWork, host.PaymentProvider, host.Clock(Now)); private static ConfirmPaymentAndPostLedgerCommandHandler Confirm(PaymentsTestHost host) - => new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + => new(host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); private static async Task InitiatePendingAsync(PaymentsTestHost host, long requestId) { diff --git a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs index 8b96715..6180e60 100644 --- a/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Payments/PaymentWebhookTests.cs @@ -43,7 +43,7 @@ public class PaymentWebhookTests var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( - host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var handler = new HandlePaymentWebhookCommandHandler( SenderRoutingConfirmTo(confirm), host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); @@ -67,7 +67,7 @@ public class PaymentWebhookTests var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( - host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var sender = SenderRoutingConfirmTo(confirm); var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); @@ -91,7 +91,7 @@ public class PaymentWebhookTests var (_, reference) = await SeedPendingAsync(host); var confirm = new ConfirmPaymentAndPostLedgerCommandHandler( - host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications()); + host.UnitOfWork, host.Config(), host.Clock(Now), host.PaymentProvider, host.Settlement, host.Serializer, host.Notifications(), TestSenders.WithTicketHooks()); var sender = SenderRoutingConfirmTo(confirm); var handler = new HandlePaymentWebhookCommandHandler(sender, host.UnitOfWork, host.Verifier, host.Lock, host.Clock(Now)); diff --git a/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs index 7fbf756..2535805 100644 --- a/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Refunds/RefundHandlerTests.cs @@ -16,7 +16,7 @@ public class RefundHandlerTests => new( host.UnitOfWork, host.Lock(), host.Config(), host.Clock(Now), host.AsAdmin(), host.Card(), host.Bnpl(), host.PayoutStatus(nursePaid), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), TestSenders.WithTicketHooks()); private static CreateRefundCommand FullRefund(long bookingId) => new(bookingId, TicketId: null, RefundPercentage: 1m, null, null, "customer_request", null, null, null); diff --git a/server/src/Tests/Baya.Test.Foundation/TestSenders.cs b/server/src/Tests/Baya.Test.Foundation/TestSenders.cs new file mode 100644 index 0000000..af346a4 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/TestSenders.cs @@ -0,0 +1,27 @@ +using Baya.Application.Features.Messaging.Commands.AutoCreateCoordinationTicket; +using Baya.Application.Features.Messaging.Commands.OpenTicket; +using Baya.Application.Models.Common; +using Baya.Application.Models.Messaging; +using Mediator; +using NSubstitute; + +namespace Baya.Test.Foundation; + +/// +/// Builds an substitute that satisfies the b15 ticket hooks the money-path handlers now +/// dispatch — OpenTicket (the refund's auto-anchored ticket) and AutoCreateCoordinationTicket (on +/// booking confirmation) — so foundation unit tests can construct those handlers without wiring the real mediator. +/// +public static class TestSenders +{ + public static ISender WithTicketHooks() + { + var sender = Substitute.For(); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromResult(OperationResult.SuccessResult( + new OpenTicketResult(1, "TKT-TEST01", "open", "refund")))); + sender.Send(Arg.Any(), Arg.Any()) + .Returns(ValueTask.FromResult(OperationResult.SuccessResult(true))); + return sender; + } +}