From 93cc5ecb98f6368415596d1f1722b124d3202871 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 9 Jul 2026 15:30:03 +0330 Subject: [PATCH] backend phase 14 & frontend phase 7 --- client/CLAUDE.md | 10 +- client/messages/en.json | 114 +- client/messages/fa.json | 112 +- .../(customer)/bookings/checkout/page.tsx | 25 + .../(customer)/bookings/request/[id]/page.tsx | 290 + .../(customer)/bookings/request/page.tsx | 551 +- .../nurse/requests/[id]/page.tsx | 317 + .../(private-routes)/nurse/requests/page.tsx | 115 + .../BookingRequestSummaryCard.test.tsx | 58 + .../BookingRequestSummaryCard.tsx | 143 + .../BookingRequestSummaryCard/index.tsx | 2 + .../CountdownTimer/CountdownTimer.test.tsx | 56 + .../CountdownTimer/CountdownTimer.tsx | 113 + .../src/components/CountdownTimer/index.tsx | 2 + .../src/components/common/AppIcon/config.ts | 5 + client/src/components/index.tsx | 6 + client/src/constants/routes.ts | 8 +- client/src/layout/NurseLayout.tsx | 1 + .../bookingRequests/apis/clientApi.ts | 82 + .../services/bookingRequests/apis/index.ts | 12 + .../services/bookingRequests/apis/mockApi.ts | 285 + .../src/services/bookingRequests/constants.ts | 43 + .../hooks/useAcceptBookingRequest.ts | 21 + .../hooks/useBookingRequest.ts | 25 + .../hooks/useCancelBookingRequest.ts | 20 + .../hooks/useCreateBookingRequest.ts | 27 + .../hooks/useCustomerRequests.ts | 28 + .../hooks/useNurseRequestInbox.ts | 29 + .../hooks/useRejectBookingRequest.ts | 26 + client/src/services/bookingRequests/index.ts | 11 + client/src/services/bookingRequests/keys.ts | 23 + client/src/services/bookingRequests/types.ts | 198 + dev/contracts/domains/reviews-records.md | 121 + dev/contracts/openapi/swagger.v1.json | 1265 +++- dev/shared-working-context/backend/STATUS.md | 18 + .../backend/handoff/after-backend-phase-14.md | 56 + dev/shared-working-context/frontend/STATUS.md | 30 + .../frontend/requests/for-backend.md | 24 + .../reports/backend-phase-14-report.md | 91 + .../reports/frontend-phase-7-report.md | 118 + .../reports/mocks-registry.md | 2 +- server/CLAUDE.md | 41 +- .../Controllers/V1/AdminReviewsController.cs | 28 + .../V1/BookingReviewsController.cs | 32 + .../Controllers/V1/NursesController.cs | 17 +- .../V1/PatientCareRecordsController.cs | 41 + .../Controllers/V1/ReviewsController.cs | 43 + .../Persistence/ICustomerProfileRepository.cs | 4 + .../IPatientCareRecordRepository.cs | 28 + .../Persistence/IReviewRepository.cs | 55 + .../Contracts/Persistence/IUnitOfWork.cs | 2 + .../Reviews/IReviewModerationService.cs | 31 + .../WritePatientCareRecordCommand.Handler.cs | 59 + ...WritePatientCareRecordCommand.Validator.cs | 13 + .../WritePatientCareRecordCommand.cs | 11 + .../GetPatientHistoryQuery.Handler.cs | 69 + .../GetPatientHistoryQuery.cs | 10 + .../AttachReviewTagsCommand.Handler.cs | 57 + .../AttachReviewTagsCommand.Validator.cs | 13 + .../AttachReviewTagsCommand.cs | 12 + .../ModerateReviewCommand.Handler.cs | 64 + .../ModerateReviewCommand.Validator.cs | 22 + .../ModerateReview/ModerateReviewCommand.cs | 12 + .../SubmitReviewCommand.Handler.cs | 124 + .../SubmitReviewCommand.Validator.cs | 14 + .../SubmitReview/SubmitReviewCommand.cs | 14 + .../GetReviewModerationQueueQuery.Handler.cs | 30 + .../GetReviewModerationQueueQuery.cs | 11 + .../GetTagAggregatesQuery.Handler.cs | 18 + .../GetTagAggregates/GetTagAggregatesQuery.cs | 8 + .../ListReviewsForNurseQuery.Handler.cs | 35 + .../ListReviewsForNurseQuery.cs | 9 + .../Features/Reviews/RecomputeNurseRating.cs | 48 + .../Features/Reviews/ReviewCache.cs | 19 + .../Models/Reviews/CareRecordProjections.cs | 28 + .../Models/Reviews/ReviewProjections.cs | 54 + .../Entities/Identity/NurseProfile.cs | 9 + .../Entities/Reviews/PatientCareRecord.cs | 37 + .../Baya.Domain/Entities/Reviews/Review.cs | 57 + .../Reviews/ReviewModerationStatus.cs | 54 + .../Entities/Reviews/ReviewTagLink.cs | 16 + .../Entities/Reviews/ReviewTagMaster.cs | 35 + .../Seams/MockReviewModerationService.cs | 31 + .../Seams/SeamOptions.cs | 16 + .../ServiceCollectionExtension.cs | 7 + .../ReviewsConfig/PatientCareRecordConfig.cs | 34 + .../ReviewsConfig/ReviewConfig.cs | 39 + .../ReviewsConfig/ReviewTagLinkConfig.cs | 25 + .../ReviewsConfig/ReviewTagMasterConfig.cs | 28 + .../ReviewsConfig/ReviewsSeed.cs | 38 + ...9_ReviewsAndPatientCareRecords.Designer.cs | 5595 +++++++++++++++++ ...0709010659_ReviewsAndPatientCareRecords.cs | 269 + .../ApplicationDbContextModelSnapshot.cs | 335 + .../Repositories/Common/UnitOfWork.cs | 4 + .../Repositories/CustomerProfileRepository.cs | 6 + .../PatientCareRecordRepository.cs | 71 + .../Repositories/ReviewRepository.cs | 144 + .../Tests/Baya.Test.Api/ReviewsApiTests.cs | 48 + .../Reviews/PatientCareRecordHandlerTests.cs | 98 + .../Reviews/ReviewHandlerTests.cs | 195 + .../Reviews/ReviewsTestHost.cs | 219 + 101 files changed, 12930 insertions(+), 39 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx create mode 100644 client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx create mode 100644 client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx create mode 100644 client/src/components/BookingRequestSummaryCard/index.tsx create mode 100644 client/src/components/CountdownTimer/CountdownTimer.test.tsx create mode 100644 client/src/components/CountdownTimer/CountdownTimer.tsx create mode 100644 client/src/components/CountdownTimer/index.tsx create mode 100644 client/src/services/bookingRequests/apis/clientApi.ts create mode 100644 client/src/services/bookingRequests/apis/index.ts create mode 100644 client/src/services/bookingRequests/apis/mockApi.ts create mode 100644 client/src/services/bookingRequests/constants.ts create mode 100644 client/src/services/bookingRequests/hooks/useAcceptBookingRequest.ts create mode 100644 client/src/services/bookingRequests/hooks/useBookingRequest.ts create mode 100644 client/src/services/bookingRequests/hooks/useCancelBookingRequest.ts create mode 100644 client/src/services/bookingRequests/hooks/useCreateBookingRequest.ts create mode 100644 client/src/services/bookingRequests/hooks/useCustomerRequests.ts create mode 100644 client/src/services/bookingRequests/hooks/useNurseRequestInbox.ts create mode 100644 client/src/services/bookingRequests/hooks/useRejectBookingRequest.ts create mode 100644 client/src/services/bookingRequests/index.ts create mode 100644 client/src/services/bookingRequests/keys.ts create mode 100644 client/src/services/bookingRequests/types.ts create mode 100644 dev/contracts/domains/reviews-records.md create mode 100644 dev/shared-working-context/backend/handoff/after-backend-phase-14.md create mode 100644 dev/shared-working-context/reports/backend-phase-14-report.md create mode 100644 dev/shared-working-context/reports/frontend-phase-7-report.md create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs create mode 100644 server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IPatientCareRecordRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Persistence/IReviewRepository.cs create mode 100644 server/src/Core/Baya.Application/Contracts/Reviews/IReviewModerationService.cs create mode 100644 server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs create mode 100644 server/src/Core/Baya.Application/Features/Reviews/ReviewCache.cs create mode 100644 server/src/Core/Baya.Application/Models/Reviews/CareRecordProjections.cs create mode 100644 server/src/Core/Baya.Application/Models/Reviews/ReviewProjections.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Reviews/PatientCareRecord.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Reviews/Review.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Reviews/ReviewModerationStatus.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagLink.cs create mode 100644 server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagMaster.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/PatientCareRecordConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagLinkConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagMasterConfig.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewsSeed.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.Designer.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientCareRecordRepository.cs create mode 100644 server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/ReviewRepository.cs create mode 100644 server/src/Tests/Baya.Test.Api/ReviewsApiTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Reviews/PatientCareRecordHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Reviews/ReviewHandlerTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Reviews/ReviewsTestHost.cs diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 7fef783..7a3427e 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -127,7 +127,9 @@ client/ │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── bookings/ │ │ │ │ ├── page.tsx # /bookings - │ │ │ │ └── request/page.tsx # /bookings/request — f6→f7 booking handoff target (DEFERRED→f7 stub; echoes carried nurse/variant/required_gender intent) + │ │ │ │ ├── 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) │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── wallet/page.tsx # /wallet @@ -135,6 +137,7 @@ client/ │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── page.tsx # /nurse (dashboard) + │ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located; PublishGate is the f5 verification-gated go-live) │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor (whole-city/district areas, dup-blocked) @@ -174,6 +177,8 @@ client/ │ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested) │ ├── NurseResultCard/ # f6 C2 result card: avatar+name, reused verified TrustBadge, rating+review count, optional distance chip, "from X تومان/unit" via PriceDisplay; presentational + memoized (tested) │ ├── 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) │ ├── 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/ @@ -225,6 +230,7 @@ client/ │ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper │ ├── 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 │ └── {domain}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── keys.ts # React Query key factory (hierarchical) @@ -318,7 +324,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 f6→f7 booking-request handoff placeholder (title + "arrives next phase" + carried nurse/variant/gender echo); f7 fills it out +- `'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` - `'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/messages/en.json b/client/messages/en.json index 2a0965c..c084676 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -9,6 +9,7 @@ "coverage": "Coverage", "services": "Services", "dashboard": "Dashboard", + "requests": "Requests", "verification": "Verification", "visits": "Visits", "admin": "Admin", @@ -365,12 +366,115 @@ "request_booking": "Request booking" }, "booking": { + "gender_female": "Woman", + "gender_male": "Man", + "gender_any": "No preference", + "unnamed_nurse": "Nurse", + "summary_patient": "Patient", + "summary_service": "Service", + "summary_address": "Address", + "summary_when": "Time", "request_title": "Booking request", - "deferred": "The booking request form arrives in the next phase.", - "handoff_echo": "Nurse #{nurse}, service #{variant}, caregiver: {gender}.", - "gender_female": "woman", - "gender_male": "man", - "gender_any": "no preference" + "form_subtitle": "Send a request to this nurse. No payment is taken yet — the nurse reviews it first.", + "missing_nurse_title": "No nurse selected", + "missing_nurse_body": "Choose a nurse from search, then send a request.", + "missing_nurse_cta": "Find a nurse", + "patient_label": "Patient (care recipient)", + "patient_placeholder": "Select a patient", + "patient_empty": "You haven't added a patient yet.", + "patient_add_cta": "Add a patient", + "service_label": "Service type", + "service_placeholder": "Select a service", + "service_empty": "This nurse has no bookable service.", + "address_label": "Address", + "address_placeholder": "Select an address", + "address_empty": "You haven't added an address yet.", + "address_add_cta": "Add an address", + "address_whole_city": "Whole city", + "date_label": "Date", + "time_start_label": "From", + "time_end_label": "To", + "gender_label": "Caregiver gender", + "gender_hint": "For personal/bodily care, a same-gender caregiver matters. Your choice is sent with the request.", + "notes_label": "Notes for the nurse", + "notes_placeholder": "What the nurse sees before accepting…", + "notes_hint": "This is the only thing the nurse sees before accepting. The full care record is added after confirmation.", + "notes_counter": "{count}/{max}", + "submit": "Send request", + "submitting": "Sending…", + "error_patient_required": "Choose a patient.", + "error_service_required": "Choose a service.", + "error_address_required": "Choose an address.", + "error_gender_required": "Choose the caregiver gender.", + "error_date_required": "Choose a date.", + "error_time_required": "Choose a start and end time.", + "error_time_range": "The end time must be after the start time.", + "error_past_date": "Choose a future date and time.", + "error_notes_long": "Notes are too long.", + "error_gender_mismatch": "The chosen gender doesn't match this nurse.", + "error_not_bookable": "This service can't be booked right now.", + "error_tenancy": "That patient or address wasn't found.", + "error_generic": "Couldn't send the request. Please try again.", + "awaiting_title": "Request sent to the nurse", + "awaiting_subtitle": "Awaiting the nurse's response", + "step_submitted": "Request submitted", + "step_awaiting": "Awaiting nurse approval", + "step_payment": "Payment & final confirmation", + "response_countdown_label": "Nurse response window", + "response_elapsed": "Awaiting server confirmation…", + "accepted_badge": "The nurse accepted", + "accepted_body": "Pay within the window below to confirm the booking.", + "payment_countdown_label": "Payment window", + "payment_elapsed": "The payment window has closed", + "continue_payment": "Continue to payment ←", + "cancel_request": "Cancel request", + "cancelling": "Cancelling…", + "cancel_confirm_title": "Cancel this request?", + "cancel_confirm_body": "The nurse will no longer see it. You can request another nurse anytime.", + "cancel_confirm_yes": "Yes, cancel", + "rejected_title": "The nurse declined the request", + "rejected_reason_label": "Nurse's reason", + "expired_title": "The nurse didn't respond in time", + "payment_expired_title": "The payment window has closed", + "cancelled_title": "Request cancelled", + "converted_title": "Your booking is confirmed", + "converted_cta": "View booking", + "terminal_rerequest": "Request another nurse", + "not_found_title": "Request not found", + "not_found_body": "This request may have been removed.", + "error_title": "Something went wrong", + "error_body": "We couldn't load this request.", + "retry": "Try again", + "inbox_title": "Incoming requests", + "inbox_subtitle": "Families requesting your care. You see only their notes until you accept.", + "inbox_empty": "You have no new requests.", + "inbox_notes_label": "Family's notes", + "required_gender_chip": "Caregiver: {gender}", + "open_detail": "View", + "detail_title": "Request detail", + "location_label": "Location", + "disclosure_note": "Before accepting you see only the family's notes. The full address and care record appear after you accept.", + "accept": "Accept request", + "accepting": "Accepting…", + "reject": "Decline", + "reject_dialog_title": "Decline request", + "reject_reason_label": "Reason", + "reject_reason_placeholder": "A short reason the family will see…", + "reject_submit": "Submit decline", + "rejecting": "Declining…", + "reason_required": "A reason is required.", + "reason_too_long": "The reason is too long.", + "action_stale": "This request can no longer be actioned.", + "accepted_toast": "Request accepted.", + "rejected_toast": "Request declined.", + "cancelled_toast": "Request cancelled.", + "status_pending_nurse_response": "Awaiting nurse response", + "status_accepted_awaiting_payment": "Accepted — awaiting payment", + "status_converted": "Booked", + "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" }, "auth": { "customer_title": "Sign in to Balinyaar", diff --git a/client/messages/fa.json b/client/messages/fa.json index 08bb070..3dcf9ae 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -9,6 +9,7 @@ "coverage": "پوشش", "services": "خدمات", "dashboard": "داشبورد", + "requests": "درخواست‌ها", "verification": "احراز هویت", "visits": "ویزیت‌ها", "admin": "مدیریت", @@ -365,12 +366,115 @@ "request_booking": "درخواست رزرو" }, "booking": { - "request_title": "درخواست رزرو", - "deferred": "فرم درخواست رزرو در فاز بعدی اضافه می‌شود.", - "handoff_echo": "پرستار #{nurse}، خدمت #{variant}، جنسیت مراقب: {gender}.", "gender_female": "خانم", "gender_male": "آقا", - "gender_any": "فرقی ندارد" + "gender_any": "فرقی ندارد", + "unnamed_nurse": "پرستار", + "summary_patient": "بیمار", + "summary_service": "خدمت", + "summary_address": "آدرس", + "summary_when": "زمان", + "request_title": "درخواست رزرو", + "form_subtitle": "برای این پرستار درخواست بفرستید. هنوز پرداختی انجام نمی‌شود؛ ابتدا پرستار درخواست را بررسی می‌کند.", + "missing_nurse_title": "پرستاری انتخاب نشده است", + "missing_nurse_body": "ابتدا از جست‌وجو یک پرستار انتخاب کنید و سپس درخواست دهید.", + "missing_nurse_cta": "یافتن پرستار", + "patient_label": "بیمار (مددجو)", + "patient_placeholder": "انتخاب بیمار", + "patient_empty": "هنوز بیماری ثبت نکرده‌اید.", + "patient_add_cta": "افزودن بیمار", + "service_label": "نوع خدمت", + "service_placeholder": "انتخاب خدمت", + "service_empty": "این پرستار خدمتی برای رزرو ندارد.", + "address_label": "آدرس", + "address_placeholder": "انتخاب آدرس", + "address_empty": "هنوز آدرسی ثبت نکرده‌اید.", + "address_add_cta": "افزودن آدرس", + "address_whole_city": "کل شهر", + "date_label": "تاریخ", + "time_start_label": "از ساعت", + "time_end_label": "تا ساعت", + "gender_label": "جنسیت مراقب", + "gender_hint": "برای مراقبت‌های بدنی، هم‌جنس بودن مراقب اهمیت دارد. انتخاب شما همراه درخواست ارسال می‌شود.", + "notes_label": "توضیحات برای پرستار", + "notes_placeholder": "آنچه پرستار پیش از پذیرش می‌بیند…", + "notes_hint": "این تنها چیزی است که پرستار پیش از پذیرش می‌بیند؛ شرح کامل مراقبت پس از تایید ثبت می‌شود.", + "notes_counter": "{count}/{max}", + "submit": "ارسال درخواست", + "submitting": "در حال ارسال…", + "error_patient_required": "یک بیمار انتخاب کنید.", + "error_service_required": "یک خدمت انتخاب کنید.", + "error_address_required": "یک آدرس انتخاب کنید.", + "error_gender_required": "جنسیت مراقب را انتخاب کنید.", + "error_date_required": "تاریخ را انتخاب کنید.", + "error_time_required": "ساعت شروع و پایان را انتخاب کنید.", + "error_time_range": "ساعت پایان باید پس از ساعت شروع باشد.", + "error_past_date": "یک تاریخ و ساعت آینده انتخاب کنید.", + "error_notes_long": "توضیحات بیش از حد طولانی است.", + "error_gender_mismatch": "جنسیت انتخاب‌شده با این پرستار هم‌خوانی ندارد.", + "error_not_bookable": "این خدمت در حال حاضر قابل رزرو نیست.", + "error_tenancy": "بیمار یا آدرس یافت نشد.", + "error_generic": "ارسال درخواست ناموفق بود. دوباره تلاش کنید.", + "awaiting_title": "درخواست برای پرستار ارسال شد", + "awaiting_subtitle": "در انتظار پاسخ پرستار", + "step_submitted": "درخواست ثبت شد", + "step_awaiting": "در انتظار تایید پرستار", + "step_payment": "پرداخت و تایید نهایی", + "response_countdown_label": "مهلت پاسخ پرستار", + "response_elapsed": "در انتظار تایید سرور…", + "accepted_badge": "پرستار تایید کرد", + "accepted_body": "برای نهایی‌شدن رزرو، در مهلت زیر پرداخت را انجام دهید.", + "payment_countdown_label": "مهلت پرداخت", + "payment_elapsed": "مهلت پرداخت به پایان رسید", + "continue_payment": "ادامه پرداخت ←", + "cancel_request": "انصراف از درخواست", + "cancelling": "در حال لغو…", + "cancel_confirm_title": "این درخواست لغو شود؟", + "cancel_confirm_body": "پرستار دیگر آن را نمی‌بیند. هر زمان می‌توانید پرستار دیگری انتخاب کنید.", + "cancel_confirm_yes": "بله، لغو کن", + "rejected_title": "پرستار درخواست را رد کرد", + "rejected_reason_label": "دلیل پرستار", + "expired_title": "پرستار در مهلت مقرر پاسخ نداد", + "payment_expired_title": "مهلت پرداخت به پایان رسید", + "cancelled_title": "درخواست لغو شد", + "converted_title": "رزرو شما ثبت شد", + "converted_cta": "مشاهده رزرو", + "terminal_rerequest": "انتخاب پرستار دیگر", + "not_found_title": "درخواست یافت نشد", + "not_found_body": "ممکن است این درخواست حذف شده باشد.", + "error_title": "خطایی رخ داد", + "error_body": "بارگذاری این درخواست ممکن نشد.", + "retry": "تلاش دوباره", + "inbox_title": "درخواست‌های دریافتی", + "inbox_subtitle": "خانواده‌هایی که پرستاری شما را خواسته‌اند. تا زمان پذیرش تنها توضیحات آن‌ها را می‌بینید.", + "inbox_empty": "درخواست جدیدی ندارید.", + "inbox_notes_label": "توضیحات خانواده", + "required_gender_chip": "مراقب: {gender}", + "open_detail": "مشاهده", + "detail_title": "جزئیات درخواست", + "location_label": "موقعیت", + "disclosure_note": "پیش از پذیرش تنها توضیحات خانواده در دسترس است؛ نشانی کامل و سوابق درمانی پس از پذیرش نمایش داده می‌شود.", + "accept": "پذیرش درخواست", + "accepting": "در حال پذیرش…", + "reject": "رد کردن", + "reject_dialog_title": "رد درخواست", + "reject_reason_label": "دلیل", + "reject_reason_placeholder": "دلیل کوتاهی که خانواده می‌بیند…", + "reject_submit": "ثبت رد", + "rejecting": "در حال رد…", + "reason_required": "دلیل الزامی است.", + "reason_too_long": "دلیل بیش از حد طولانی است.", + "action_stale": "این درخواست دیگر قابل اقدام نیست.", + "accepted_toast": "درخواست پذیرفته شد.", + "rejected_toast": "درخواست رد شد.", + "cancelled_toast": "درخواست لغو شد.", + "status_pending_nurse_response": "در انتظار پاسخ پرستار", + "status_accepted_awaiting_payment": "پذیرفته‌شده — در انتظار پرداخت", + "status_converted": "رزرو شد", + "status_rejected_by_nurse": "ردشده توسط پرستار", + "status_expired_no_response": "بدون پاسخ در مهلت", + "status_payment_deadline_expired": "پایان مهلت پرداخت", + "status_cancelled_by_customer": "لغوشده" }, "auth": { "customer_title": "ورود به بلینیار", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx new file mode 100644 index 0000000..9f7c4d8 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx @@ -0,0 +1,25 @@ +'use client'; +import { Suspense } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import { AppLoading, PlaceholderScreen } from '@/components'; + +/** + * Checkout (pay & confirm) — **DEFERRED → frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with + * the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder + * confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. + */ +export default function CheckoutPage() { + return ( + }> + + + ); +} + +function CheckoutDeferred() { + const t = useTranslations('booking'); + const params = useSearchParams(); + const requestId = params.get('request_id') ?? '—'; + return ; +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx new file mode 100644 index 0000000..5287aa3 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx @@ -0,0 +1,290 @@ +'use client'; +import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useParams, useRouter } from 'next/navigation'; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Skeleton, + Stack, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon, BookingRequestSummaryCard, CountdownTimer, StatusChip, StepperHeader } from '@/components'; +import { ROUTES } from '@/constants'; +import { useBookingRequest, useCancelBookingRequest } from '@/services/bookingRequests'; +import type { BookingRequestDto } from '@/services/bookingRequests/types'; + +/** + * C5 — Awaiting nurse acceptance (در انتظار تایید پرستار). Keyed by the request id, it **polls** the + * request (`useBookingRequest`, stopping at a terminal status) so the accept / reject / expire transition + * surfaces without a manual refresh. It renders the shared summary card, the 3-step tracker, and a + * countdown driven by the **server-frozen** deadline: the response window while pending, then the 30-min + * payment window once accepted (with the hand-off to checkout). Terminal states show their own card. + */ +export default function BookingRequestStatusPage() { + const t = useTranslations('booking'); + const locale = useLocale(); + const router = useRouter(); + const params = useParams<{ id: string }>(); + const id = Number(params.id); + + const { data: request, isLoading, isError, refetch } = useBookingRequest( + Number.isInteger(id) && id > 0 ? id : undefined, + 'customer', + ); + const cancelRequest = useCancelBookingRequest(); + const [confirmCancel, setConfirmCancel] = useState(false); + + if (isLoading) return ; + + if (isError || !request) { + return ( + refetch()} + /> + ); + } + + const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`); + const addressLabel = customerAddressLabel(request, locale, t('address_whole_city')); + + const summary = ( + + ); + + // Terminal states — each is its own card with a re-request path back into discovery (or booking). + if (request.status === 'rejected_by_nurse') { + return ( + + {summary} + + + ); + } + if (request.status === 'expired_no_response') { + return ( + + {summary} + + + ); + } + if (request.status === 'payment_deadline_expired') { + return ( + + {summary} + + + ); + } + if (request.status === 'cancelled_by_customer') { + return ( + + {summary} + + + ); + } + if (request.status === 'converted') { + return ( + + {summary} + router.push(`/${locale}${ROUTES.BOOKINGS}`)} + /> + + ); + } + + const accepted = request.status === 'accepted_awaiting_payment'; + const activeStep = accepted ? 2 : 1; + + return ( + + + + + {t('awaiting_title')} + + + {t('awaiting_subtitle')} + + + + + + {summary} + + {accepted ? ( + + + + + {t('accepted_body')} + + {request.paymentDeadlineAt ? ( + refetch()} + /> + ) : null} + router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)} + sx={{ m: 0, py: 1.25 }} + > + {t('continue_payment')} + + + + ) : ( + + refetch()} + /> + + )} + + setConfirmCancel(true)} + sx={{ m: 0, alignSelf: 'center' }} + > + {cancelRequest.isPending ? t('cancelling') : t('cancel_request')} + + + setConfirmCancel(false)}> + {t('cancel_confirm_title')} + + + {t('cancel_confirm_body')} + + + + setConfirmCancel(false)}> + {t('cancel_request')} + + { + setConfirmCancel(false); + cancelRequest.mutate(request.id); + }} + > + {t('cancel_confirm_yes')} + + + + + ); +} + +/** "title · city · district" (or "· whole city"), locale-aware — the customer view carries the full address. */ +function customerAddressLabel(request: BookingRequestDto, locale: string, wholeCityLabel: string): string { + const city = locale === 'en' ? request.cityNameEn : request.cityNameFa; + const district = + request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa; + return `${request.addressTitle} · ${city} · ${district}`; +} + +function TerminalCard({ + icon, + tone, + title, + body, + ctaLabel, + onCta, +}: { + icon: string; + tone: string; + title: string; + body?: string; + ctaLabel: string; + onCta: () => void; +}) { + return ( + + + + {title} + + {body ? ( + + {body} + + ) : null} + + {ctaLabel} + + + ); +} + +function StatusSkeleton() { + return ( + + + + + + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx index d4d612c..4b04cc3 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx @@ -1,32 +1,545 @@ 'use client'; -import { Suspense } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useTranslations } from 'next-intl'; -import { AppLoading, PlaceholderScreen } from '@/components'; +import { Suspense, useMemo, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { + Box, + MenuItem, + Paper, + Skeleton, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon, AppLoading, PriceDisplay } from '@/components'; +import { AddressMapPicker } from '@/components/geography'; +import { ROUTES } from '@/constants'; +import { ApiError } from '@/lib/api/errors'; +import { cityCentroid } from '@/services/geography/constants'; +import { usePatients } from '@/services/patients'; +import { useAddresses } from '@/services/addresses'; +import { useNurseProfile } from '@/services/search'; +import { useCreateBookingRequest } from '@/services/bookingRequests'; +import { CUSTOMER_NOTES_MAX_LENGTH } from '@/services/bookingRequests/constants'; +import type { + BookingRequestDisplayContext, + RequiredCaregiverGender, +} from '@/services/bookingRequests/types'; + +const GENDER_OPTIONS: RequiredCaregiverGender[] = ['female', 'male', 'any']; /** - * Booking-request handoff target — **DEFERRED → frontend-phase-7-b8**. C3's "درخواست رزرو" lands here - * carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes - * `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder - * confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. + * C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the + * `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The + * family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address + * (f3), a future date + time window, the **first-class caregiver-gender** preference, and stage-1 notes, + * then sends the request → lands on C5. Money-free: no price breakdown, no booking row (that's f9/b9). + * `useSearchParams` requires a Suspense boundary. */ -export default function BookingRequestPage() { +export default function BookingRequestFormPage() { return ( }> - + ); } -function BookingRequestDeferred() { +function BookingRequestForm() { const t = useTranslations('booking'); - const params = useSearchParams(); - const gender = params.get('required_gender'); - const echo = t('handoff_echo', { - nurse: params.get('nurse_id') ?? '—', - variant: params.get('variant_id') ?? '—', - gender: gender ? t(`gender_${gender}`) : t('gender_any'), - }); + const tAddress = useTranslations('address'); + const locale = useLocale(); + const router = useRouter(); + const query = useSearchParams(); - return ; + const nurseId = Number(query.get('nurse_id')); + const hasNurse = Number.isInteger(nurseId) && nurseId > 0; + const variantIdParam = Number(query.get('variant_id')) || null; + const genderParam = query.get('required_gender'); + + const profileQuery = useNurseProfile(hasNurse ? nurseId : undefined); + const patientsQuery = usePatients(); + const addressesQuery = useAddresses(); + const createRequest = useCreateBookingRequest(); + + const profile = profileQuery.data; + const patients = useMemo(() => patientsQuery.data?.items ?? [], [patientsQuery.data]); + const addresses = useMemo(() => addressesQuery.data?.items ?? [], [addressesQuery.data]); + const services = useMemo(() => profile?.services ?? [], [profile]); + + const [patientId, setPatientId] = useState(''); + const [variantSel, setVariantSel] = useState(variantIdParam ?? ''); + const [addressSel, setAddressSel] = useState(''); + const [gender, setGender] = useState( + genderParam === 'male' || genderParam === 'female' ? genderParam : '', + ); + const [date, setDate] = useState(''); + const [timeStart, setTimeStart] = useState('09:00'); + const [timeEnd, setTimeEnd] = useState('13:00'); + const [notes, setNotes] = useState(''); + const [attempted, setAttempted] = useState(false); + const [pastDateError, setPastDateError] = useState(false); + const [formError, setFormError] = useState(null); + + // Effective selection = the user's explicit choice, else a sensible default derived from the loaded + // data. Computed during render (no setState-in-effect): the variant defaults to the carried one / the + // first offered, the address to the primary / first. + const firstVariantId: number | '' = services.length > 0 ? services[0].variantId : ''; + const variantId = variantSel !== '' ? variantSel : firstVariantId; + const primaryAddressId: number | '' = + addresses.length > 0 ? (addresses.find((address) => address.isPrimary)?.id ?? addresses[0].id) : ''; + const addressId = addressSel !== '' ? addressSel : primaryAddressId; + + const selectedVariant = useMemo( + () => services.find((service) => service.variantId === variantId), + [services, variantId], + ); + const selectedAddress = useMemo( + () => addresses.find((address) => address.id === addressId), + [addresses, addressId], + ); + const selectedPatient = useMemo( + () => patients.find((patient) => patient.id === patientId), + [patients, patientId], + ); + + // A concrete gender that contradicts the (single) nurse's gender is a same-gender mismatch (400) — + // block it inline before the round-trip; the server re-validates and is authoritative. + const genderMismatch = + gender !== '' && gender !== 'any' && profile != null && gender !== profile.nurseGender; + + const requiredChosen = + patientId !== '' && variantId !== '' && addressId !== '' && gender !== '' && date !== '' && timeStart !== '' && timeEnd !== ''; + + const regionLabel = (): string => { + if (!selectedAddress) return ''; + const city = locale === 'en' ? selectedAddress.cityNameEn : selectedAddress.cityNameFa; + const district = + selectedAddress.districtId == null + ? t('address_whole_city') + : locale === 'en' + ? selectedAddress.districtNameEn + : selectedAddress.districtNameFa; + return `${selectedAddress.title} · ${city} · ${district}`; + }; + + const handleSubmit = () => { + setAttempted(true); + setFormError(null); + if (!requiredChosen) return; + if (timeEnd <= timeStart) return; + // Future date+time guard (local wall-clock, matching the wire's date + time fields). Evaluated in the + // handler (not render) so the render path stays pure; the result drives the inline date error. + if (Date.parse(`${date}T${timeStart}`) < Date.now()) { + setPastDateError(true); + return; + } + setPastDateError(false); + if (genderMismatch) return; + + const context: BookingRequestDisplayContext | undefined = + profile && selectedVariant && selectedAddress && selectedPatient + ? { + nurseName: profile.nurseName, + nurseRating: profile.averageRating, + nurseTotalReviews: profile.totalReviews, + patientName: selectedPatient.displayName, + variantLabel: selectedVariant.displayName, + variantPriceUnit: selectedVariant.priceUnit, + variantPrice: selectedVariant.priceIrr, + addressTitle: selectedAddress.title, + cityId: selectedAddress.cityId, + cityNameFa: selectedAddress.cityNameFa, + cityNameEn: selectedAddress.cityNameEn, + districtId: selectedAddress.districtId, + districtNameFa: selectedAddress.districtNameFa, + districtNameEn: selectedAddress.districtNameEn, + addressLine: selectedAddress.addressLine, + postalCode: selectedAddress.postalCode, + recipientName: selectedAddress.recipientName, + recipientPhone: selectedAddress.recipientPhone, + } + : undefined; + + createRequest.mutate( + { + payload: { + nurseId, + variantId: variantId as number, + patientId: patientId as number, + customerAddressId: addressId as number, + requestedDate: date, + requestedTimeStart: `${timeStart}:00`, + requestedTimeEnd: `${timeEnd}:00`, + requiredCaregiverGender: gender as RequiredCaregiverGender, + customerNotes: notes.trim() || null, + }, + context, + }, + { + onSuccess: (dto) => { + router.push(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${dto.id}`); + }, + onError: (error) => setFormError(mapCreateError(error, t)), + }, + ); + }; + + if (!hasNurse) { + return ( + router.push(`/${locale}${ROUTES.SEARCH}`)} + /> + ); + } + + if (profileQuery.isLoading) return ; + + const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart; + const pastError = pastDateError; + + return ( + + + + {t('request_title')} + + + {t('form_subtitle')} + + + + {/* Patient */} + {patients.length === 0 ? ( + router.push(`/${locale}${ROUTES.PATIENTS}`)} + /> + ) : ( + setPatientId(Number(event.target.value))} + fullWidth + > + + {t('patient_placeholder')} + + {patients.map((patient) => ( + + {patient.displayName} + + ))} + + )} + + {/* Service variant */} + {services.length === 0 ? ( + + {t('service_empty')} + + ) : ( + setVariantSel(Number(event.target.value))} + fullWidth + > + + {t('service_placeholder')} + + {services.map((service) => ( + + {service.displayName} + + ))} + + )} + {selectedVariant ? ( + + + + ) : null} + + {/* Address */} + {addresses.length === 0 ? ( + router.push(`/${locale}${ROUTES.ADDRESSES}`)} + /> + ) : ( + + setAddressSel(Number(event.target.value))} + fullWidth + > + + {t('address_placeholder')} + + {addresses.map((address) => ( + + {address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa} + + ))} + + {selectedAddress ? ( + + + {regionLabel()} + {selectedAddress.addressLine ? ` — ${selectedAddress.addressLine}` : ''} + + {selectedAddress.latitude != null && selectedAddress.longitude != null ? ( + // Read-only preview of the address's stored pin (the pin itself is set in the f3 book). + + undefined} + center={cityCentroid(selectedAddress.cityId)} + helperText={regionLabel()} + latLabel={tAddress('map_lat')} + lngLabel={tAddress('map_lng')} + /> + + ) : null} + + ) : null} + + )} + + {/* Date + time */} + + { + setDate(event.target.value); + if (pastDateError) setPastDateError(false); + }} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + { + setTimeStart(event.target.value); + if (pastDateError) setPastDateError(false); + }} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + setTimeEnd(event.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + fullWidth + /> + + + {/* Caregiver gender — first-class, three-way, never silently defaulted */} + + + {t('gender_label')} + + { + if (next) setGender(next); + }} + sx={{ + '& .MuiToggleButton-root': { + flex: 1, + py: 1.25, + fontWeight: 600, + borderColor: attempted && gender === '' ? 'var(--bal-error)' : undefined, + }, + }} + > + {GENDER_OPTIONS.map((option) => ( + + {t(`gender_${option}`)} + + ))} + + + {t('gender_hint')} + + {attempted && gender === '' ? ( + + {t('error_gender_required')} + + ) : null} + {genderMismatch ? ( + + {t('error_gender_mismatch')} + + ) : null} + + + {/* Stage-1 notes */} + setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))} + multiline + minRows={3} + fullWidth + helperText={t('notes_hint')} + /> + + {t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })} + + + {formError ? ( + + {formError} + + ) : null} + + + {createRequest.isPending ? t('submitting') : t('submit')} + + + ); +} + +/** Map a create `ApiError` (domain 400/404 codes) to a translated, user-facing message. */ +function mapCreateError(error: unknown, t: (key: string) => string): string { + if (error instanceof ApiError) { + switch (error.code) { + case 'gender_required': + return t('error_gender_required'); + case 'invalid_time_range': + return t('error_time_range'); + case 'past_date': + return t('error_past_date'); + case 'notes_too_long': + return t('error_notes_long'); + case 'gender_mismatch': + return t('error_gender_mismatch'); + case 'inactive_variant': + case 'not_bookable': + return t('error_not_bookable'); + case 'not_found': + return t('error_tenancy'); + default: + break; + } + if (error.status === 404) return t('error_tenancy'); + } + return t('error_generic'); +} + +function FieldEmpty({ + label, + message, + ctaLabel, + onCta, +}: { + label: string; + message: string; + ctaLabel: string; + onCta: () => void; +}) { + return ( + + + {label} + + + + + {message} + + + {ctaLabel} + + + + + ); +} + +function EmptyState({ + icon, + title, + body, + ctaLabel, + onCta, +}: { + icon: string; + title: string; + body: string; + ctaLabel: string; + onCta: () => void; +}) { + return ( + + + + {title} + + + {body} + + + {ctaLabel} + + + ); +} + +function FormSkeleton() { + return ( + + + {[0, 1, 2, 3].map((key) => ( + + ))} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx new file mode 100644 index 0000000..ca39f29 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/requests/[id]/page.tsx @@ -0,0 +1,317 @@ +'use client'; +import { useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useParams, useRouter } from 'next/navigation'; +import { useSnackbar } from 'notistack'; +import { + Box, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Paper, + Skeleton, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon, CountdownTimer, PriceDisplay, StatusChip } from '@/components'; +import { ROUTES } from '@/constants'; +import { ApiError } from '@/lib/api/errors'; +import { formatShamsiDate } from '@/utils'; +import { + useAcceptBookingRequest, + useBookingRequest, + useRejectBookingRequest, +} from '@/services/bookingRequests'; +import { REJECTION_REASON_MAX_LENGTH } from '@/services/bookingRequests/constants'; +import { isTerminalBookingRequestStatus, type BookingRequestDto } from '@/services/bookingRequests/types'; + +/** + * Nurse request detail (نمای پرستار). Renders the request summary and **only `customerNotes`** as the + * clinical context (two-stage disclosure — the address is masked to city/district and no clinical field + * exists pre-accept). A pending request offers accept / reject (with a reason); both invalidate the inbox + * + this detail so the request leaves the pending list and the customer's C5 reflects it. A stale action + * returns `409`, surfaced then refetched. + */ +export default function NurseRequestDetailPage() { + const t = useTranslations('booking'); + const locale = useLocale(); + const router = useRouter(); + const { enqueueSnackbar } = useSnackbar(); + const params = useParams<{ id: string }>(); + const id = Number(params.id); + + const { data: request, isLoading, isError, refetch } = useBookingRequest( + Number.isInteger(id) && id > 0 ? id : undefined, + 'nurse', + ); + const acceptRequest = useAcceptBookingRequest(); + const rejectRequest = useRejectBookingRequest(); + + const [rejectOpen, setRejectOpen] = useState(false); + const [reason, setReason] = useState(''); + const [reasonError, setReasonError] = useState(false); + + if (isLoading) return ; + + if (isError || !request) { + return ( + + + {t('not_found_title')} + + + {t('not_found_body')} + + router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}> + {t('inbox_title')} + + + ); + } + + const pending = request.status === 'pending_nurse_response'; + const isTerminal = isTerminalBookingRequestStatus(request.status); + const location = coarseLocation(request, locale, t('address_whole_city')); + + const handleStaleError = (error: unknown) => { + if (error instanceof ApiError && error.status === 409) { + enqueueSnackbar(t('action_stale'), { variant: 'warning' }); + refetch(); + } else { + enqueueSnackbar(t('error_generic'), { variant: 'error' }); + } + }; + + const handleAccept = () => { + acceptRequest.mutate(request.id, { + onSuccess: () => enqueueSnackbar(t('accepted_toast'), { variant: 'success' }), + onError: handleStaleError, + }); + }; + + const handleReject = () => { + const trimmed = reason.trim(); + if (!trimmed) { + setReasonError(true); + return; + } + rejectRequest.mutate( + { id: request.id, payload: { reason: trimmed } }, + { + onSuccess: () => { + setRejectOpen(false); + setReason(''); + enqueueSnackbar(t('rejected_toast'), { variant: 'success' }); + }, + onError: (error) => { + setRejectOpen(false); + handleStaleError(error); + }, + }, + ); + }; + + const startDate = new Date(`${request.requestedDate}T${request.requestedTimeStart}`); + const endDate = new Date(`${request.requestedDate}T${request.requestedTimeEnd}`); + const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' }); + const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`; + + return ( + + + + {t('detail_title')} + + + + + + + + + {request.patientName} + + + + + + {request.variantLabel} + + {request.variantPrice ? ( + + ) : null} + + + + + {location} + + + + + {whenLabel} + + + {request.requiredCaregiverGender ? ( + + + + ) : null} + + + + {/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */} + + + + {t('inbox_notes_label')} + + + {request.customerNotes || '—'} + + + + + + {t('disclosure_note')} + + + + + + {pending ? ( + + refetch()} + /> + + + {acceptRequest.isPending ? t('accepting') : t('accept')} + + setRejectOpen(true)} + sx={{ m: 0, flex: 1, py: 1.25 }} + > + {t('reject')} + + + + ) : ( + + + {t(`status_${request.status}`)} + + + )} + + setRejectOpen(false)} fullWidth maxWidth="xs"> + {t('reject_dialog_title')} + + { + setReason(event.target.value.slice(0, REJECTION_REASON_MAX_LENGTH)); + if (reasonError) setReasonError(false); + }} + error={reasonError} + helperText={reasonError ? t('reason_required') : undefined} + multiline + minRows={2} + fullWidth + sx={{ mt: 1 }} + /> + + + setRejectOpen(false)}> + {t('cancel_request')} + + + {rejectRequest.isPending ? t('rejecting') : t('reject_submit')} + + + + + ); +} + +/** Coarse, masked location (city · district) — the nurse view never receives the full address. */ +function coarseLocation(request: BookingRequestDto, locale: string, wholeCityLabel: string): string { + const city = locale === 'en' ? request.cityNameEn : request.cityNameFa; + const district = + request.districtId == null ? wholeCityLabel : locale === 'en' ? request.districtNameEn : request.districtNameFa; + return `${city} · ${district}`; +} + +function statusKind(status: BookingRequestDto['status']) { + switch (status) { + case 'accepted_awaiting_payment': + return 'active' as const; + case 'converted': + return 'verified' as const; + case 'rejected_by_nurse': + case 'payment_deadline_expired': + return 'rejected' as const; + case 'expired_no_response': + case 'cancelled_by_customer': + return 'neutral' as const; + default: + return 'pending' as const; + } +} + +function DetailRow({ caption, children }: { caption: string; children: React.ReactNode }) { + return ( + + + {caption} + + {children} + + ); +} + +function DetailSkeleton() { + return ( + + + + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx new file mode 100644 index 0000000..8cc52e3 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/requests/page.tsx @@ -0,0 +1,115 @@ +'use client'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { Box, Chip, Paper, Skeleton, Stack, Typography } from '@mui/material'; +import { AppButton, AppIcon, CountdownTimer } from '@/components'; +import { ROUTES } from '@/constants'; +import { formatShamsiDate } from '@/utils'; +import { useNurseRequestInbox } from '@/services/bookingRequests'; +import type { BookingRequestListItem } from '@/services/bookingRequests/types'; + +/** + * Nurse incoming-requests inbox (نمای پرستار). Lists pending requests — each a card with the family's + * patient name, the requested time (Shamsi), the **required-caregiver-gender** chip, a notes preview, and + * a **per-request countdown** to that request's response deadline. Two-stage disclosure: the row shows + * only `customerNotes` — never an address or any clinical field. Lightly polled so new requests appear. + */ +export default function NurseRequestsPage() { + const t = useTranslations('booking'); + const { data, isLoading } = useNurseRequestInbox(); + const items = data?.items ?? []; + + return ( + + + + {t('inbox_title')} + + + {t('inbox_subtitle')} + + + + {isLoading ? ( + + {[0, 1].map((key) => ( + + ))} + + ) : items.length === 0 ? ( + + + + {t('inbox_empty')} + + + ) : ( + + {items.map((item) => ( + + ))} + + )} + + ); +} + +function InboxCard({ item }: { item: BookingRequestListItem }) { + const t = useTranslations('booking'); + const locale = useLocale(); + const router = useRouter(); + + const startDate = new Date(`${item.requestedDate}T${item.requestedTimeStart}`); + const endDate = new Date(`${item.requestedDate}T${item.requestedTimeEnd}`); + const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { hour: '2-digit', minute: '2-digit' }); + const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`; + + return ( + + + + + + {item.counterpartyName} + + + {whenLabel} + + + + + + {item.requiredCaregiverGender ? ( + + + + ) : null} + + {item.customerNotes ? ( + + + {t('inbox_notes_label')} + + + {item.customerNotes} + + + ) : null} + + router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('open_detail')} + + + + ); +} diff --git a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx new file mode 100644 index 0000000..96ca36b --- /dev/null +++ b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; + +// next-intl mocked to echo keys; locale = en so money/date format with ASCII digits we can assert on. +jest.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})); + +import BookingRequestSummaryCard, { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard'; + +const BASE: BookingRequestSummaryCardProps = { + nurseName: 'Maryam Rezaei', + nurseAvatarUrl: null, + nurseRating: 4.8, + patientName: 'Haj Mousavi', + variantLabel: 'Elderly care — day shift', + variantPrice: '2800000', + variantPriceUnit: 'per_hour', + addressLabel: 'Home · Tehran · Saadat Abad', + requestedDate: '2026-08-01', + requestedTimeStart: '09:00:00', + requestedTimeEnd: '13:00:00', +}; + +function renderCard(props: Partial = {}) { + return render( + + + , + ); +} + +describe(' component', () => { + it('renders the nurse, patient, service and address', () => { + renderCard(); + expect(screen.getByText('Maryam Rezaei')).toBeInTheDocument(); + expect(screen.getByText('Haj Mousavi')).toBeInTheDocument(); + expect(screen.getByText('Elderly care — day shift')).toBeInTheDocument(); + expect(screen.getByText('Home · Tehran · Saadat Abad')).toBeInTheDocument(); + }); + + it('prices the service in grouped Toman when a variantPrice is present', () => { + renderCard(); + // 2,800,000 IRR = 280,000 Toman. + expect(screen.getByText(/280,000/)).toBeInTheDocument(); + }); + + it('hides the price line when variantPrice is null (real-path contract gap)', () => { + renderCard({ variantPrice: null }); + expect(screen.queryByText(/280,000/)).not.toBeInTheDocument(); + }); + + it('hides the rating row when no rating is supplied', () => { + renderCard({ nurseRating: null }); + expect(screen.queryByText('4.8')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx new file mode 100644 index 0000000..f021b56 --- /dev/null +++ b/client/src/components/BookingRequestSummaryCard/BookingRequestSummaryCard.tsx @@ -0,0 +1,143 @@ +'use client'; +import { FunctionComponent, ReactNode } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { Avatar, Divider, Paper, Stack, Typography } from '@mui/material'; +import AppIcon from '@/components/common/AppIcon'; +import PriceDisplay from '@/components/PriceDisplay'; +import { formatShamsiDate } from '@/utils'; +import type { PriceUnit } from '@/services/catalog/types'; + +export interface BookingRequestSummaryCardProps { + nurseName: string; + nurseAvatarUrl?: string | null; + /** Average rating; `null`/omitted hides the rating row (e.g. the nurse-side view). */ + nurseRating?: number | null; + patientName: string; + variantLabel: string; + /** IRR digit-string; when `null` the price line is hidden (contract gap REQ-013 on the real path). */ + variantPrice?: string | null; + variantPriceUnit: PriceUnit; + /** Localised "title · city · district" label, computed by the caller (locale-aware region names). */ + addressLabel: string; + /** ISO date `YYYY-MM-DD`. */ + requestedDate: string; + /** `HH:mm:ss`. */ + requestedTimeStart: string; + requestedTimeEnd: string; +} + +/** + * The engagement summary shared by the customer's awaiting screen (C5), the nurse request detail, and + * (later) the f8 booking detail: nurse identity + rating, patient, priced service, address label, and the + * requested date/time (Shamsi). Presentational — it reads only the `booking` caption keys and formats + * money/dates through the shared utils; every value is supplied by the caller. Kept at the shared level so + * f8 reuses it rather than re-deriving the layout. + * @component BookingRequestSummaryCard + */ +const BookingRequestSummaryCard: FunctionComponent = ({ + nurseName, + nurseAvatarUrl, + nurseRating, + patientName, + variantLabel, + variantPrice, + variantPriceUnit, + addressLabel, + requestedDate, + requestedTimeStart, + requestedTimeEnd, +}) => { + const t = useTranslations('booking'); + const locale = useLocale(); + const name = nurseName.trim() || t('unnamed_nurse'); + + const startDate = new Date(`${requestedDate}T${requestedTimeStart}`); + const endDate = new Date(`${requestedDate}T${requestedTimeEnd}`); + const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + }); + const whenLabel = `${formatShamsiDate(startDate, locale)} · ${timeFmt.format(startDate)} – ${timeFmt.format(endDate)}`; + + const ratingLabel = + nurseRating != null + ? new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(nurseRating) + : null; + + return ( + + + + + {name.charAt(0)} + + + + {name} + + {ratingLabel ? ( + + + + {ratingLabel} + + + ) : null} + + + + + + + + + {patientName} + + + + + + + {variantLabel} + + {variantPrice ? ( + + ) : null} + + + + + + {addressLabel} + + + + + + {whenLabel} + + + + + + ); +}; + +function SummaryRow({ caption, children }: { caption: string; children: ReactNode }) { + return ( + + + {caption} + + {children} + + ); +} + +export default BookingRequestSummaryCard; diff --git a/client/src/components/BookingRequestSummaryCard/index.tsx b/client/src/components/BookingRequestSummaryCard/index.tsx new file mode 100644 index 0000000..7fa7adc --- /dev/null +++ b/client/src/components/BookingRequestSummaryCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './BookingRequestSummaryCard'; +export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard'; diff --git a/client/src/components/CountdownTimer/CountdownTimer.test.tsx b/client/src/components/CountdownTimer/CountdownTimer.test.tsx new file mode 100644 index 0000000..8727b6f --- /dev/null +++ b/client/src/components/CountdownTimer/CountdownTimer.test.tsx @@ -0,0 +1,56 @@ +import { act, render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; + +// next-intl mocked so the locale is `en` and the countdown formats with ASCII digits we can assert on. +jest.mock('next-intl', () => ({ useLocale: () => 'en' })); + +import CountdownTimer, { CountdownTimerProps } from './CountdownTimer'; + +const deadlineInSeconds = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString(); + +function renderTimer(props: CountdownTimerProps) { + return render( + + + , + ); +} + +describe(' component', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-09T10:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders MM:SS remaining for a sub-hour deadline', () => { + renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' }); + expect(screen.getByText('01:30')).toBeInTheDocument(); + }); + + it('renders HH:MM:SS for a multi-hour deadline', () => { + renderTimer({ deadlineIso: deadlineInSeconds(3661), elapsedText: 'time up' }); + expect(screen.getByText('01:01:01')).toBeInTheDocument(); + }); + + it('ticks down each second without lifting state to the page', () => { + renderTimer({ deadlineIso: deadlineInSeconds(90), elapsedText: 'time up' }); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(screen.getByText('01:29')).toBeInTheDocument(); + }); + + it('shows the elapsed text and fires onElapsed exactly once at zero', () => { + const onElapsed = jest.fn(); + renderTimer({ deadlineIso: deadlineInSeconds(2), elapsedText: 'time up', onElapsed }); + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(screen.getByText('time up')).toBeInTheDocument(); + expect(onElapsed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/CountdownTimer/CountdownTimer.tsx b/client/src/components/CountdownTimer/CountdownTimer.tsx new file mode 100644 index 0000000..5a5d024 --- /dev/null +++ b/client/src/components/CountdownTimer/CountdownTimer.tsx @@ -0,0 +1,113 @@ +'use client'; +import { FunctionComponent, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocale } from 'next-intl'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AppIcon from '@/components/common/AppIcon'; + +export interface CountdownTimerProps { + /** + * The **server-supplied** absolute UTC instant to count down to (e.g. `nurseResponseDeadlineAt`). The + * client only renders the difference against `Date.now()` — it never computes or recomputes a deadline. + */ + deadlineIso: string; + /** Optional label above the digits (already translated by the caller). */ + label?: string; + /** Shown once the deadline has passed — the poll then resolves the real terminal status. */ + elapsedText: string; + /** Terracotta-accented urgency styling for the money-adjacent payment window. */ + urgent?: boolean; + /** Fired once when the countdown reaches zero (e.g. to nudge a refetch). */ + onElapsed?: () => void; +} + +const MS_PER_SECOND = 1000; +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = 3600; + +/** + * A pure presentational countdown to a server-frozen deadline. It owns its own one-second tick so only + * this component re-renders each second — never the page around it (the summary card / form stay put). + * The ticking stops the moment the deadline passes; crossing zero shows `elapsedText` and fires + * `onElapsed` once. Digits render in the active locale (Persian for `fa`), forced LTR so the `HH:MM:SS` + * order is correct under RTL. + * @component CountdownTimer + */ +const CountdownTimer: FunctionComponent = ({ + deadlineIso, + label, + elapsedText, + urgent = false, + onElapsed, +}) => { + const locale = useLocale(); + const [now, setNow] = useState(() => Date.now()); + const target = useMemo(() => Date.parse(deadlineIso), [deadlineIso]); + const remainingMs = Number.isFinite(target) ? Math.max(0, target - now) : 0; + const elapsed = remainingMs <= 0; + + // Recreated only when `elapsed` flips (once) — not every tick, since `elapsed` stays false until zero. + useEffect(() => { + if (elapsed) return undefined; + const interval = setInterval(() => setNow(Date.now()), MS_PER_SECOND); + return () => clearInterval(interval); + }, [elapsed]); + + const firedRef = useRef(false); + useEffect(() => { + if (elapsed && !firedRef.current) { + firedRef.current = true; + onElapsed?.(); + } else if (!elapsed) { + firedRef.current = false; + } + }, [elapsed, onElapsed]); + + const accent = urgent ? 'var(--bal-secondary)' : 'var(--bal-primary)'; + + if (elapsed) { + return ( + + + + {elapsedText} + + + ); + } + + const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND); + const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR); + const minutes = Math.floor((totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); + const seconds = totalSeconds % SECONDS_PER_MINUTE; + + const pad = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { + minimumIntegerDigits: 2, + useGrouping: false, + }); + const clock = [hours > 0 ? pad.format(hours) : null, pad.format(minutes), pad.format(seconds)] + .filter((part) => part !== null) + .join(':'); + + return ( + + {label ? ( + + {label} + + ) : null} + + + + {clock} + + + + ); +}; + +export default CountdownTimer; diff --git a/client/src/components/CountdownTimer/index.tsx b/client/src/components/CountdownTimer/index.tsx new file mode 100644 index 0000000..6c8b657 --- /dev/null +++ b/client/src/components/CountdownTimer/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CountdownTimer'; +export type { CountdownTimerProps } from './CountdownTimer'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 523b66a..022225e 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -58,6 +58,9 @@ import PublishIcon from '@mui/icons-material/RocketLaunchOutlined'; // Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls import StarIcon from '@mui/icons-material/Star'; 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'; /** * List of all available Icon names @@ -128,4 +131,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was - publish: PublishIcon, star: StarIcon, tune: TuneIcon, + requests: RequestsIcon, + payment: PaymentIcon, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index fe34d4e..f044951 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -19,6 +19,8 @@ import TrustBadge from './TrustBadge'; import DocumentUpload from './DocumentUpload'; import NurseResultCard from './NurseResultCard'; import ServicePriceRow from './ServicePriceRow'; +import CountdownTimer from './CountdownTimer'; +import BookingRequestSummaryCard from './BookingRequestSummaryCard'; export { UserInfo, @@ -40,6 +42,8 @@ export { DocumentUpload, NurseResultCard, ServicePriceRow, + CountdownTimer, + BookingRequestSummaryCard, }; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; @@ -59,3 +63,5 @@ export type { TrustBadgeProps } from './TrustBadge'; export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload'; export type { NurseResultCardProps } from './NurseResultCard'; export type { ServicePriceRowProps } from './ServicePriceRow'; +export type { CountdownTimerProps } from './CountdownTimer'; +export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 37d80e4..d881e1d 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -14,8 +14,12 @@ export const ROUTES = { // C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this). SEARCH_NURSE: '/search/nurse', BOOKINGS: '/bookings', - // Booking-request handoff target (f7 owns the form) — C3's "درخواست رزرو" lands here with intent. + // C4 booking-request form (f7) — C3's "درخواست رزرو" lands here carrying the nurse + variant + gender. BOOKING_REQUEST: '/bookings/request', + // C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status. + BOOKING_REQUEST_STATUS: '/bookings/request', + // Checkout (pay & confirm) — the C5 accept CTA hands off here (screen itself is DEFERRED → f9 stub). + CHECKOUT: '/bookings/checkout', PATIENTS: '/patients', // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. ADDRESSES: '/addresses', @@ -30,6 +34,8 @@ export const ROUTES = { // Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search). NURSE_COVERAGE: '/nurse/coverage', NURSE_BANK: '/nurse/bank', + // Incoming booking-requests inbox (f7) — pending requests + accept/reject; append `/{id}` for detail. + NURSE_REQUESTS: '/nurse/requests', // Verification (trust engine) subtree — B3 hub + the staged submission screens (B4/B5/B6). NURSE_VERIFICATION: '/nurse/verification', NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity', diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index bbe4d8d..edf8964 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -18,6 +18,7 @@ const NurseLayout: FunctionComponent = ({ children }) => { const sidebarItems: Array = useMemo( () => [ { title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, + { title: t('requests'), path: ROUTES.NURSE_REQUESTS, icon: 'requests' }, { title: t('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' }, { title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' }, { title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' }, diff --git a/client/src/services/bookingRequests/apis/clientApi.ts b/client/src/services/bookingRequests/apis/clientApi.ts new file mode 100644 index 0000000..7783d76 --- /dev/null +++ b/client/src/services/bookingRequests/apis/clientApi.ts @@ -0,0 +1,82 @@ +import { clientFetch } from '@/lib/api/client'; +import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { BOOKING_REQUEST_PAGE_SIZE } from '../constants'; +import type { + BookingRequestDto, + BookingRequestListItem, + BookingRequestListParams, + BookingRequestsApi, + CreateBookingRequestPayload, + RejectBookingRequestPayload, +} from '../types'; + +const BASE = '/api/v1/booking_requests'; + +/** + * The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice` + * (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price). + */ +type BookingRequestWireDto = Omit; + +/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */ +function toDto(wire: BookingRequestWireDto): BookingRequestDto { + return { ...wire, variantPrice: null }; +} + +/** + * Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract + * `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for + * accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and + * `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST. + * + * NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse, + * patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice` + * (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is + * ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once + * the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change). + */ +export const bookingRequestsClientApi: BookingRequestsApi = { + create: async (payload: CreateBookingRequestPayload) => + toDto( + unwrap( + await clientFetch>(`${BASE}/create`, { + method: 'POST', + body: JSON.stringify(payload), + }), + ), + ), + + get: async (id: number) => + toDto(unwrap(await clientFetch>(`${BASE}/get/${id}`))), + + list: async (params: BookingRequestListParams): 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 ?? BOOKING_REQUEST_PAGE_SIZE)); + return unwrap( + await clientFetch>>(`${BASE}/list?${query.toString()}`), + ); + }, + + accept: async (id: number) => + toDto( + unwrap(await clientFetch>(`${BASE}/accept/${id}`, { method: 'POST' })), + ), + + reject: async (id: number, payload: RejectBookingRequestPayload) => + toDto( + unwrap( + await clientFetch>(`${BASE}/reject/${id}`, { + method: 'POST', + body: JSON.stringify(payload), + }), + ), + ), + + cancel: async (id: number) => + toDto( + unwrap(await clientFetch>(`${BASE}/cancel/${id}`, { method: 'POST' })), + ), +}; diff --git a/client/src/services/bookingRequests/apis/index.ts b/client/src/services/bookingRequests/apis/index.ts new file mode 100644 index 0000000..acbc064 --- /dev/null +++ b/client/src/services/bookingRequests/apis/index.ts @@ -0,0 +1,12 @@ +import { USE_BOOKING_REQUESTS_MOCK } from '../constants'; +import type { BookingRequestsApi } from '../types'; +import { bookingRequestsClientApi } from './clientApi'; +import { bookingRequestsMockApi } from './mockApi'; + +/** + * The selected `BookingRequestsApi` implementation — the single seam the hooks import. Selection is by + * config (`USE_BOOKING_REQUESTS_MOCK`), never by scattered `if (mock)` checks. + */ +export const bookingRequestsApi: BookingRequestsApi = USE_BOOKING_REQUESTS_MOCK + ? bookingRequestsMockApi + : bookingRequestsClientApi; diff --git a/client/src/services/bookingRequests/apis/mockApi.ts b/client/src/services/bookingRequests/apis/mockApi.ts new file mode 100644 index 0000000..03f5dca --- /dev/null +++ b/client/src/services/bookingRequests/apis/mockApi.ts @@ -0,0 +1,285 @@ +import { sleep } from '@/utils'; +import { ApiError } from '@/lib/api/errors'; +import type { Paginated } from '@/lib/api/types'; +import { + BOOKING_REQUEST_PAGE_SIZE, + CUSTOMER_NOTES_MAX_LENGTH, + MOCK_PAYMENT_DEADLINE_MINUTES, + MOCK_RESPONSE_DEADLINE_MINUTES, + REJECTION_REASON_MAX_LENGTH, +} from '../constants'; +import type { + BookingRequestDisplayContext, + BookingRequestDto, + BookingRequestListItem, + BookingRequestListParams, + BookingRequestsApi, + BookingRequestStatus, + CreateBookingRequestPayload, + RejectBookingRequestPayload, + RequestRole, +} from '../types'; +import { isTerminalBookingRequestStatus } from '../types'; + +const MOCK_LATENCY_MS = 350; + +// Shared, module-level store so the customer's created request appears in the nurse inbox and a nurse +// accept/reject flips the customer's polled C5 — all within one browser session (one module instance). +// Seeded with two pending requests so a nurse-only visit sees a non-empty inbox before any create. +let store: BookingRequestDto[] = []; +let nextId = 1; + +const minutesFromNow = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString(); + +function seedRow(overrides: Partial): BookingRequestDto { + const id = nextId++; + return { + id, + status: 'pending_nurse_response', + nurseId: 1, + nurseName: 'مریم رضایی', + nurseRating: 4.9, + nurseTotalReviews: 37, + patientId: 900 + id, + patientName: 'حاج‌آقا موسوی', + variantId: 11, + variantLabel: 'مراقبت سالمند — شیفت روز', + variantPriceUnit: 'per_hour', + variantPrice: '2800000', + customerAddressId: 800 + id, + addressTitle: 'منزل', + cityId: 101, + cityNameFa: 'تهران', + cityNameEn: 'Tehran', + districtId: 1003, + districtNameFa: 'سعادت‌آباد', + districtNameEn: 'Saadat Abad', + addressLine: 'خیابان نمونه، کوچه دوم، پلاک ۱۲', + postalCode: '1998887766', + recipientName: 'زهرا موسوی', + recipientPhone: '09121234567', + requiredCaregiverGender: 'female', + requestedDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10), + requestedTimeStart: '09:00:00', + requestedTimeEnd: '13:00:00', + customerNotes: 'لطفاً در تعویض سِرُم دقت شود.', + nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES), + paymentDeadlineAt: null, + nurseRejectionReason: null, + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +store = [ + seedRow({}), + seedRow({ + nurseName: 'مریم رضایی', + patientName: 'خانم احمدی', + variantLabel: 'مراقبت پس از جراحی', + requiredCaregiverGender: 'female', + customerNotes: 'بیمار پس از عمل زانو، نیاز به کمک در جابه‌جایی دارد.', + requestedTimeStart: '15:00:00', + requestedTimeEnd: '19:00:00', + }), +]; + +/** + * Lazily transition any past-deadline row to its terminal state — the client stand-in for the server's + * background expiry sweep, so `expired_no_response` / `payment_deadline_expired` surface through the poll + * without a real cron. Runs on every read/write. + */ +function sweep(): void { + const now = Date.now(); + store = store.map((row) => { + if (row.status === 'pending_nurse_response' && Date.parse(row.nurseResponseDeadlineAt) < now) { + return { ...row, status: 'expired_no_response' as BookingRequestStatus }; + } + if ( + row.status === 'accepted_awaiting_payment' && + row.paymentDeadlineAt != null && + Date.parse(row.paymentDeadlineAt) < now + ) { + return { ...row, status: 'payment_deadline_expired' as BookingRequestStatus }; + } + return row; + }); +} + +function find(id: number): BookingRequestDto { + const row = store.find((r) => r.id === id); + // Party-scoping is not modelled in the single-session mock; existence is not leaked either way (404). + if (!row) throw new ApiError(404, 'Booking request not found', 'not_found'); + return row; +} + +/** The nurse view masks the full address (two-stage disclosure) — coarse city/district only. */ +function maskForNurse(dto: BookingRequestDto): BookingRequestDto { + return { ...dto, addressLine: null, postalCode: null, recipientName: null, recipientPhone: null }; +} + +function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestListItem { + return { + id: dto.id, + status: dto.status, + counterpartyName: role === 'nurse' ? dto.patientName : dto.nurseName, + nurseRating: role === 'customer' ? dto.nurseRating : null, + requiredCaregiverGender: dto.requiredCaregiverGender, + requestedDate: dto.requestedDate, + requestedTimeStart: dto.requestedTimeStart, + requestedTimeEnd: dto.requestedTimeEnd, + nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt, + paymentDeadlineAt: dto.paymentDeadlineAt, + customerNotes: role === 'nurse' ? dto.customerNotes : null, + }; +} + +// Actionable (non-terminal) rows first, then by soonest response deadline — mirrors the contract's +// "actionable rows sort first". +function actionableFirst(a: BookingRequestDto, b: BookingRequestDto): number { + const at = isTerminalBookingRequestStatus(a.status) ? 1 : 0; + const bt = isTerminalBookingRequestStatus(b.status) ? 1 : 0; + if (at !== bt) return at - bt; + return Date.parse(a.nurseResponseDeadlineAt) - Date.parse(b.nurseResponseDeadlineAt); +} + +function assertCreateValid(payload: CreateBookingRequestPayload): void { + if (!payload.requiredCaregiverGender) { + throw new ApiError(400, 'requiredCaregiverGender is required', 'gender_required'); + } + if (payload.requestedTimeEnd <= payload.requestedTimeStart) { + throw new ApiError(400, 'requestedTimeEnd must be after requestedTimeStart', 'invalid_time_range'); + } + if ((payload.customerNotes?.length ?? 0) > CUSTOMER_NOTES_MAX_LENGTH) { + throw new ApiError(400, 'customerNotes too long', 'notes_too_long'); + } + const start = Date.parse(`${payload.requestedDate}T${payload.requestedTimeStart}`); + if (Number.isFinite(start) && start < Date.now()) { + throw new ApiError(400, 'requestedDate/time is in the past', 'past_date'); + } +} + +function buildFromContext( + id: number, + payload: CreateBookingRequestPayload, + context: BookingRequestDisplayContext | undefined, +): BookingRequestDto { + return { + id, + status: 'pending_nurse_response', + nurseId: payload.nurseId, + nurseName: context?.nurseName ?? '', + nurseRating: context?.nurseRating ?? 0, + nurseTotalReviews: context?.nurseTotalReviews ?? 0, + patientId: payload.patientId, + patientName: context?.patientName ?? '', + variantId: payload.variantId, + variantLabel: context?.variantLabel ?? '', + variantPriceUnit: context?.variantPriceUnit ?? 'per_hour', + variantPrice: context?.variantPrice ?? null, + customerAddressId: payload.customerAddressId, + addressTitle: context?.addressTitle ?? '', + cityId: context?.cityId ?? 0, + cityNameFa: context?.cityNameFa ?? '', + cityNameEn: context?.cityNameEn ?? '', + districtId: context?.districtId ?? null, + districtNameFa: context?.districtNameFa ?? null, + districtNameEn: context?.districtNameEn ?? null, + addressLine: context?.addressLine ?? null, + postalCode: context?.postalCode ?? null, + recipientName: context?.recipientName ?? null, + recipientPhone: context?.recipientPhone ?? null, + requiredCaregiverGender: payload.requiredCaregiverGender, + requestedDate: payload.requestedDate, + requestedTimeStart: payload.requestedTimeStart, + requestedTimeEnd: payload.requestedTimeEnd, + customerNotes: payload.customerNotes?.trim() || null, + nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES), + paymentDeadlineAt: null, + nurseRejectionReason: null, + createdAt: new Date().toISOString(), + }; +} + +/** + * In-memory mock behind the `BookingRequestsApi` seam. Drives the full request lifecycle over shared + * session state so C4 → C5 → the nurse inbox → accept/reject/expire all demo end-to-end without a live + * b8 backend (and without the upstream mock domains' ids needing to exist server-side). Mirrors the real + * shapes + status/deadline/masking semantics for a one-line swap once the stack is live + * (`USE_BOOKING_REQUESTS_MOCK = false`). + */ +export const bookingRequestsMockApi: BookingRequestsApi = { + create: async (payload, context) => { + await sleep(MOCK_LATENCY_MS); + assertCreateValid(payload); + const dto = buildFromContext(nextId++, payload, context); + store = [dto, ...store]; + return dto; + }, + + get: async (id, role) => { + await sleep(MOCK_LATENCY_MS); + sweep(); + const dto = find(id); + return role === 'nurse' ? maskForNurse(dto) : dto; + }, + + list: async (params: BookingRequestListParams): Promise> => { + await sleep(MOCK_LATENCY_MS); + sweep(); + const matched = [...store] + .filter((row) => (params.status ? row.status === params.status : true)) + .sort(actionableFirst) + .map((row) => toListItem(row, params.role)); + + const page = params.page ?? 1; + const pageSize = params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE; + const start = (page - 1) * pageSize; + return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize }; + }, + + accept: async (id) => { + await sleep(MOCK_LATENCY_MS); + sweep(); + const dto = find(id); + if (dto.status !== 'pending_nurse_response') { + throw new ApiError(409, 'Request is not pending', 'not_pending'); + } + const updated: BookingRequestDto = { + ...dto, + status: 'accepted_awaiting_payment', + paymentDeadlineAt: minutesFromNow(MOCK_PAYMENT_DEADLINE_MINUTES), + }; + store = store.map((row) => (row.id === id ? updated : row)); + return maskForNurse(updated); + }, + + reject: async (id, payload: RejectBookingRequestPayload) => { + await sleep(MOCK_LATENCY_MS); + sweep(); + const dto = find(id); + const reason = payload.reason?.trim() ?? ''; + if (!reason) throw new ApiError(400, 'reason is required', 'reason_required'); + if (reason.length > REJECTION_REASON_MAX_LENGTH) { + throw new ApiError(400, 'reason too long', 'reason_too_long'); + } + if (dto.status !== 'pending_nurse_response') { + throw new ApiError(409, 'Request is not pending', 'not_pending'); + } + const updated: BookingRequestDto = { ...dto, status: 'rejected_by_nurse', nurseRejectionReason: reason }; + store = store.map((row) => (row.id === id ? updated : row)); + return maskForNurse(updated); + }, + + cancel: async (id) => { + await sleep(MOCK_LATENCY_MS); + sweep(); + const dto = find(id); + if (dto.status !== 'pending_nurse_response' && dto.status !== 'accepted_awaiting_payment') { + throw new ApiError(409, 'Request can no longer be cancelled', 'not_cancellable'); + } + const updated: BookingRequestDto = { ...dto, status: 'cancelled_by_customer' }; + store = store.map((row) => (row.id === id ? updated : row)); + return updated; + }, +}; diff --git a/client/src/services/bookingRequests/constants.ts b/client/src/services/bookingRequests/constants.ts new file mode 100644 index 0000000..3ddfef7 --- /dev/null +++ b/client/src/services/bookingRequests/constants.ts @@ -0,0 +1,43 @@ +/** + * When true, the booking-requests domain is served by the in-memory mock (`apis/mockApi.ts`) behind the + * `BookingRequestsApi` seam. + * + * **Mock is primary this phase.** The b8 endpoints are live server-side, but every *input* to a request + * — the nurse (search, f6), the patient (patients, f2), the address (addresses, f3) — is itself served by + * a **mock-primary** client domain today, so a real `booking_requests/create` would reference ids that + * exist only in those in-memory stores. Running the whole flow end-to-end (create → nurse inbox → accept + * → the customer's C5 flips → expiry) therefore needs a mock that shares the same session state. The mock + * drives exactly that. Additionally the contract DTO omits the variant price the summary card renders + * (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component + * change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`). + */ +export const USE_BOOKING_REQUESTS_MOCK = true; + +/** + * The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition + * (accept / reject / expire) surfaces without a manual refresh; polling **stops** on a terminal/`converted` + * status (see the hooks' `refetchInterval` guard). 15s balances freshness against request volume. + */ +export const BOOKING_REQUEST_POLL_MS = 15 * 1000; + +/** A single request is cheap and changes only on the other party's action — a short stale window. */ +export const BOOKING_REQUEST_STALE_TIME = 10 * 1000; +export const BOOKING_REQUEST_GC_TIME = 5 * 60 * 1000; + +/** api-conventions default/max page sizes (max 100 server-side); an inbox page. */ +export const BOOKING_REQUEST_PAGE_SIZE = 20; + +/** `customerNotes` limit (contract: ≤ 1000) — enforced client-side before the CTA. */ +export const CUSTOMER_NOTES_MAX_LENGTH = 1000; + +/** `nurseRejectionReason` limit (contract: ≤ 500) — enforced client-side in the reject dialog. */ +export const REJECTION_REASON_MAX_LENGTH = 500; + +/** + * Mock-only deadline windows. The **payment** window is contract-accurate (30 min); the **response** + * window is a demo-shortened stand-in for the server's real 24h so a session can observe the + * `expired_no_response` terminal path. Neither is used on the real path — the server freezes the true + * deadlines from `IPlatformConfig`. Documented in the phase report + mocks note. + */ +export const MOCK_RESPONSE_DEADLINE_MINUTES = 30; +export const MOCK_PAYMENT_DEADLINE_MINUTES = 30; diff --git a/client/src/services/bookingRequests/hooks/useAcceptBookingRequest.ts b/client/src/services/bookingRequests/hooks/useAcceptBookingRequest.ts new file mode 100644 index 0000000..4190fdf --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useAcceptBookingRequest.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; + +/** + * Nurse accept — opens the 30-minute payment window server-side. On success it invalidates every inbox + * list (so the request leaves the pending inbox immediately) and the request detail (so the customer's + * polled C5 reflects the accept). A stale accept (past deadline / not pending) returns `409` via + * `mutation.error` — the caller surfaces it and refetches. + */ +export function useAcceptBookingRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => bookingRequestsApi.accept(id), + onSuccess: (dto) => { + queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() }); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) }); + }, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useBookingRequest.ts b/client/src/services/bookingRequests/hooks/useBookingRequest.ts new file mode 100644 index 0000000..e51cf54 --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useBookingRequest.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; +import { BOOKING_REQUEST_GC_TIME, BOOKING_REQUEST_POLL_MS, BOOKING_REQUEST_STALE_TIME } from '../constants'; +import { isTerminalBookingRequestStatus, type RequestRole } from '../types'; + +/** + * A single booking request (C5 customer view + the nurse request detail). **Polls** while the status is + * non-terminal so an accept / reject / expire transition surfaces without a manual refresh, and **stops** + * polling once a terminal/`converted` status is reached. `role` drives the mock's nurse-view masking (the + * real server infers it from auth). Enabled only when an id is present. + */ +export function useBookingRequest(id: number | undefined, role: RequestRole) { + return useQuery({ + queryKey: bookingRequestKeys.detail(id ?? -1), + queryFn: () => bookingRequestsApi.get(id as number, role), + enabled: id != null && id > 0, + staleTime: BOOKING_REQUEST_STALE_TIME, + gcTime: BOOKING_REQUEST_GC_TIME, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status && isTerminalBookingRequestStatus(status) ? false : BOOKING_REQUEST_POLL_MS; + }, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useCancelBookingRequest.ts b/client/src/services/bookingRequests/hooks/useCancelBookingRequest.ts new file mode 100644 index 0000000..a34665d --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useCancelBookingRequest.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; + +/** + * Customer cancel — withdraws a request that is still `pending_nurse_response` or + * `accepted_awaiting_payment` (before paying). Invalidates the lists + the request detail so both inboxes + * and the C5 reflect the cancellation. A cancel on a terminal request returns `409` via `mutation.error`. + */ +export function useCancelBookingRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => bookingRequestsApi.cancel(id), + onSuccess: (dto) => { + queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() }); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) }); + }, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useCreateBookingRequest.ts b/client/src/services/bookingRequests/hooks/useCreateBookingRequest.ts new file mode 100644 index 0000000..b5cb487 --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useCreateBookingRequest.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; +import type { BookingRequestDisplayContext, CreateBookingRequestPayload } from '../types'; + +interface CreateArgs { + payload: CreateBookingRequestPayload; + /** Mock-only display aid (ignored by the real client) so the created request renders fully on C5. */ + context?: BookingRequestDisplayContext; +} + +/** + * Creates a booking request (C4 → C5). On success it seeds the new request into the detail cache and + * invalidates the customer inbox so both reflect it immediately; the page owns the navigation to C5. + * Domain `400`s (same-gender mismatch, tenancy 404, inactive variant, invalid time/date) surface via + * `mutation.error` — the fetch layer owns 401/403/5xx toasts. + */ +export function useCreateBookingRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ payload, context }: CreateArgs) => bookingRequestsApi.create(payload, context), + onSuccess: (dto) => { + queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() }); + }, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useCustomerRequests.ts b/client/src/services/bookingRequests/hooks/useCustomerRequests.ts new file mode 100644 index 0000000..fca9f01 --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useCustomerRequests.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; +import { + BOOKING_REQUEST_GC_TIME, + BOOKING_REQUEST_PAGE_SIZE, + BOOKING_REQUEST_STALE_TIME, +} from '../constants'; +import type { BookingRequestStatus } from '../types'; + +/** + * The customer's own booking-requests inbox (`list?role=customer`), optionally filtered by status. C5 is + * keyed by a single request id, so this list is not required by a phase-7 screen — it exists so f8's + * "my bookings" surface (and any future customer request list) reads the same cached domain, and so a + * create/cancel invalidation has a list to refresh. + */ +export function useCustomerRequests(status?: BookingRequestStatus, page = 1) { + const isAuthenticated = useIsAuthenticated(); + const params = { role: 'customer' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE }; + return useQuery({ + queryKey: bookingRequestKeys.list(params), + queryFn: () => bookingRequestsApi.list(params), + enabled: isAuthenticated, + staleTime: BOOKING_REQUEST_STALE_TIME, + gcTime: BOOKING_REQUEST_GC_TIME, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useNurseRequestInbox.ts b/client/src/services/bookingRequests/hooks/useNurseRequestInbox.ts new file mode 100644 index 0000000..d2f6b8f --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useNurseRequestInbox.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { useIsAuthenticated } from '@/hooks'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; +import { + BOOKING_REQUEST_GC_TIME, + BOOKING_REQUEST_PAGE_SIZE, + BOOKING_REQUEST_POLL_MS, + BOOKING_REQUEST_STALE_TIME, +} from '../constants'; +import type { BookingRequestStatus } from '../types'; + +/** + * The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly + * polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this + * list so an actioned request leaves the pending inbox immediately. + */ +export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) { + const isAuthenticated = useIsAuthenticated(); + const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE }; + return useQuery({ + queryKey: bookingRequestKeys.nurseInbox(params), + queryFn: () => bookingRequestsApi.list(params), + enabled: isAuthenticated, + staleTime: BOOKING_REQUEST_STALE_TIME, + gcTime: BOOKING_REQUEST_GC_TIME, + refetchInterval: BOOKING_REQUEST_POLL_MS, + }); +} diff --git a/client/src/services/bookingRequests/hooks/useRejectBookingRequest.ts b/client/src/services/bookingRequests/hooks/useRejectBookingRequest.ts new file mode 100644 index 0000000..f877311 --- /dev/null +++ b/client/src/services/bookingRequests/hooks/useRejectBookingRequest.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { bookingRequestsApi } from '../apis'; +import { bookingRequestKeys } from '../keys'; +import type { RejectBookingRequestPayload } from '../types'; + +interface RejectArgs { + id: number; + payload: RejectBookingRequestPayload; +} + +/** + * Nurse reject (with a required reason). On success it invalidates every inbox list (the request leaves + * the pending inbox) and the request detail (the customer's polled C5 shows the rejected terminal card + * with the reason). A stale reject returns `409` via `mutation.error`. + */ +export function useRejectBookingRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: RejectArgs) => bookingRequestsApi.reject(id, payload), + onSuccess: (dto) => { + queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() }); + queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) }); + }, + }); +} diff --git a/client/src/services/bookingRequests/index.ts b/client/src/services/bookingRequests/index.ts new file mode 100644 index 0000000..e6ded86 --- /dev/null +++ b/client/src/services/bookingRequests/index.ts @@ -0,0 +1,11 @@ +/** + * Booking-requests domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). + * Import types/keys/apis directly from their files when needed. + */ +export { useCreateBookingRequest } from './hooks/useCreateBookingRequest'; +export { useBookingRequest } from './hooks/useBookingRequest'; +export { useNurseRequestInbox } from './hooks/useNurseRequestInbox'; +export { useCustomerRequests } from './hooks/useCustomerRequests'; +export { useAcceptBookingRequest } from './hooks/useAcceptBookingRequest'; +export { useRejectBookingRequest } from './hooks/useRejectBookingRequest'; +export { useCancelBookingRequest } from './hooks/useCancelBookingRequest'; diff --git a/client/src/services/bookingRequests/keys.ts b/client/src/services/bookingRequests/keys.ts new file mode 100644 index 0000000..08fb169 --- /dev/null +++ b/client/src/services/bookingRequests/keys.ts @@ -0,0 +1,23 @@ +import type { BookingRequestListParams } from './types'; + +/** + * React Query key factory for the booking-requests domain (hierarchical, per the `services/{domain}` + * pattern). The list key carries the role + status filter so the customer inbox and the nurse inbox are + * distinct cache entries and a nurse accept/reject invalidates exactly the right list. + */ +export const bookingRequestKeys = { + all: ['bookingRequests'] as const, + lists: () => [...bookingRequestKeys.all, 'list'] as const, + list: (params: BookingRequestListParams) => + [ + ...bookingRequestKeys.lists(), + params.role, + params.status ?? 'all', + params.page ?? 1, + params.pageSize ?? 0, + ] as const, + /** The nurse incoming-requests inbox (a `list` with `role='nurse'`), factored for readability. */ + nurseInbox: (params: BookingRequestListParams) => bookingRequestKeys.list(params), + details: () => [...bookingRequestKeys.all, 'detail'] as const, + detail: (id: number) => [...bookingRequestKeys.details(), id] as const, +}; diff --git a/client/src/services/bookingRequests/types.ts b/client/src/services/bookingRequests/types.ts new file mode 100644 index 0000000..f8d92a9 --- /dev/null +++ b/client/src/services/bookingRequests/types.ts @@ -0,0 +1,198 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; +import type { PriceUnit } from '@/services/catalog/types'; + +/** + * Booking-requests domain — the **pre-payment intent** layer of the engagement lifecycle (b8). A + * customer requests a nurse for a patient/variant/address/date; the nurse accepts (opening a + * config-driven 30-minute payment window) or rejects; both sides read a role-scoped inbox and a single + * request. **There is no money and no `bookings` row here** — that conversion is b9/b10. Shapes mirror + * the b8 contract (`dev/contracts/domains/booking-requests.md`); the wire is **camelCase** and + * `clientFetch` unwraps the `ApiEnvelope`, so these are the post-`unwrap()` payloads. + * + * Load-bearing semantics (contract "Key semantics" + phase §5): + * - **Deadlines are server-frozen absolute UTC instants.** The client renders countdowns from + * `nurseResponseDeadlineAt` / `paymentDeadlineAt` against `Date.now()` — it never computes a deadline. + * `paymentDeadlineAt` is `null` until the nurse accepts. + * - **`requiredCaregiverGender` is first-class and required on create** (`male`/`female`/`any`) — never + * silently defaulted. `male`/`female` must match the nurse's gender (a mismatch is a `400`). + * - **Two-stage clinical disclosure.** The nurse sees **only** `customerNotes` (stage-1 plaintext) and a + * **masked** address (`addressLine`/`postalCode`/recipient are `null` in the nurse view). Full + * clinical/care instructions do not exist until b9 and must never appear in this UI. + * - **Forward-only status machine.** Terminal states have no outgoing edges; a stale accept/reject/cancel + * returns `409`. + */ + +/** The same-gender matching facet carried from search into the request (`any` = فرقی ندارد). */ +export type RequiredCaregiverGender = 'male' | 'female' | 'any'; + +/** The full `booking_request_status` enum (contract). */ +export type BookingRequestStatus = + | 'pending_nurse_response' + | 'accepted_awaiting_payment' + | 'converted' + | 'rejected_by_nurse' + | 'expired_no_response' + | 'payment_deadline_expired' + | 'cancelled_by_customer'; + +/** Which inbox to read — disambiguates a user who holds both roles (contract `list?role=`). */ +export type RequestRole = 'customer' | 'nurse'; + +/** Terminal states: no outgoing edges, so polling stops and no accept/reject/cancel is offered. */ +export const TERMINAL_BOOKING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [ + 'converted', + 'rejected_by_nurse', + 'expired_no_response', + 'payment_deadline_expired', + 'cancelled_by_customer', +] as const; + +export function isTerminalBookingRequestStatus(status: BookingRequestStatus): boolean { + return TERMINAL_BOOKING_REQUEST_STATUSES.includes(status); +} + +/** + * `BookingRequestDto` — the full single-request view. The customer/admin view carries the full address; + * the **nurse view masks it** (`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are `null`), + * leaving only the coarse city/district. + * + * `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit` + * but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the + * real client leaves it `null` (the summary then hides the amount) until the field lands. + */ +export interface BookingRequestDto { + id: number; + status: BookingRequestStatus; + nurseId: number; + nurseName: string; + nurseRating: number; + nurseTotalReviews: number; + patientId: number; + patientName: string; + variantId: number; + variantLabel: string; + variantPriceUnit: PriceUnit; + /** Client-augmented (REQ-013): IRR digit-string, or `null` on the real path until the DTO carries it. */ + variantPrice: string | null; + customerAddressId: number; + addressTitle: string; + cityId: number; + cityNameFa: string; + cityNameEn: string; + districtId: number | null; + districtNameFa: string | null; + districtNameEn: string | null; + /** Full-address PII — present in the customer/admin view, **`null` in the nurse view** (masked). */ + addressLine: string | null; + postalCode: string | null; + recipientName: string | null; + recipientPhone: string | null; + requiredCaregiverGender: RequiredCaregiverGender | null; + /** ISO date `YYYY-MM-DD`. */ + requestedDate: string; + /** `HH:mm:ss`. */ + requestedTimeStart: string; + requestedTimeEnd: string; + /** Stage-1 plaintext — the ONLY clinical text the nurse sees before accepting. */ + customerNotes: string | null; + /** Server-frozen absolute UTC instant. */ + nurseResponseDeadlineAt: string; + /** Server-frozen UTC; `null` until the nurse accepts. */ + paymentDeadlineAt: string | null; + nurseRejectionReason: string | null; + createdAt: string; +} + +/** + * `BookingRequestListItemDto` — an inbox row. The **customer** inbox sets `counterpartyName` = nurse name + * (+ `nurseRating`); the **nurse** inbox sets `counterpartyName` = patient name (+ `customerNotes`, + * stage-1 only). Actionable rows sort first server-side. + */ +export interface BookingRequestListItem { + id: number; + status: BookingRequestStatus; + counterpartyName: string; + /** Customer view only; `null` in the nurse inbox. */ + nurseRating: number | null; + requiredCaregiverGender: RequiredCaregiverGender | null; + requestedDate: string; + requestedTimeStart: string; + requestedTimeEnd: string; + nurseResponseDeadlineAt: string; + paymentDeadlineAt: string | null; + /** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */ + customerNotes: string | null; +} + +/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */ +export interface CreateBookingRequestPayload { + nurseId: number; + variantId: number; + patientId: number; + customerAddressId: number; + /** `YYYY-MM-DD`. */ + requestedDate: string; + /** `HH:mm:ss`. */ + requestedTimeStart: string; + requestedTimeEnd: string; + /** Required, never silently defaulted. */ + requiredCaregiverGender: RequiredCaregiverGender; + /** ≤ 1000 chars; the only text the nurse sees pre-accept. */ + customerNotes?: string | null; +} + +/** The `booking_requests/reject` body. */ +export interface RejectBookingRequestPayload { + /** Required, ≤ 500 chars. */ + reason: string; +} + +/** `booking_requests/list` query params (role-scoped, paginated, optional status filter). */ +export interface BookingRequestListParams extends PageParams { + role: RequestRole; + status?: BookingRequestStatus; +} + +/** + * Display fields the mock needs to build a faithful DTO, resolved by the C4 form from the already-loaded + * nurse profile / patient / address / variant queries. **The real `clientApi` ignores this** — the + * server returns the joined DTO from the ids alone; only the mock (which cannot read the other domains' + * in-memory stores) uses it. This is the b8 analogue of the search/patients/addresses "client-augmented" + * display fields, kept off the wire `CreateBookingRequestPayload`. + */ +export interface BookingRequestDisplayContext { + nurseName: string; + nurseRating: number; + nurseTotalReviews: number; + patientName: string; + variantLabel: string; + variantPriceUnit: PriceUnit; + variantPrice: string | null; + addressTitle: string; + cityId: number; + cityNameFa: string; + cityNameEn: string; + districtId: number | null; + districtNameFa: string | null; + districtNameEn: string | null; + addressLine: string | null; + postalCode: string | null; + recipientName: string | null; + recipientPhone: string | null; +} + +/** + * The booking-requests API seam — the real HTTP client and the in-memory mock both implement this + * interface; selection is by config (`USE_BOOKING_REQUESTS_MOCK`), never scattered `if (mock)` checks. + * + * `get`/`create` take an optional `role`/`context` that only the mock uses (to mask the nurse view and + * to build a faithful DTO respectively); the real client infers the view from auth and ignores them. + */ +export interface BookingRequestsApi { + create(payload: CreateBookingRequestPayload, context?: BookingRequestDisplayContext): Promise; + get(id: number, role?: RequestRole): Promise; + list(params: BookingRequestListParams): Promise>; + accept(id: number): Promise; + reject(id: number, payload: RejectBookingRequestPayload): Promise; + cancel(id: number): Promise; +} diff --git a/dev/contracts/domains/reviews-records.md b/dev/contracts/domains/reviews-records.md new file mode 100644 index 0000000..7514b3a --- /dev/null +++ b/dev/contracts/domains/reviews-records.md @@ -0,0 +1,121 @@ +# Contract — Reviews & Patient Care Records (backend phase b14) + +> The trust-and-continuity surface: a customer leaves **one moderated review per completed booking**; an +> admin/moderator transitions it (recomputing the nurse's public rating from source on every transition); the +> public reads only ever see published reviews + the aggregate; and nurses author **encrypted, patient-scoped** +> clinical notes readable only under a strict clinical-access rule. 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/swagger.v1.json). + +**Status:** live as of backend-phase-b14 · **Frontend consumer:** frontend-phase-f13-b14 + +There is **no money** in this domain. Ratings are integers 1–5; the aggregate `averageRating` is a decimal +(2-dp, e.g. `4.5`). Timestamps are ISO-8601. List query params are **camelCase** (`page`, `pageSize`, +`status`). The response envelope is the standard `{ data, … }`; the shapes below are the `data`. + +## Enums used +- `moderationStatus`: `pending_moderation` | `published` | `hidden` | `rejected`. A review is born + `pending_moderation` and is **never public / never counted** until `published`. Only `published` reviews are + returned by any public read and only `published` reviews feed the aggregate. +- Moderation **action** (the `PATCH` body): `publish` | `hide` | `reject` | `unpublish`. `hide`/`reject` + require a `reason`; `unpublish` returns a published review to `pending_moderation`. +- Review **tag codes** (seeded vocabulary): `punctual` | `professional` | `clean` | `kind` | `communicative`. + +## Reviews + +### `POST api/v1/bookings/{bookingId}/review` +- **Purpose:** The customer submits the one review for a completed booking. +- **Auth:** authenticated customer who **owns** the booking (tenancy enforced in the handler). +- **Path params:** `bookingId` (long). +- **Request body:** `{ "rating": 5, "body": "great care", "tagCodes": ["punctual","kind"] }` — `rating` 1–5 + required; `body` optional (≤ 2000); `tagCodes` optional (validated against the active vocabulary). +- **Success `200` (`data`):** `SubmitReviewResult` — `{ id, moderationStatus, lowRatingAlertRaised }`. The status + is `pending_moderation` by default (the AI pre-screen keeps clean text pending; a banned-word hit auto-hides). +- **Failure cases:** `400` rating out of 1–5 / unknown tag code; `401` unauthenticated; `403` caller is not a + customer; `404` booking not found **or not owned** (a cross-tenant booking is a not-found, never a leak); a + plain failure when the booking is **not completed/closed**; `409` the booking is **already reviewed** (1:1). +- **Notes:** A rating **≤ `min_rating_for_support_alert`** (config, default 2) raises an internal `low_rating` + `support_alert` (never surfaced on any user response). The new review does **not** appear in the public list + until published. + +### `POST api/v1/reviews/{reviewId}/tags` +- **Purpose:** Replace a review's standardized tags with exactly the requested set. +- **Auth:** the review's **author** or a moderator (admin/super_admin/moderation) — enforced in the handler. +- **Path params:** `reviewId` (long). **Request body:** `{ "tagCodes": ["punctual","professional"] }`. +- **Success `200` (`data`):** `ReviewTagsResult` — `{ reviewId, tagCodes }`. +- **Failure cases:** `400` unknown tag code; `401`; `403` not the author and not a moderator; `404` review not + found. The `UNIQUE(reviewId, tagCode)` forbids a duplicate tag (the set is de-duplicated server-side). + +### `PATCH api/v1/reviews/{reviewId}/status` +- **Purpose:** Admin/moderator moderation transition (the human decision authority; always overrides the AI). +- **Auth:** admin / moderator (dynamic-permission policy). +- **Path params:** `reviewId` (long). **Request body:** `{ "action": "publish", "reason": null }` — `hide`/`reject` + require a non-empty `reason` (≤ 500). +- **Success `200` (`data`):** `ModerateReviewResult` — `{ id, moderationStatus, averageRating, totalReviews }` + (the recomputed-from-source nurse aggregate). +- **Failure cases:** `400` unknown action / missing reason on hide|reject; `401`; `403` non-admin; `404` review + not found. +- **Notes:** **Every** transition recomputes `nurse_profiles.averageRating`/`totalReviews` from the nurse's + currently-`published` reviews (not an incremental delta) and refreshes the search index — in the **same + transaction** as the status change — so hiding a low rating lowers the count and re-derives the average. + +### `GET api/v1/nurses/{nurseProfileId}/reviews` — public +- **Purpose:** The public reviews for a nurse: the rating aggregate + a page of **published** reviews. +- **Auth:** anonymous. **Path params:** `nurseProfileId` (long). **Query:** `page` (default 1), `pageSize` + (default 20, max 100). +- **Success `200` (`data`):** `NurseReviewsResult` — `{ aggregate: { averageRating, publishedCount }, + reviews: PagedResult }`. `ReviewListItemDto` = `{ id, rating, body, tagCodes[], createdAt }` + — **never** carries moderation internals. An unknown nurse returns a zero aggregate + empty page (`200`). +- **Notes:** The publish gate is enforced at the query layer — a `pending_moderation`/`hidden`/`rejected` review + is never returned and never counted. The aggregate is cached and invalidated on every transition. + +### `GET api/v1/nurses/{nurseProfileId}/review_tags` — public +- **Purpose:** The per-nurse tag rollup ("% punctual") over published reviews. +- **Auth:** anonymous. **Path params:** `nurseProfileId` (long). +- **Success `200` (`data`):** `NurseTagAggregatesResult` — `{ publishedReviewCount, tags: TagAggregateDto[] }` + where `TagAggregateDto` = `{ code, labelFa, labelEn, count, percentage }` (percentage of published reviews, 1-dp). + The active seeded vocabulary is always returned (zero counts for a nurse with no published reviews). + +### `GET api/v1/admin/reviews/moderation_queue` — admin +- **Purpose:** The moderation worklist. +- **Auth:** admin / moderator. **Query:** `status` (default `pending_moderation`; any `moderationStatus`), + `page`, `pageSize`. +- **Success `200` (`data`):** `PagedResult` — `{ id, bookingId, nurseProfileId, + customerProfileId, rating, body, moderationStatus, moderationReason, lowRatingAlertId, createdAt }`. The + `lowRatingAlertId` is the linked internal alert (id only — support alerts stay internal). +- **Failure cases:** `400` unknown status; `401`; `403` non-admin. + +## Patient care records + +Clinical bodies are **encrypted at rest** and returned **decrypted only after the access check passes**. The +access rule is enforced in the handler, not just the route policy. + +**Access matrix** (both endpoints): + +| Caller | Write | Read | +| --- | --- | --- | +| Nurse with a **confirmed** (or in-progress/completed/disputed/closed) booking for the patient | ✅ | ✅ | +| Nurse **without** such a booking | ❌ `403` | ❌ `403` | +| The patient's **owning customer** | ❌ (only nurses author) | ✅ | +| Admin / super_admin | ❌ (only nurses author) | ✅ | +| Anyone else | ❌ | ❌ `403` | + +### `POST api/v1/patients/{patientId}/care_records` +- **Purpose:** A nurse authors a clinical note for a patient (patient-scoped; the note is encrypted before persist). +- **Auth:** authenticated nurse with a qualifying booking for the patient. +- **Path params:** `patientId` (long). **Request body:** `{ "bookingId": 123, "body": "…" }` — `bookingId` + optional provenance (which visit produced the note); `body` required (≤ 8000). +- **Success `200` (`data`):** `WriteCareRecordResult` — `{ id, patientId, recordedAt }`. +- **Failure cases:** `401`; `403` caller is not a nurse **or** has no qualifying booking for the patient; `404` + patient not found; `400` empty body. + +### `GET api/v1/patients/{patientId}/care_records` +- **Purpose:** The patient-scoped longitudinal history, newest first. +- **Auth:** owning customer / nurse with a qualifying booking / admin (see the matrix). +- **Path params:** `patientId` (long). **Query:** `page`, `pageSize`. +- **Success `200` (`data`):** `PagedResult` — `CareRecordDto` = `{ id, patientId, bookingId, + nurseProfileId, nurseName, body, recordedAt }` (`body` decrypted). Ordered by `recordedAt DESC`. +- **Failure cases:** `401`; `403` no clinical access; `404` patient not found. +- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole + history (not just their own booking's notes). diff --git a/dev/contracts/openapi/swagger.v1.json b/dev/contracts/openapi/swagger.v1.json index c80ae49..5b309f5 100644 --- a/dev/contracts/openapi/swagger.v1.json +++ b/dev/contracts/openapi/swagger.v1.json @@ -3012,6 +3012,100 @@ ] } }, + "/api/v1/admin/reviews/moderation_queue": { + "get": { + "tags": [ + "AdminReviews" + ], + "operationId": "AdminReviews_ModerationQueue", + "parameters": [ + { + "name": "Status", + "in": "query", + "schema": { + "type": "string", + "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/ApiResultOfPagedResultOfModerationQueueItemDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/admin_search/rebuild_index": { "post": { "tags": [ @@ -4637,6 +4731,95 @@ ] } }, + "/api/v1/bookings/{bookingId}/review": { + "post": { + "tags": [ + "BookingReviews" + ], + "operationId": "BookingReviews_Review", + "parameters": [ + { + "name": "bookingId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitReviewBody" + } + } + }, + "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/ApiResultOfSubmitReviewResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/bookings/convert": { "post": { "tags": [ @@ -7293,7 +7476,7 @@ "tags": [ "Me" ], - "description": "Role claims live inside the access token \u2014 after selecting a role the client should\n refresh its tokens to pick the new role up.", + "description": "Role claims live inside the access token — after selecting a role the client should\n refresh its tokens to pick the new role up.", "operationId": "Me_SelectRole", "requestBody": { "x-name": "command", @@ -8408,6 +8591,170 @@ } } }, + "/api/v1/nurses/{nurseProfileId}/reviews": { + "get": { + "tags": [ + "Nurses" + ], + "operationId": "Nurses_Reviews", + "parameters": [ + { + "name": "nurseProfileId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + }, + "x-position": 2 + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + }, + "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/ApiResultOfNurseReviewsResult" + } + } + } + } + } + } + }, + "/api/v1/nurses/{nurseProfileId}/review_tags": { + "get": { + "tags": [ + "Nurses" + ], + "operationId": "Nurses_ReviewTags", + "parameters": [ + { + "name": "nurseProfileId", + "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/ApiResultOfNurseTagAggregatesResult" + } + } + } + } + } + } + }, "/api/v1/nurse_service_areas/add": { "post": { "tags": [ @@ -9583,6 +9930,190 @@ ] } }, + "/api/v1/patients/{patientId}/care_records": { + "post": { + "tags": [ + "PatientCareRecords" + ], + "operationId": "PatientCareRecords_Write", + "parameters": [ + { + "name": "patientId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WriteCareRecordBody" + } + } + }, + "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/ApiResultOfWriteCareRecordResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + }, + "get": { + "tags": [ + "PatientCareRecords" + ], + "operationId": "PatientCareRecords_History", + "parameters": [ + { + "name": "patientId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + }, + "x-position": 2 + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + }, + "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/ApiResultOfPagedResultOfCareRecordDto" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/patients/create": { "post": { "tags": [ @@ -10527,6 +11058,184 @@ ] } }, + "/api/v1/reviews/{reviewId}/tags": { + "post": { + "tags": [ + "Reviews" + ], + "operationId": "Reviews_Tags", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachReviewTagsBody" + } + } + }, + "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/ApiResultOfReviewTagsResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, + "/api/v1/reviews/{reviewId}/status": { + "patch": { + "tags": [ + "Reviews" + ], + "operationId": "Reviews_Status", + "parameters": [ + { + "name": "reviewId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + }, + "x-position": 1 + } + ], + "requestBody": { + "x-name": "body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModerateReviewBody" + } + } + }, + "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/ApiResultOfModerateReviewResult" + } + } + } + } + }, + "security": [ + { + "Bearer": [] + } + ] + } + }, "/api/v1/search/nurses": { "get": { "tags": [ @@ -13013,6 +13722,98 @@ } } }, + "ApiResultOfPagedResultOfModerationQueueItemDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfModerationQueueItemDto" + } + ] + } + } + } + ] + }, + "PagedResultOfModerationQueueItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ModerationQueueItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ModerationQueueItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64" + }, + "nurseProfileId": { + "type": "integer", + "format": "int64" + }, + "customerProfileId": { + "type": "integer", + "format": "int64" + }, + "rating": { + "type": "integer", + "format": "int32" + }, + "body": { + "type": "string", + "nullable": true + }, + "moderationStatus": { + "type": "string" + }, + "moderationReason": { + "type": "string", + "nullable": true + }, + "lowRatingAlertId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResultOfSearchIndexRebuildResult": { "allOf": [ { @@ -14055,6 +14856,65 @@ } } }, + "ApiResultOfSubmitReviewResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/SubmitReviewResult" + } + ] + } + } + } + ] + }, + "SubmitReviewResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "moderationStatus": { + "type": "string" + }, + "lowRatingAlertRaised": { + "type": "boolean" + } + } + }, + "SubmitReviewBody": { + "type": "object", + "description": "The review body (the booking id comes from the route).", + "additionalProperties": false, + "properties": { + "rating": { + "type": "integer", + "format": "int32" + }, + "body": { + "type": "string", + "nullable": true + }, + "tagCodes": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + } + } + }, "ApiResultOfBookingDetailDto": { "allOf": [ { @@ -15983,6 +16843,166 @@ } } }, + "ApiResultOfNurseReviewsResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NurseReviewsResult" + } + ] + } + } + } + ] + }, + "NurseReviewsResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "aggregate": { + "$ref": "#/components/schemas/NurseReviewAggregateDto" + }, + "reviews": { + "$ref": "#/components/schemas/PagedResultOfReviewListItemDto" + } + } + }, + "NurseReviewAggregateDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "averageRating": { + "type": "number", + "format": "decimal" + }, + "publishedCount": { + "type": "integer", + "format": "int32" + } + } + }, + "PagedResultOfReviewListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/ReviewListItemDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "ReviewListItemDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "rating": { + "type": "integer", + "format": "int32" + }, + "body": { + "type": "string", + "nullable": true + }, + "tagCodes": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResultOfNurseTagAggregatesResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/NurseTagAggregatesResult" + } + ] + } + } + } + ] + }, + "NurseTagAggregatesResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "publishedReviewCount": { + "type": "integer", + "format": "int32" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagAggregateDto" + } + } + } + }, + "TagAggregateDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "labelFa": { + "type": "string" + }, + "labelEn": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" + }, + "percentage": { + "type": "number", + "format": "decimal" + } + } + }, "ApiResultOfNurseServiceAreaDto": { "allOf": [ { @@ -16573,6 +17593,141 @@ } } }, + "ApiResultOfWriteCareRecordResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/WriteCareRecordResult" + } + ] + } + } + } + ] + }, + "WriteCareRecordResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "patientId": { + "type": "integer", + "format": "int64" + }, + "recordedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "WriteCareRecordBody": { + "type": "object", + "description": "The care-record body (the patient id comes from the route).", + "additionalProperties": false, + "properties": { + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "body": { + "type": "string", + "nullable": true + } + } + }, + "ApiResultOfPagedResultOfCareRecordDto": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/PagedResultOfCareRecordDto" + } + ] + } + } + } + ] + }, + "PagedResultOfCareRecordDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "items": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/CareRecordDto" + } + }, + "total": { + "type": "integer", + "format": "int32" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "pageSize": { + "type": "integer", + "format": "int32" + } + } + }, + "CareRecordDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "patientId": { + "type": "integer", + "format": "int64" + }, + "bookingId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "nurseProfileId": { + "type": "integer", + "format": "int64" + }, + "nurseName": { + "type": "string", + "nullable": true + }, + "body": { + "type": "string" + }, + "recordedAt": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResultOfPatientDto": { "allOf": [ { @@ -17035,6 +18190,114 @@ } } }, + "ApiResultOfReviewTagsResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ReviewTagsResult" + } + ] + } + } + } + ] + }, + "ReviewTagsResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "reviewId": { + "type": "integer", + "format": "int64" + }, + "tagCodes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "AttachReviewTagsBody": { + "type": "object", + "description": "Tag-attach body (the review id comes from the route).", + "additionalProperties": false, + "properties": { + "tagCodes": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + } + } + }, + "ApiResultOfModerateReviewResult": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiResult" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "data": { + "nullable": true, + "oneOf": [ + { + "$ref": "#/components/schemas/ModerateReviewResult" + } + ] + } + } + } + ] + }, + "ModerateReviewResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "moderationStatus": { + "type": "string" + }, + "averageRating": { + "type": "number", + "format": "decimal" + }, + "totalReviews": { + "type": "integer", + "format": "int32" + } + } + }, + "ModerateReviewBody": { + "type": "object", + "description": "Moderation body (the review id comes from the route): publish|hide|reject|unpublish.", + "additionalProperties": false, + "properties": { + "action": { + "type": "string", + "nullable": true + }, + "reason": { + "type": "string", + "nullable": true + } + } + }, "ApiResultOfPagedResultOfNurseSearchResultDto": { "allOf": [ { diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md index a820d89..319120d 100644 --- a/dev/shared-working-context/backend/STATUS.md +++ b/dev/shared-working-context/backend/STATUS.md @@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he - **Notes for frontend:** --> +## 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` + (`UNIQUE(review_id, review_tag_master_id)`), `PatientCareRecords` (encrypted, patient-scoped, `(patient_id, + recorded_at)` index). One migration (`ReviewsAndPatientCareRecords`). CQRS: SubmitReview / ModerateReview / + AttachReviewTags / ListReviewsForNurse / GetReviewModerationQueue / GetTagAggregates / WritePatientCareRecord / + GetPatientHistory + the `RecomputeNurseRating` from-source helper. 8 endpoints across 5 controllers. Added + `NurseProfile.SetReviewAggregates` (guarded aggregate write) + `ICustomerProfileRepository.GetUserIdByProfileIdAsync`. +- **Contracts:** `dev/contracts/domains/reviews-records.md` + openapi snapshot refreshed (yes). +- **Mocked:** `IReviewModerationService` (AI pre-screen — keyword/pass-through) → 🟡 (see reports/mocks-registry.md). +- **Gate:** build clean (0 new warnings) / tests green (346 total: 4 identity + 236 foundation + 106 API, + incl. 13 new review/care-record handler tests + 4 new API tests). +- **Handoff:** backend/handoff/after-backend-phase-14.md +- **Notes for frontend:** publish gate — only `published` reviews are ever public/counted; the nurse aggregate + recomputes from source on every transition. Care records are patient-scoped + encrypted + strict access + (owner/assigned-nurse/admin). Money-free domain. Support alerts stay internal (only `lowRatingAlertId` on the + admin queue). + ## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09 - **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates), `NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks` diff --git a/dev/shared-working-context/backend/handoff/after-backend-phase-14.md b/dev/shared-working-context/backend/handoff/after-backend-phase-14.md new file mode 100644 index 0000000..88f1a98 --- /dev/null +++ b/dev/shared-working-context/backend/handoff/after-backend-phase-14.md @@ -0,0 +1,56 @@ +# Handoff — after backend-phase-14 (Reviews, ratings & patient care records) + +**The trust loop and the continuity-of-care loop are live.** A customer leaves **one moderated review per +completed booking**; an admin/moderator publishes/hides/rejects it; the nurse's public rating is recomputed +**from source on every transition** (so hiding a 1-star lowers the count and re-derives the average — no +inflated-after-hide drift); low ratings auto-raise an internal `support_alert`; and nurses author **encrypted, +patient-scoped** clinical notes readable only under a strict clinical-access rule. + +## What the frontend (f13-b14) can now build +- **Leave a review** — `POST bookings/{bookingId}/review` `{ rating 1–5, body?, tagCodes? }` (customer who owns a + completed booking). Returns `{ id, moderationStatus: "pending_moderation", lowRatingAlertRaised }`. The review is + **not public** until an admin publishes it — build the "submitted, awaiting moderation" state. +- **Public nurse reviews** — `GET nurses/{nurseProfileId}/reviews?page=&pageSize=` → `{ aggregate: { averageRating, + publishedCount }, reviews: PagedResult<{ id, rating, body, tagCodes[], createdAt }> }`. **Published only.** +- **Public tag rollup** — `GET nurses/{nurseProfileId}/review_tags` → `{ publishedReviewCount, tags: [{ code, + labelFa, labelEn, count, percentage }] }` ("% punctual"). The seeded vocab (`punctual/professional/clean/kind/ + communicative`) is always returned. +- **Tag your own review** — `POST reviews/{reviewId}/tags` `{ tagCodes }` (author or moderator; replaces the set). +- **Admin moderation console** — `GET admin/reviews/moderation_queue?status=&page=&pageSize=` (default + `pending_moderation`; each row carries the linked `lowRatingAlertId` for triage) and + `PATCH reviews/{reviewId}/status` `{ action: publish|hide|reject|unpublish, reason? }` (hide/reject need a reason). + The PATCH returns the recomputed `{ averageRating, totalReviews }`. +- **Patient care records** — `POST patients/{patientId}/care_records` `{ bookingId?, body }` (nurse with a + confirmed booking) and `GET patients/{patientId}/care_records?page=&pageSize=` (owning customer / nurse with a + confirmed booking / admin) → decrypted `{ id, patientId, bookingId, nurseProfileId, nurseName, body, recordedAt }` + newest first. The history is **patient-scoped** — a new nurse taking over reads the whole history. + +## Contracts +- **`dev/contracts/domains/reviews-records.md`** — all 8 endpoints, the `moderationStatus`/action enums, tag + codes, DTO shapes, and the care-record **access matrix**. +- **`dev/contracts/openapi/swagger.v1.json`** refreshed — the 8 review/care-record paths are in the snapshot. + +## What is mocked (and how it becomes real) +- **`IReviewModerationService`** (new) — AI review pre-screen. `MockReviewModerationService` is a keyword filter: + clean text → a human-review **Flag** by default (so the publish gate holds — reviews land `pending_moderation`); + a banned-word substring → **Reject** (auto-hidden). Config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`. + Make it real → a text classifier / LLM endpoint (see reports/mocks-registry.md). `ModerateReviewCommand` keeps + decision authority + the human override, so the real impl never touches the handler. + +## Load-bearing rules (don't regress) +- **Recompute from source, not delta** — every publish/hide/reject/unpublish re-derives `average_rating`/ + `total_reviews` over currently-published reviews (exclude-the-changed-review then fold in its new status), in the + same transaction, then refreshes the search index. +- **Publish gate** — `pending_moderation`/`hidden`/`rejected` are never in a public read and never counted. +- **1:1 per completed booking** — `UNIQUE(booking_id)` + handler pre-check; cross-tenant is a 404. +- **Low rating (≤ config `min_rating_for_support_alert`, default 2)** raises an internal `low_rating` alert — + internal-only, never in a user response (only its id shows on the admin queue). +- **Care records are patient-scoped, encrypted at rest, strict access** — a nurse without a confirmed booking for + the patient is denied read + write. + +## Deferred (flagged, not built) +- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal. +- First-class `incidents` entity + ML fraud scoring (manual suspension + `support_alerts` cover it now). +- The ticket system, partner centers, and the admin **support-alert worklist console** → **b15** (this phase only + *raises* alerts). +- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions → b15. diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 9905ed7..f1a0343 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,36 @@ for awareness. - **Requests filed:** frontend/requests/for-backend.md (yes/no) --> +## 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 + first-class 3-way caregiver-gender toggle + stage-1 notes; client-side validation gates the CTA; domain + 400s surface as field/form errors; reuses f2 patients, f3 addresses + map preview, f4/search variants), + **C5** `/bookings/request/[id]` (summary card + 3-step tracker + polled status; response countdown → + accept flips to a 30-min payment countdown + checkout CTA / reject/expire/cancel/converted terminal + cards; cancel-with-confirm), the **nurse inbox** `/nurse/requests` (+ nurse nav item) and **detail** + `/nurse/requests/[id]` (only `customerNotes` + masked city/district; accept / reject-with-reason + invalidate inbox+detail), and a `/bookings/checkout` f9 stub. Two shared tested composites: + `CountdownTimer` (owns its 1s tick — only it re-renders; stops at zero), `BookingRequestSummaryCard`. + i18n `booking` (fully fleshed) + `nav.requests` in both locales. +- **Load-bearing rules honored:** deadlines are **server-frozen UTC** (client renders, never recomputes); + `required_caregiver_gender` is explicit + client-blocks a same-gender mismatch; two-stage disclosure (the + nurse UI never renders address/clinical fields — `get(id,'nurse')` masks); polling **stops** on a + terminal/`converted` status. +- **Consumes:** dev/contracts/domains/booking-requests.md (b8) — routes `booking_requests/{create,accept/{id}, + reject/{id},cancel/{id},list,get/{id}}`, wire camelCase, action-style. `services/bookingRequests/types.ts` + derives from it. +- **Mocked client-side:** `services/bookingRequests` via `bookingRequestsMockApi` (**USE_BOOKING_REQUESTS_MOCK + =true, primary**) — a shared in-memory state machine so create → nurse inbox → accept/reject → C5-poll → + lazy expiry all demo end-to-end (b8 is live, but its inputs — search/patients/addresses — are themselves + mock-primary; and the DTO omits `variantPrice`, REQ-013). Real `bookingRequestsClientApi` maps the b8 + contract 1:1; swap is one flag. Recorded in the phase report (not mocks-registry — backend DI seams only). +- **Gate:** npm run check green · npm run test:ci green (173 tests, +8) · npm run build green with + NEXT_PUBLIC_API_URL set (the only prerender failure without env is the pre-existing "Missing .env + variable!" — hits the existing `/fa/search` too, unrelated to f7). +- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-013 variantPrice on the DTO + nurse avatar; + REQ-014 inbox list-item variantLabel + patient age). + ## frontend-phase-6-b7 — Search & discovery (find a verified, same-gender nurse) — 2026-07-09 - **Shipped:** the family discovery slice — `services/search` (types/keys/constants/apis/hooks + a shared `filterParams.ts` C1↔C2 URL serializer) and screens **C1** `/search` (reused category grid + f3 region diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 2700261..ceb8239 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -171,3 +171,27 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a - **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add `GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings. - **Status:** open + +## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09 +- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's + **avatar URL**). The b8 DTO carries `variantLabel` + `variantPriceUnit` but no price, so the request + summary card (C5 + the nurse detail + later f8 booking detail) can't show the priced rate from the DTO. +- **Why:** The C5 awaiting screen and the nurse request detail render the shared `BookingRequestSummaryCard`, + which prices the service via the f0 money util + i18n unit label. Without a price on the DTO the summary + hides the amount on the real path. The client augments `variantPrice` behind the `services/bookingRequests` + seam (the mock supplies it from the chosen variant; the real client leaves it `null`); adding the field + lets the summary price the service once the domain flips to the real endpoint. +- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`. + (Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.) +- **Status:** open + +## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09 +- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to + `BookingRequestListItemDto`. Today the nurse-inbox row carries `counterpartyName` (patient) + + `customerNotes` + gender + times + deadline, but **not** which service was requested. +- **Why:** The f7 nurse inbox card is specced to show the requested **service variant** and the patient's + age alongside the gender chip + countdown. The list item omits both, so the card shows a notes preview and + the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing + `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 diff --git a/dev/shared-working-context/reports/backend-phase-14-report.md b/dev/shared-working-context/reports/backend-phase-14-report.md new file mode 100644 index 0000000..ea6bd1c --- /dev/null +++ b/dev/shared-working-context/reports/backend-phase-14-report.md @@ -0,0 +1,91 @@ +# Backend Phase 14 — Reviews, ratings & patient care records — Report + +**Date:** 2026-07-09 · **Track:** backend · **Status:** complete, gate green. + +Closes the trust loop (moderated reviews + an honest, recomputed-from-source public rating + low-rating safety +alerts) and the continuity-of-care loop (encrypted, patient-scoped clinical notes under strict access). + +## What was built + +### Schema (one additive migration `ReviewsAndPatientCareRecords`, `reviews` schema) +- **`Reviews`** — one per completed booking. `UNIQUE(booking_id)` (1:1), `CHECK(rating BETWEEN 1 AND 5)`, guarded + `moderation_status` (+ reason/moderator/time), soft-delete filter. Indexes: unique `booking_id`, + `(nurse_profile_id, moderation_status)`, `moderation_status`. Marked `IAuditable` → the SaveChanges interceptor + writes an append-only `audit_logs` diff on creation and every moderation transition. +- **`ReviewTagsMaster`** — seeded vocabulary (`punctual/professional/clean/kind/communicative`), `UNIQUE(code)`. +- **`ReviewTagLinks`** — N:N join, `UNIQUE(review_id, review_tag_master_id)`. +- **`PatientCareRecords`** — nurse-authored, **patient-scoped** (`patient_id`, not booking), nullable `booking_id` + provenance, `body_encrypted` (ciphertext, no EF converter), `(patient_id, recorded_at)` index, soft-delete. +- **`NurseProfile`** gained `SetReviewAggregates(avg, count)` — the single sanctioned write for the existing + `AverageRating`/`TotalReviews` columns (no new aggregate table). + +### Features (CQRS, `Baya.Application/Features/`) +- **Reviews/**: `SubmitReviewCommand`, `ModerateReviewCommand`, `AttachReviewTagsCommand`, + `ListReviewsForNurseQuery`, `GetReviewModerationQueueQuery`, `GetTagAggregatesQuery`; plus the internal + `RecomputeNurseRating` from-source helper and `ReviewCache` (cached public aggregate + eviction). +- **PatientCareRecords/**: `WritePatientCareRecordCommand`, `GetPatientHistoryQuery`. + +### Endpoints (5 controllers) +| Verb & route | Maps to | Auth | +| --- | --- | --- | +| `POST /v1/bookings/{bookingId}/review` | SubmitReview | customer (owns booking) | +| `POST /v1/reviews/{reviewId}/tags` | AttachReviewTags | author / moderator | +| `PATCH /v1/reviews/{reviewId}/status` | ModerateReview | admin / moderator | +| `GET /v1/nurses/{nurseProfileId}/reviews` | ListReviewsForNurse | public | +| `GET /v1/nurses/{nurseProfileId}/review_tags` | GetTagAggregates | public | +| `GET /v1/admin/reviews/moderation_queue` | GetReviewModerationQueue | admin | +| `POST /v1/patients/{patientId}/care_records` | WritePatientCareRecord | nurse (confirmed booking) | +| `GET /v1/patients/{patientId}/care_records` | GetPatientHistory | owner / nurse / admin | + +### Key design decisions (non-obvious) +- **Recompute is exclude-and-fold, in one transaction.** A fresh LINQ query can't see the tracked, uncommitted + status change, so `RecomputeNurseRating` reads `COUNT`/`SUM(rating)` over the nurse's published reviews + **excluding the transitioning review** (id `0` for a brand-new one), then folds in that review's *new* status in + memory. Correct-from-source and single-commit — no `+delta`/`-delta`, no stale re-query, no second commit. +- **Care records encrypt manually (no EF value converter).** Unlike the b3/b9 converter-backed columns, + `body_encrypted` stores ciphertext directly; the handler `Encrypt`s on write and `Decrypt`s only after the access + check passes — so no query path (list, projection) can ever surface plaintext. `recorded_at` is `DateTime` (UTC), + not `DateTimeOffset`, so the SQLite test provider can `ORDER BY` it. +- **AI verdict → initial disposition.** `SubmitReview` calls `IReviewModerationService.ScreenAsync`; the verdict + maps to the initial status (`Approve`→published, `Reject`→hidden, else pending). The mock defaults clean text to + a human-review **Flag** so the **publish gate holds by default** (reviews land `pending_moderation`); + `Seams:ReviewModeration:AutoApproveClean` opts into auto-publish. Human `ModerateReview` always overrides. + +## What is now testable and exactly how (the phase §7 steps) +Run the API against SQL Server; use Swagger/curl. +1. **Submit on a completed booking → accepted.** `POST bookings/{completedId}/review {rating:5}` as the owner → + `200`, `moderationStatus: pending_moderation`. It does **not** appear in `GET nurses/{id}/reviews` (publish gate). +2. **Submit on a cancelled booking → rejected;** a second submit on an already-reviewed booking → conflict (1:1). +3. **Moderate publish recomputes up.** `PATCH reviews/{id}/status {action:"publish"}` → the review appears in the + public list; the aggregate `publishedCount` increments and `averageRating` reflects it. +4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → the public list drops it and the + aggregate rises / count decrements (re-derived from source). +5. **Low rating raises an alert.** `POST … {rating:1}` → a `low_rating` `support_alerts` row exists (visible only on + the admin/internal path — never in a user response; its id shows on the moderation queue row). +6. **Write + read a care record.** As a nurse with a confirmed booking, `POST patients/{P}/care_records` → `200`; + the stored column is ciphertext. As the owning customer or that nurse, `GET …` → decrypted note, newest first. +7. **Unauthorized nurse denied.** A nurse with no confirmed booking for P → `403` on both write and read. + +**Automated coverage:** 13 Foundation handler tests (`Baya.Test.Foundation/Reviews/`) cover steps 1–7 incl. the +recompute-from-source math (hide lowers count **and** average) and ciphertext-at-rest; 4 API tests +(`Baya.Test.Api/ReviewsApiTests.cs`) cover the HTTP pipeline (public reads anonymous, admin/patient reads 401 +without a token). Full suite: **346 pass**, build clean (0 new warnings). + +## What is mocked / waiting on a real service +- **`IReviewModerationService`** (introduced here) — `MockReviewModerationService` (keyword filter / pass-through), + config `Seams:ReviewModeration:{AutoApproveClean,BannedWords}`. Make it real → a text classifier / LLM endpoint, + registration-only swap (see reports/mocks-registry.md, row → 🟡). `ModerateReviewCommand` keeps decision authority. +- Reused seams: `IFieldEncryptor` (clinical notes), `ISearchIndexMaintainer` (aggregate refresh), + `ISupportAlertService` (low-rating alert), `INotificationDispatcher` (review-outcome notice), `IPlatformConfig` + (`min_rating_for_support_alert`), `ICacheService` (aggregate cache). + +## Contracts produced +- `dev/contracts/domains/reviews-records.md` (8 endpoints, enums, DTOs, care-record access matrix). +- `dev/contracts/openapi/swagger.v1.json` refreshed (all 8 paths present). + +## Follow-ups for later phases (b15) +- Ticket system, partner centers, and the admin **support-alert worklist console** (this phase only *raises* + `low_rating` alerts). +- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions. +- Deferred by design: two-way double-blind reviews with timed reveal; a first-class `incidents` entity + ML fraud + scoring; optional structured `vitals_encrypted` on care records. diff --git a/dev/shared-working-context/reports/frontend-phase-7-report.md b/dev/shared-working-context/reports/frontend-phase-7-report.md new file mode 100644 index 0000000..05ceae1 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-7-report.md @@ -0,0 +1,118 @@ +# Frontend Phase 7 — Booking request flow (C4 + C5 + nurse inbox) — report + +**Date:** 2026-07-09 · **Track:** frontend · **Depends on:** f6 (search/nurse profile → the CTA + the +nurse's variants), f3 (addresses + map preview), f2 (patients), f0 (money/date utils, services pattern, +stepper/status composites) · **Consumes:** b8 `booking-requests.md` · **Unlocks:** f8 (booking detail → +the `converted` handoff) + f9 (checkout → the payment CTA). + +## What was built + +The money-free **request phase** of the engagement lifecycle — a customer turns a nurse profile into a +sent request and both sides close the loop, with server-frozen deadlines and two-stage clinical disclosure +made visible. + +### `services/bookingRequests/` (the domain, copies the f0/auth shape) +- **`types.ts`** — `RequiredCaregiverGender`, the full `BookingRequestStatus` union + `TERMINAL_*` set + + `isTerminalBookingRequestStatus`, `BookingRequestDto` (customer/admin full-address vs nurse masked view), + `BookingRequestListItem`, `CreateBookingRequestPayload`, `RejectBookingRequestPayload`, the paginated + list params, `BookingRequestDisplayContext` (mock-only display aid), and the `BookingRequestsApi` seam. + Derived from the b8 contract; `variantPrice` is documented **client-augmented** (REQ-013). +- **`keys.ts`** — `bookingRequestKeys.list(role,status,page)` / `.nurseInbox(...)` / `.detail(id)`. +- **`constants.ts`** — `USE_BOOKING_REQUESTS_MOCK` (true, primary), poll/stale/gc times, page size, the + notes/reason limits, and the mock-only deadline windows. +- **`apis/`** — `mockApi.ts` (**primary**; a shared in-memory state machine, see below), `clientApi.ts` + (real b8 1:1 mapping — action-style snake_case routes, camelCase bodies, ids from the route), `index.ts` + (seam selection by the flag). +- **`hooks/`** — `useCreateBookingRequest` (mutation → seeds the detail cache + invalidates the customer + list), `useBookingRequest(id, role)` (query; **polls while non-terminal, stops at terminal/`converted`** + via a `refetchInterval` guard; `role` drives the mock's nurse-view masking), `useNurseRequestInbox` + (query; light polling), `useCustomerRequests` (query; for f8 reuse + create/cancel invalidation), + `useAccept`/`useReject`/`useCancel` (mutations → invalidate inbox list + detail). Barrel re-exports hooks. + +### Screens (all RTL/i18n/dark-mode) +- **C4** `/bookings/request` (customer) — the request form. Patient dropdown (f2; empty → link to + `/patients`), service-variant dropdown (the chosen nurse's `services` from the f6 profile) + a + `PriceDisplay` of the picked rate, address dropdown (f3) + a read-only **map-pin preview** of the saved + address's coordinates, native date + time-window fields, a **first-class 3-way caregiver-gender toggle** + (خانم/آقا/فرقی ندارد) with a why-line, and a stage-1 notes field with a counter + honesty copy. Defaults + are **derived during render** (no setState-in-effect): the variant to the carried/first, the address to + the primary/first. The CTA is gated on all required fields + a client-side same-gender-mismatch block; + domain 400 codes map to field/form errors. On success → C5. +- **C5** `/bookings/request/[id]` (customer) — the awaiting screen. `BookingRequestSummaryCard` + the + 3-step `StepperHeader` tracker + a status-driven body: **pending** shows a `CountdownTimer` to + `nurseResponseDeadlineAt`; **accepted** swaps the tracker, shows a ✓ badge + a terracotta 30-min + `CountdownTimer` to `paymentDeadlineAt` + the **ادامه پرداخت** CTA → `/bookings/checkout`; **rejected / + expired / payment-expired / cancelled** are terminal cards with a re-request CTA into search; + **converted** routes to booking (f8). Cancel-with-confirm while pending/accepted. Polls until terminal. +- **Nurse inbox** `/nurse/requests` — pending requests, each a card with patient name, Shamsi time, the + **required-gender chip**, a `customerNotes` preview, and a per-request `CountdownTimer`. Empty state + + light polling. New nurse-shell nav item. +- **Nurse detail** `/nurse/requests/[id]` — the summary + **only `customerNotes`** (masked coarse + city/district, a disclosure note) + accept / reject-with-reason (a dialog capturing the reason). A stale + `409` surfaces a warning + refetch. Both actions invalidate the inbox + detail. +- **`/bookings/checkout`** — an f9 DEFERRED stub (PlaceholderScreen) so the accept CTA doesn't dead-end. + +### Shared composites (tested) +- **`CountdownTimer`** — a pure countdown to a **server-frozen** UTC instant; owns its own 1-second tick so + only it re-renders (never the page), stops at zero and shows an elapsed label + fires `onElapsed` once, + renders locale digits forced LTR. 4 tests (MM:SS / HH:MM:SS / tick / elapsed+onElapsed via fake timers). +- **`BookingRequestSummaryCard`** — nurse identity + rating, patient, priced service (`PriceDisplay` when a + price is present), address label, Shamsi date/time. Reused by C5 + nurse detail; f8 reuses it too. 4 tests. + +### Other +- Routes: `BOOKING_REQUEST` (now the C4 form), `BOOKING_REQUEST_STATUS`, `CHECKOUT`, `NURSE_REQUESTS`. + Icons: `requests`, `payment`. i18n: `booking` namespace fully fleshed + `nav.requests`, both locales in + sync. The prior `/bookings/request` f6-handoff **stub was replaced** by the real C4 form. + +## Now testable, and exactly how (§7 of the phase) +Run `npm run dev` (mock is primary — no backend needed). Within one browser tab (the mock store is a shared +module singleton): +1. **Submit:** from a nurse profile (C3) tap **درخواست رزرو** → C4 pick patient/variant/address/date/time + + a gender → **ارسال درخواست** → land on **C5** (tracker step 2 active, response countdown ticking). +2. **Nurse sees it:** open `/nurse/requests` → the new request appears with the gender chip + notes + + countdown; open the detail → **only the notes** (no address/clinical fields). +3. **Accept:** tap accept → it leaves the pending inbox (invalidated); navigate back to **C5** → step 2 + done, step 3 active, **✓ پرستار تایید کرد**, the **30-min payment countdown**, and **ادامه پرداخت →** + (routes to the checkout stub with `request_id`). +4. **Reject:** on another request, reject with a reason → C5 shows the **rejected** terminal card + reason. +5. **Expiry:** the mock shortens the response window (see below) — once it lapses, a C5 poll (or reopening) + shows **expired_no_response**; after accept, the 30-min window lapsing shows **payment_deadline_expired**. +6. **Quality:** flip fa↔en (dir + strings), dark mode holds; Devtools shows the inbox/detail invalidating on + accept/reject and the C5 poll stopping at a terminal status. + +## What is mocked (and how it swaps to real) +`services/bookingRequests` is **mock-primary** (`USE_BOOKING_REQUESTS_MOCK = true`). b8 is live server-side, +but this is a **client-side** mock for two reasons: (a) every *input* id — the nurse (f6 search), the +patient (f2), the address (f3) — comes from a **mock-primary** upstream domain, so a real +`booking_requests/create` would reference ids that don't exist in a real DB; (b) the contract DTO omits the +variant price the summary renders (REQ-013). The mock is a **shared in-memory state machine**: one +module-level store drives create → the nurse inbox → accept/reject/cancel → the customer C5 poll, with a +**lazy expiry sweep** on every read (the client stand-in for the server's background job), the nurse-view +**address masking**, and the forward-only status/deadline rules. Mock-only deadline windows: the payment +window is contract-accurate (30 min); the **response** window is shortened to 30 min (a stand-in for the +server's 24h) so a session can observe the expiry path. The real `bookingRequestsClientApi` maps the b8 +contract 1:1 (the `context`/`role` args are mock-only and ignored). **Swapping to real is a one-line flag +flip** once the upstream domains are live and REQ-013 lands — no hook/component change. (Client-side mock; +recorded here, not in `mocks-registry.md`, which tracks backend DI seams.) + +## Contracts consumed / requests filed +- **Consumed (not edited):** `dev/contracts/domains/booking-requests.md` (b8) — types derive from it. Note: + the actual routes are `booking_requests/{create,accept/{id},reject/{id},cancel/{id},list,get/{id}}` (the + phase file's illustrative `create_booking_request`-style names were superseded by the contract), and the + wire uses `requestedDate` + `requestedTimeStart/End` (not a single `scheduled_start_at`). +- **Filed:** `frontend/requests/for-backend.md` — **REQ-013** (`variantPrice` + `nurseAvatarUrl` on + `BookingRequestDto`) and **REQ-014** (`variantLabel` + `patientAge` on `BookingRequestListItemDto`). + +## Follow-ups for later phases +- **f8 (booking detail):** the C5 `converted` state routes to `/bookings` today — wire it to the real + booking-detail route when f8 lands. f8 should reuse `BookingRequestSummaryCard`. +- **f9 (checkout):** the C5 accept CTA → `/bookings/checkout?request_id=…` (a stub). f9 builds the C6 + summary + escrow + card/BNPL and consumes the accepted request id. +- **Swap to real b8:** flip `USE_BOOKING_REQUESTS_MOCK=false` once search/patients/addresses are live and + REQ-013/014 land; verify the nurse-view masking + deadline freezing against the server. + +## Gate +`npm run check` green · `npm run test:ci` green (173 tests, +8) · `npm run build` green with +`NEXT_PUBLIC_API_URL` set (all five new routes compile + prerender). Without the env var the build fails on +the **pre-existing** "Missing .env variable!" from `@/config` — it hits the existing `/fa/search` route +identically, so it is environmental, not an f7 defect. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 019594f..413898b 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -30,7 +30,7 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 | `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 | | `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 | | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 | -| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 | +| `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 | | `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 | | `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 | | `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 | diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 69c0fb7..3c9981b 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies. ``` src/ ├── Core/ -│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) -│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) +│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) +│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) ├── Infrastructure/ -│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) +│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) -│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider) + AddCrossCuttingSeams +│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net ├── API/ -│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts), appsettings*.json +│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only) ├── Shared/Baya.SharedKernel Extensions + validation base @@ -424,6 +424,37 @@ ledger post + clawback netting); per-domain repo `IPayoutRepository` on `IUnitOf (batches are admin-triggered; cadence in `nurse_payout_interval_days`); the BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` config flag. +**Reviews, ratings & patient care records (backend-phase-14).** A new **`reviews` schema** holds four tables: +`Reviews` (one per completed booking — `UNIQUE(booking_id)`, `CHECK(rating 1–5)`, `moderation_status` code + +guarded moderation fields; `IAuditable` so the interceptor audits every transition), `ReviewTagsMaster` (seeded +tag vocabulary, `UNIQUE(code)`), `ReviewTagLinks` (N:N, `UNIQUE(review_id, review_tag_master_id)`), and +`PatientCareRecords` (nurse-authored, **encrypted, patient-scoped** clinical notes; `(patient_id, recorded_at)` +index). Entities in `Domain/Entities/Reviews/`; configs in `Persistence/Configuration/ReviewsConfig/`; per-domain +repos `IReviewRepository` + `IPatientCareRecordRepository` on `IUnitOfWork`; features under +`Baya.Application/Features/{Reviews|PatientCareRecords}/`; controllers `BookingReviewsController` (submit) / +`ReviewsController` (tags + moderate) / `AdminReviewsController` (queue) / `NursesController` (public reviews + +review_tags) / `PatientCareRecordsController`. Load-bearing rules: +- **Reviews are for completed/closed bookings only, owned by the caller, 1:1.** The `UNIQUE(booking_id)` is the + backstop; the handler pre-checks and returns a clean `OperationResult` (409 on a duplicate, not a raw DB error). + A cross-tenant booking is a 404, never a leak. +- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** `RecomputeNurseRating` + (`Features/Reviews/`) reads `COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews **excluding the + transitioning review**, folds in that review's *new* status in memory, sets `nurse_profiles.average_rating`/ + `total_reviews` (guarded `NurseProfile.SetReviewAggregates`), and stages the b7 `ReindexNurseAsync` refresh — all + in the **same transaction** as the status change (the exclude-and-fold avoids a stale pre-commit re-query). This + is the fix for inflated-rating-after-hide drift. +- **Publish gate — `pending_moderation` is never public.** `ListReviewsForNurse` and the aggregate count + `published` only, filtered at the query layer. The public aggregate read is cached (`ReviewCache`) and evicted on + every transition. +- **Low rating raises a `support_alert` reliably.** `rating <= min_rating_for_support_alert` (config, default 2) + → `RaiseSupportAlert(low_rating)` in the same flow (after the main commit, never silently swallowed). +- **`patient_care_records` are patient-scoped (not booking-scoped) + encrypted + strict access.** `body_encrypted` + holds `IFieldEncryptor` ciphertext with **no EF value converter** — the handler encrypts on write and decrypts + only after the access check passes (owning customer / nurse with a confirmed booking / admin; anyone else 403). +- **`IReviewModerationService`** (new seam, `Contracts/Reviews`; mock `MockReviewModerationService` in CrossCutting, + 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). + **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/AdminReviewsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs new file mode 100644 index 0000000..ad4a8f8 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +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 review console: the moderation queue (defaults to pending_moderation), with any linked +/// low-rating alert id. Support alerts themselves stay internal — only their id is surfaced here for triage. +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/admin/reviews")] +[Authorize(ConstantPolicies.DynamicPermission)] +[Display(Description = "Admin review moderation queue")] +public sealed class AdminReviewsController(ISender sender) : BaseController +{ + [HttpGet("moderation_queue")] + [ProducesOkApiResponseType>] + public async Task ModerationQueue([FromQuery] GetReviewModerationQueueQuery query, CancellationToken cancellationToken) + => OperationResult(await sender.Send(query, cancellationToken)); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs new file mode 100644 index 0000000..70b018e --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Reviews.Commands.SubmitReview; +using Baya.Application.Models.Reviews; +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 customer submits the one allowed review for a completed booking. Ownership, the completed/closed +/// eligibility gate, and the 1:1 rule are enforced in the handler; the booking id comes from the route. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/bookings")] +[Authorize] +[Display(Description = "Submit the one review for a completed booking (customer)")] +public sealed class BookingReviewsController(ISender sender) : BaseController +{ + [HttpPost("{bookingId}/review")] + [ProducesOkApiResponseType] + public async Task Review(long bookingId, SubmitReviewBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send( + new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken)); + + /// The review body (the booking id comes from the route). + public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList? TagCodes); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs index 9580783..744b932 100644 --- a/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs +++ b/server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs @@ -1,6 +1,9 @@ using System.ComponentModel.DataAnnotations; using Asp.Versioning; +using Baya.Application.Features.Reviews.Queries.GetTagAggregates; +using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse; using Baya.Application.Features.Verification.Queries.GetTrustBadge; +using Baya.Application.Models.Reviews; using Baya.Application.Models.Verification; using Baya.WebFramework.Attributes; using Baya.WebFramework.BaseController; @@ -14,7 +17,7 @@ namespace Baya.Web.Api.Controllers.V1; [ApiController] [Route("api/v{version:apiVersion}/[controller]")] [AllowAnonymous] -[Display(Description = "Public nurse read surface (the verified trust badge)")] +[Display(Description = "Public nurse read surface (the verified trust badge + published reviews)")] public sealed class NursesController(ISender sender) : BaseController { // Public: the verified badge exposes credential *types* held, never the encrypted numbers. @@ -22,4 +25,16 @@ public sealed class NursesController(ISender sender) : BaseController [ProducesOkApiResponseType] public async Task TrustBadge(long nurseId, CancellationToken cancellationToken) => OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken)); + + // Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate. + [HttpGet("{nurseProfileId}/reviews")] + [ProducesOkApiResponseType] + public async Task Reviews(long nurseProfileId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) + => OperationResult(await sender.Send(new ListReviewsForNurseQuery(nurseProfileId, page, pageSize), cancellationToken)); + + // Public: the per-nurse tag rollup ("% punctual", …) over published reviews. + [HttpGet("{nurseProfileId}/review_tags")] + [ProducesOkApiResponseType] + public async Task ReviewTags(long nurseProfileId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new GetTagAggregatesQuery(nurseProfileId), cancellationToken)); } diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs new file mode 100644 index 0000000..fc8ed16 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; +using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.WebFramework.Attributes; +using Baya.WebFramework.BaseController; +using Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Baya.Web.Api.Controllers.V1; + +/// +/// Patient-scoped clinical care records. Writing is nurse-only (with a confirmed booking for that patient); +/// reading is restricted to the owning customer, a nurse with a confirmed booking, or admin — the strict +/// clinical access rule is enforced in the handler (not just this route policy). Clinical bodies are encrypted +/// at rest and decrypted only after the access check passes. +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/patients")] +[Authorize] +[Display(Description = "Patient-scoped, encrypted clinical care records (write: nurse; read: owner/nurse/admin)")] +public sealed class PatientCareRecordsController(ISender sender) : BaseController +{ + [HttpPost("{patientId}/care_records")] + [ProducesOkApiResponseType] + public async Task Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send( + new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body), cancellationToken)); + + [HttpGet("{patientId}/care_records")] + [ProducesOkApiResponseType>] + public async Task History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) + => OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken)); + + /// The care-record body (the patient id comes from the route). + public record WriteCareRecordBody(long? BookingId, string Body); +} diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs new file mode 100644 index 0000000..c21a011 --- /dev/null +++ b/server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using Asp.Versioning; +using Baya.Application.Features.Reviews.Commands.AttachReviewTags; +using Baya.Application.Features.Reviews.Commands.ModerateReview; +using Baya.Application.Models.Reviews; +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; + +/// +/// Review write surface. Tagging a review is for its author (or a moderator, enforced in the handler); the +/// moderation transition is admin/moderator-only (the narrower +/// policy overrides the controller-level authorize). +/// +[ApiVersion("1")] +[ApiController] +[Route("api/v{version:apiVersion}/reviews")] +[Authorize] +[Display(Description = "Review tagging (owner/moderator) and moderation transitions (admin)")] +public sealed class ReviewsController(ISender sender) : BaseController +{ + [HttpPost("{reviewId}/tags")] + [ProducesOkApiResponseType] + public async Task Tags(long reviewId, AttachReviewTagsBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AttachReviewTagsCommand(reviewId, body.TagCodes), cancellationToken)); + + [HttpPatch("{reviewId}/status")] + [Authorize(ConstantPolicies.DynamicPermission)] + [ProducesOkApiResponseType] + public async Task Status(long reviewId, ModerateReviewBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new ModerateReviewCommand(reviewId, body.Action, body.Reason), cancellationToken)); + + /// Tag-attach body (the review id comes from the route). + public record AttachReviewTagsBody(IReadOnlyList TagCodes); + + /// Moderation body (the review id comes from the route): publish|hide|reject|unpublish. + public record ModerateReviewBody(string Action, string? Reason); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs index c6558c4..2d518a8 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/ICustomerProfileRepository.cs @@ -18,4 +18,8 @@ public interface ICustomerProfileRepository /// The customer's customer_profiles.id from their user id — the tenancy anchor for /// patient operations. NULL when the user has no customer profile yet. Task GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken); + + /// The owning users.id for a customer profile — the notification recipient for a review + /// outcome. NULL when the profile does not exist. + Task GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken); } diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IPatientCareRecordRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IPatientCareRecordRepository.cs new file mode 100644 index 0000000..5fc771a --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IPatientCareRecordRepository.cs @@ -0,0 +1,28 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The patient_care_records store — patient-scoped clinical notes, encrypted at rest. Reads return the +/// ciphertext; the handler decrypts only after the strict clinical access check passes. Access facts (patient +/// ownership, a nurse's qualifying booking) are answered here so the handler can gate before touching the body. +/// +public interface IPatientCareRecordRepository +{ + Task AddAsync(PatientCareRecord record, CancellationToken cancellationToken); + + /// The patient's owning customer_profiles.id, or null if the patient does not exist — the + /// tenancy anchor for the owning-customer access branch. + Task GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken); + + /// True if the nurse has a booking for the patient that reached a confirmed (or later) state — the + /// gate for both writing and reading that patient's clinical history. A nurse never assigned is denied. + Task NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken); + + /// Patient-scoped longitudinal history, paginated, newest first — ciphertext bodies; the + /// handler decrypts post-check. + Task> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IReviewRepository.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IReviewRepository.cs new file mode 100644 index 0000000..24fa5f1 --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IReviewRepository.cs @@ -0,0 +1,55 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; + +namespace Baya.Application.Contracts.Persistence; + +/// +/// The reviews aggregate (reviews + tag links). Writes load tracked rows; reads project to DTOs and +/// return published reviews only on any public path. The nurse rating aggregate is always derived from +/// source (AVG/COUNT over currently-published reviews) — never an incremental delta. +/// +public interface IReviewRepository +{ + Task AddAsync(Review review, CancellationToken cancellationToken); + + /// The booking's ownership + status + nurse, for the submit-eligibility guards. Null if absent. + Task GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken); + + /// True if a (non-deleted) review already exists for the booking — the 1:1 pre-check. + Task ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken); + + /// Tracked review for a moderation transition. Null if absent. + Task GetTrackedAsync(long reviewId, CancellationToken cancellationToken); + + /// Tracked review with its tag links loaded — for the add/replace-tags command. Null if absent. + Task GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken); + + /// Validates tag codes against the active master vocabulary; returns the matched code → id + /// (an unknown/inactive code is simply absent from the map, so the handler can reject it cleanly). + Task> GetTagIdsByCodesAsync(IReadOnlyList codes, CancellationToken cancellationToken); + + /// + /// The recompute-from-source stats for a nurse — COUNT and SUM(rating) over the nurse's + /// currently published reviews excluding . The caller then + /// folds in the transitioning review's new status in memory, so the aggregate is derived from source + /// (not an incremental delta) and is correct before the single commit — a fresh query can't yet see + /// the tracked, uncommitted status change. Pass 0 to exclude nothing (a brand-new review). + /// + Task<(int Count, long Sum)> GetPublishedRatingStatsExcludingAsync(long nurseProfileId, long excludeReviewId, CancellationToken cancellationToken); + + /// The nurse's denormalized, published-only rating aggregate (kept correct by the recompute) — + /// what the public reviews read returns and caches. + Task GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken); + + /// Public, paginated list of a nurse's published reviews (with tag codes), newest first. + Task> ListPublishedForNurseAsync(long nurseProfileId, int page, int pageSize, CancellationToken cancellationToken); + + /// Admin moderation queue, filtered by status (default caller-supplied), newest first, with any + /// linked low-rating alert id joined in. + Task> GetModerationQueueAsync(string? status, int page, int pageSize, CancellationToken cancellationToken); + + /// Per-nurse tag rollup over published reviews — each active tag's count and share. + Task GetTagAggregatesAsync(long nurseProfileId, CancellationToken cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs index 15c679e..a09bc1e 100644 --- a/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs +++ b/server/src/Core/Baya.Application/Contracts/Persistence/IUnitOfWork.cs @@ -23,6 +23,8 @@ public interface IUnitOfWork public IInvoiceRepository InvoiceRepository { get; } public IBnplRepository BnplRepository { get; } public IPayoutRepository PayoutRepository { get; } + public IReviewRepository ReviewRepository { get; } + public IPatientCareRecordRepository PatientCareRecordRepository { get; } Task CommitAsync(); ValueTask RollBackAsync(); } diff --git a/server/src/Core/Baya.Application/Contracts/Reviews/IReviewModerationService.cs b/server/src/Core/Baya.Application/Contracts/Reviews/IReviewModerationService.cs new file mode 100644 index 0000000..1e9efda --- /dev/null +++ b/server/src/Core/Baya.Application/Contracts/Reviews/IReviewModerationService.cs @@ -0,0 +1,31 @@ +#nullable enable +namespace Baya.Application.Contracts.Reviews; + +/// The AI/automated pre-screen decision for a submitted review. +public enum ModerationDecision +{ + /// Clean text — safe to auto-publish (subject to the auto-approve config). + Approve, + + /// Suspicious — keep pending_moderation for a human to decide. + Flag, + + /// Clearly disallowed — a human can still override, but the pre-screen recommends rejecting. + Reject +} + +/// The verdict a pre-screen returns: the decision plus a short machine reason. +/// The recommended disposition. +/// A short reason code/text (e.g. clean, banned_word:scam). +public sealed record ModerationVerdict(ModerationDecision Decision, string Reason); + +/// +/// Seam for automated review moderation (introduced backend-phase-14). The mock is a keyword filter / +/// pass-through with no external call; a real text classifier / LLM endpoint swaps in by a registration +/// change only — ModerateReviewCommand keeps decision authority and always allows a human override, so +/// the real implementation never needs to touch the handler. +/// +public interface IReviewModerationService +{ + ValueTask ScreenAsync(string? reviewText, CancellationToken cancellationToken = default); +} diff --git a/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs new file mode 100644 index 0000000..97e96ed --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs @@ -0,0 +1,59 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; +using Mediator; + +namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; + +/// +/// A nurse writes a patient-scoped clinical note. Guards: the caller is a nurse, the patient exists, and the +/// nurse has a confirmed (or later) booking for that patient — a nurse never assigned is denied. The +/// clinical body is encrypted through before it is persisted; plaintext never +/// touches the column. +/// +internal sealed class WritePatientCareRecordCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IFieldEncryptor fieldEncryptor, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle( + WritePatientCareRecordCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseProfileId is not { } nurseId) + return OperationResult.ForbiddenResult("Only a nurse can write a care record."); + + var owner = await unitOfWork.PatientCareRecordRepository.GetPatientOwnerCustomerIdAsync(request.PatientId, cancellationToken); + if (owner is null) + return OperationResult.NotFoundResult("Patient not found."); + + var qualifies = await unitOfWork.PatientCareRecordRepository + .NurseHasQualifyingBookingForPatientAsync(nurseId, request.PatientId, cancellationToken); + if (!qualifies) + return OperationResult.ForbiddenResult( + "You can only write care records for a patient you have a confirmed booking with."); + + var record = new PatientCareRecord + { + PatientId = request.PatientId, + BookingId = request.BookingId, + NurseProfileId = nurseId, + BodyEncrypted = fieldEncryptor.Encrypt(request.Body.Trim()), + RecordedAt = dateTimeProvider.UtcNow.UtcDateTime + }; + + await unitOfWork.PatientCareRecordRepository.AddAsync(record, cancellationToken); + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult( + new WriteCareRecordResult(record.Id, record.PatientId, record.RecordedAt)); + } +} diff --git a/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Validator.cs b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Validator.cs new file mode 100644 index 0000000..479a3db --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; + +public sealed class WritePatientCareRecordCommandValidator : AbstractValidator +{ + public WritePatientCareRecordCommandValidator() + { + RuleFor(x => x.PatientId).GreaterThan(0); + RuleFor(x => x.BookingId).GreaterThan(0).When(x => x.BookingId.HasValue); + RuleFor(x => x.Body).NotEmpty().MaximumLength(8000); + } +} diff --git a/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.cs b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.cs new file mode 100644 index 0000000..faea15a --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; + +/// A nurse authors a clinical note for a patient (optionally tagged with the booking that produced +/// it). The patient id comes from the route; the body is encrypted at rest before persisting. +public record WritePatientCareRecordCommand(long PatientId, long? BookingId, string Body) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs b/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs new file mode 100644 index 0000000..352e60e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs @@ -0,0 +1,69 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory; + +/// +/// Reads a patient's longitudinal care history under the strict clinical access rule (enforced here, not just +/// at the route): the owning customer, a nurse with a confirmed booking for the patient, or an admin — nobody +/// else. Only after the check passes are the ciphertext bodies decrypted via . +/// +internal sealed class GetPatientHistoryQueryHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IFieldEncryptor fieldEncryptor) + : IRequestHandler>> +{ + private static readonly string[] AdminRoles = [RoleNames.Admin, RoleNames.SuperAdmin]; + + public async ValueTask>> Handle( + GetPatientHistoryQuery request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult>.UnauthorizedResult("Not authenticated."); + + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + var records = unitOfWork.PatientCareRecordRepository; + + var ownerCustomerId = await records.GetPatientOwnerCustomerIdAsync(request.PatientId, cancellationToken); + if (ownerCustomerId is null) + return OperationResult>.NotFoundResult("Patient not found."); + + var allowed = currentUser.Roles.Any(AdminRoles.Contains); + if (!allowed) + { + var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerProfileId == ownerCustomerId) + { + allowed = true; + } + else + { + var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (nurseProfileId is { } nurseId) + allowed = await records.NurseHasQualifyingBookingForPatientAsync(nurseId, request.PatientId, cancellationToken); + } + } + + if (!allowed) + return OperationResult>.ForbiddenResult( + "You do not have clinical access to this patient's care records."); + + var cipher = await records.GetPatientHistoryAsync(request.PatientId, page, pageSize, cancellationToken); + + var items = cipher.Items + .Select(r => new CareRecordDto( + r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.NurseName, + fieldEncryptor.Decrypt(r.BodyEncrypted), r.RecordedAt)) + .ToList(); + + return OperationResult>.SuccessResult( + new PagedResult(items, cipher.Total, cipher.Page, cipher.PageSize)); + } +} diff --git a/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.cs b/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.cs new file mode 100644 index 0000000..9cdcb1c --- /dev/null +++ b/server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.cs @@ -0,0 +1,10 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory; + +/// Patient-scoped longitudinal care history, paginated, newest first. Readable only by the owning +/// customer, a nurse with a confirmed booking for the patient, or an admin. +public record GetPatientHistoryQuery(long PatientId, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Handler.cs new file mode 100644 index 0000000..4ce1aae --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Handler.cs @@ -0,0 +1,57 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.User; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags; + +/// +/// Replaces a review's tag links with exactly the requested set. Authorized for the review's author or a +/// moderator; a non-owner non-moderator is denied. Tag codes are validated against the active master +/// vocabulary; the resulting set is de-duplicated so the UNIQUE(review_id, review_tag_master_id) is +/// never violated. +/// +internal sealed class AttachReviewTagsCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork) + : IRequestHandler> +{ + private static readonly string[] ModeratorRoles = [RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Moderation]; + + public async ValueTask> Handle(AttachReviewTagsCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var review = await unitOfWork.ReviewRepository.GetTrackedWithTagsAsync(request.ReviewId, cancellationToken); + if (review is null) + return OperationResult.NotFoundResult("Review not found."); + + var isModerator = currentUser.Roles.Any(ModeratorRoles.Contains); + if (!isModerator) + { + var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerProfileId != review.CustomerProfileId) + return OperationResult.ForbiddenResult("You can only tag your own review."); + } + + var distinct = request.TagCodes.Select(c => c.Trim()).Where(c => c.Length > 0).Distinct().ToList(); + var resolved = await unitOfWork.ReviewRepository.GetTagIdsByCodesAsync(distinct, cancellationToken); + var unknown = distinct.Where(c => !resolved.ContainsKey(c)).ToList(); + if (unknown.Count > 0) + return OperationResult.FailureResult($"Unknown review tag(s): {string.Join(", ", unknown)}."); + + // Replace: EF deletes the removed links and inserts the new ones in one transaction. + review.TagLinks.Clear(); + foreach (var tagId in resolved.Values) + review.TagLinks.Add(new ReviewTagLink { ReviewId = review.Id, ReviewTagMasterId = tagId }); + + await unitOfWork.CommitAsync(); + + return OperationResult.SuccessResult(new ReviewTagsResult(review.Id, distinct)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Validator.cs new file mode 100644 index 0000000..8d3aaf5 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.Validator.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags; + +public sealed class AttachReviewTagsCommandValidator : AbstractValidator +{ + public AttachReviewTagsCommandValidator() + { + RuleFor(x => x.ReviewId).GreaterThan(0); + RuleFor(x => x.TagCodes).NotNull(); + RuleForEach(x => x.TagCodes).NotEmpty().MaximumLength(50); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.cs new file mode 100644 index 0000000..fdfa98f --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/AttachReviewTags/AttachReviewTagsCommand.cs @@ -0,0 +1,12 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags; + +/// Sets (replaces) the standardized tags on a review the caller owns (or a moderator manages). The +/// resulting set is exactly ; the UNIQUE(review_id, review_tag_master_id) forbids +/// a duplicate tag. +public record AttachReviewTagsCommand(long ReviewId, IReadOnlyList TagCodes) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Handler.cs new file mode 100644 index 0000000..9797765 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Handler.cs @@ -0,0 +1,64 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.ModerateReview; + +/// +/// Applies a moderation transition and, in the same transaction: recomputes the nurse aggregate from +/// source (§ ) and stages the b7 search-index refresh. The transition is +/// audited automatically because is IAuditable (the SaveChanges interceptor writes +/// the diff). After commit the cached aggregate is invalidated and the author is notified of the outcome. This +/// is the human decision authority — it can always override the AI pre-screen. +/// +internal sealed class ModerateReviewCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + ISearchIndexMaintainer searchIndex, + ICacheService cache, + IDateTimeProvider dateTimeProvider, + INotificationDispatcher notifications) + : IRequestHandler> +{ + public async ValueTask> Handle(ModerateReviewCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } moderatorId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var targetStatus = ReviewModerationAction.ToStatus(request.Action); + if (targetStatus is null) + return OperationResult.FailureResult($"Unknown moderation action '{request.Action}'."); + + var review = await unitOfWork.ReviewRepository.GetTrackedAsync(request.ReviewId, cancellationToken); + if (review is null) + return OperationResult.NotFoundResult("Review not found."); + + var reason = ReviewModerationAction.RequiresReason(request.Action) ? request.Reason : null; + review.Moderate(targetStatus, reason, moderatorId, dateTimeProvider.UtcNow); + + // From-source recompute + search refresh, staged on the same unit of work as the status change. + await RecomputeNurseRating.ExecuteAsync(review, unitOfWork, searchIndex, cancellationToken); + + await unitOfWork.CommitAsync(); + + await ReviewCache.InvalidateAggregateAsync(cache, review.NurseProfileId, cancellationToken); + + // Best-effort author notice of the outcome (in-app). Not on the critical path. + var recipientUserId = await unitOfWork.CustomerProfileRepository.GetUserIdByProfileIdAsync(review.CustomerProfileId, cancellationToken); + if (recipientUserId is { } uid) + await notifications.DispatchAsync( + new Notification(uid, "review_moderated", "Your review was updated", + $"Your review is now {review.ModerationStatus}.", + $"{{\"reviewId\":{review.Id},\"status\":\"{review.ModerationStatus}\"}}"), + cancellationToken); + + var aggregate = await unitOfWork.ReviewRepository.GetNurseAggregateAsync(review.NurseProfileId, cancellationToken); + return OperationResult.SuccessResult( + new ModerateReviewResult(review.Id, review.ModerationStatus, aggregate.AverageRating, aggregate.PublishedCount)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Validator.cs new file mode 100644 index 0000000..c318d5b --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.Validator.cs @@ -0,0 +1,22 @@ +using Baya.Domain.Entities.Reviews; +using FluentValidation; + +namespace Baya.Application.Features.Reviews.Commands.ModerateReview; + +public sealed class ModerateReviewCommandValidator : AbstractValidator +{ + public ModerateReviewCommandValidator() + { + RuleFor(x => x.ReviewId).GreaterThan(0); + RuleFor(x => x.Action) + .NotEmpty() + .Must(ReviewModerationAction.All.Contains) + .WithMessage($"Action must be one of: {string.Join(", ", ReviewModerationAction.All)}."); + + // Hide and reject must carry a reason for the audit trail and the author notice. + RuleFor(x => x.Reason) + .NotEmpty() + .MaximumLength(500) + .When(x => x.Action is not null && ReviewModerationAction.RequiresReason(x.Action)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.cs new file mode 100644 index 0000000..e55dda9 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/ModerateReview/ModerateReviewCommand.cs @@ -0,0 +1,12 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.ModerateReview; + +/// An admin/moderator transitions a review: publish | hide | reject | +/// unpublish. Every transition recomputes the nurse aggregate from source and refreshes the search +/// index in the same transaction. Hide/reject require a reason. +public record ModerateReviewCommand(long ReviewId, string Action, string? Reason) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs new file mode 100644 index 0000000..df4dc5e --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs @@ -0,0 +1,124 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Reviews; +using Baya.Application.Contracts.Search; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.SupportAlerts; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.SubmitReview; + +/// +/// Creates the one allowed review for a completed booking. Guards (all clean +/// failures, never throws): the caller owns the booking (tenancy), the booking is completed/closed, and no +/// review exists yet (the 1:1 rule, with the UNIQUE index as the race backstop). The AI pre-screen sets the +/// initial disposition — clean text stays pending_moderation by default (the publish gate); the human +/// path can always override later. A rating at/below the configured threshold raises a low-rating support +/// alert reliably (after commit, not swallowed). +/// +internal sealed class SubmitReviewCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IPlatformConfig platformConfig, + IReviewModerationService moderation, + ISupportAlertService supportAlerts, + ISearchIndexMaintainer searchIndex, + ICacheService cache, + IDateTimeProvider dateTimeProvider) + : IRequestHandler> +{ + public async ValueTask> Handle(SubmitReviewCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken); + if (customerProfileId is not { } customerId) + return OperationResult.ForbiddenResult("Only a customer can review a booking."); + + var booking = await unitOfWork.ReviewRepository.GetReviewableBookingAsync(request.BookingId, cancellationToken); + if (booking is null) + return OperationResult.NotFoundResult("Booking not found."); + + // Tenancy: a customer can only review their own booking — a mismatch is a not-found, never a leak. + if (booking.CustomerProfileId != customerId) + return OperationResult.NotFoundResult("Booking not found."); + + // Eligibility: only a completed/closed booking is reviewable (never cancelled/expired/in-progress). + if (booking.Status is not (BookingStatus.Completed or BookingStatus.Closed)) + return OperationResult.FailureResult("A review can only be left for a completed booking."); + + if (await unitOfWork.ReviewRepository.ExistsForBookingAsync(request.BookingId, cancellationToken)) + return OperationResult.ConflictResult("This booking has already been reviewed."); + + var tagMasterIds = new List(); + if (request.TagCodes is { Count: > 0 } codes) + { + var distinct = codes.Select(c => c.Trim()).Where(c => c.Length > 0).Distinct().ToList(); + var resolved = await unitOfWork.ReviewRepository.GetTagIdsByCodesAsync(distinct, cancellationToken); + var unknown = distinct.Where(c => !resolved.ContainsKey(c)).ToList(); + if (unknown.Count > 0) + return OperationResult.FailureResult($"Unknown review tag(s): {string.Join(", ", unknown)}."); + tagMasterIds.AddRange(resolved.Values); + } + + // AI pre-screen sets the initial disposition; the mock defaults clean text to a human-review flag so the + // publish gate holds. Decision authority still rests with ModerateReviewCommand (human override). + var verdict = await moderation.ScreenAsync(request.Body, cancellationToken); + var initialStatus = verdict.Decision switch + { + ModerationDecision.Approve => ReviewModerationStatus.Published, + ModerationDecision.Reject => ReviewModerationStatus.Hidden, + _ => ReviewModerationStatus.PendingModeration + }; + + var now = dateTimeProvider.UtcNow; + + var review = new Review + { + BookingId = booking.BookingId, + CustomerProfileId = customerId, + NurseProfileId = booking.NurseProfileId, + Rating = request.Rating, + Body = string.IsNullOrWhiteSpace(request.Body) ? null : request.Body.Trim() + }; + foreach (var tagId in tagMasterIds) + review.TagLinks.Add(new ReviewTagLink { ReviewTagMasterId = tagId }); + + if (initialStatus != ReviewModerationStatus.PendingModeration) + review.Moderate(initialStatus, initialStatus == ReviewModerationStatus.Hidden ? verdict.Reason : null, null, now); + + await unitOfWork.ReviewRepository.AddAsync(review, cancellationToken); + + // A brand-new pending review does not count; an auto-published/auto-hidden one recomputes the aggregate + // from source in the same transaction (id 0 excludes nothing — the new review is folded in by status). + if (initialStatus != ReviewModerationStatus.PendingModeration) + await RecomputeNurseRating.ExecuteAsync(review, unitOfWork, searchIndex, cancellationToken); + + await unitOfWork.CommitAsync(); + + if (initialStatus != ReviewModerationStatus.PendingModeration) + await ReviewCache.InvalidateAggregateAsync(cache, booking.NurseProfileId, cancellationToken); + + // Low-rating safety signal: raise the internal alert reliably (self-committing facade, after the main + // commit) — never a silently-swallowed best-effort. A raise failure surfaces to the caller. + var lowRatingAlertRaised = false; + var threshold = await platformConfig.GetConfig("min_rating_for_support_alert", cancellationToken); + if (request.Rating <= threshold) + { + await supportAlerts.RaiseAsync( + SupportAlertType.LowRating, "review", review.Id.ToString(), SupportAlertSeverity.High, + bookingId: booking.BookingId, reviewId: review.Id, cancellationToken); + lowRatingAlertRaised = true; + } + + return OperationResult.SuccessResult( + new SubmitReviewResult(review.Id, review.ModerationStatus, lowRatingAlertRaised)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Validator.cs new file mode 100644 index 0000000..f3aade4 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Validator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Baya.Application.Features.Reviews.Commands.SubmitReview; + +public sealed class SubmitReviewCommandValidator : AbstractValidator +{ + public SubmitReviewCommandValidator() + { + RuleFor(x => x.BookingId).GreaterThan(0); + RuleFor(x => x.Rating).InclusiveBetween(1, 5); + RuleFor(x => x.Body).MaximumLength(2000); + RuleForEach(x => x.TagCodes).NotEmpty().MaximumLength(50).When(x => x.TagCodes is not null); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.cs b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.cs new file mode 100644 index 0000000..65c7f07 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.cs @@ -0,0 +1,14 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Commands.SubmitReview; + +/// A customer leaves the one allowed review for a completed booking: a 1–5 rating, optional free-text +/// body, and optional standardized tag codes. The booking id comes from the route, not the body. +public record SubmitReviewCommand( + long BookingId, + int Rating, + string? Body, + IReadOnlyList? TagCodes) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.Handler.cs new file mode 100644 index 0000000..81f406d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.Handler.cs @@ -0,0 +1,30 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue; + +internal sealed class GetReviewModerationQueueQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler>> +{ + public async ValueTask>> Handle( + GetReviewModerationQueueQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + var status = string.IsNullOrWhiteSpace(request.Status) + ? ReviewModerationStatus.PendingModeration + : request.Status; + + if (!ReviewModerationStatus.IsValid(status)) + return OperationResult>.FailureResult( + $"Unknown moderation status '{status}'."); + + var result = await unitOfWork.ReviewRepository.GetModerationQueueAsync(status, page, pageSize, cancellationToken); + return OperationResult>.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.cs new file mode 100644 index 0000000..1c51432 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewModerationQueue/GetReviewModerationQueueQuery.cs @@ -0,0 +1,11 @@ +#nullable enable +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.GetReviewModerationQueue; + +/// Admin moderation queue, paginated and filterable by moderation_status (defaults to +/// pending_moderation). Includes any linked low-rating alert id for staff triage. +public record GetReviewModerationQueueQuery(string? Status = null, int Page = 1, int PageSize = 20) + : IRequest>>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.Handler.cs new file mode 100644 index 0000000..18f3238 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.Handler.cs @@ -0,0 +1,18 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.GetTagAggregates; + +internal sealed class GetTagAggregatesQueryHandler(IUnitOfWork unitOfWork) + : IRequestHandler> +{ + public async ValueTask> Handle( + GetTagAggregatesQuery request, CancellationToken cancellationToken) + { + var result = await unitOfWork.ReviewRepository.GetTagAggregatesAsync(request.NurseProfileId, cancellationToken); + return OperationResult.SuccessResult(result); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.cs new file mode 100644 index 0000000..5d51996 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/GetTagAggregates/GetTagAggregatesQuery.cs @@ -0,0 +1,8 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.GetTagAggregates; + +/// Public per-nurse tag rollup ("% punctual", …) computed over the nurse's published reviews. +public record GetTagAggregatesQuery(long NurseProfileId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.Handler.cs new file mode 100644 index 0000000..fb5c5dd --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.Handler.cs @@ -0,0 +1,35 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.ListReviewsForNurse; + +/// +/// Public reviews read. Returns published reviews only (the publish gate is enforced at the query +/// layer, not the UI) and the nurse's rating aggregate — read from the denormalized, published-only columns +/// and cached, with cache invalidation on every moderation transition. +/// +internal sealed class ListReviewsForNurseQueryHandler( + IUnitOfWork unitOfWork, + ICacheService cache) + : IRequestHandler> +{ + public async ValueTask> Handle(ListReviewsForNurseQuery request, CancellationToken cancellationToken) + { + var page = request.Page < 1 ? 1 : request.Page; + var pageSize = request.PageSize is < 1 or > 100 ? 20 : request.PageSize; + + var aggregate = await cache.GetOrCreateAsync( + ReviewCache.AggregateKey(request.NurseProfileId), + async ct => await unitOfWork.ReviewRepository.GetNurseAggregateAsync(request.NurseProfileId, ct), + ReviewCache.Ttl, + cancellationToken); + + var reviews = await unitOfWork.ReviewRepository.ListPublishedForNurseAsync(request.NurseProfileId, page, pageSize, cancellationToken); + + return OperationResult.SuccessResult(new NurseReviewsResult(aggregate, reviews)); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.cs b/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.cs new file mode 100644 index 0000000..f7d10ab --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/Queries/ListReviewsForNurse/ListReviewsForNurseQuery.cs @@ -0,0 +1,9 @@ +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Mediator; + +namespace Baya.Application.Features.Reviews.Queries.ListReviewsForNurse; + +/// Public, paginated list of a nurse's published reviews plus the cached rating aggregate. +public record ListReviewsForNurseQuery(long NurseProfileId, int Page = 1, int PageSize = 20) + : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs b/server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs new file mode 100644 index 0000000..8bc2204 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs @@ -0,0 +1,48 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; +using Baya.Domain.Entities.Reviews; + +namespace Baya.Application.Features.Reviews; + +/// +/// Recomputes a nurse's denormalized rating aggregate from source — never by an incremental +/// +delta/-delta. It reads COUNT/SUM(rating) over the nurse's currently +/// published reviews (excluding the transitioning review), then folds in that review's new +/// moderation status. This is the fix for inflated-rating-after-hide drift: hiding a 1-star lowers the count +/// and re-derives the average from what remains public. +/// +/// It mutates the tracked and stages the b7 +/// search-index refresh on the same unit of work; the caller's single CommitAsync persists the review +/// transition, the aggregate, and the projection atomically. It does not commit. Invoked by every +/// moderation transition and by an auto-published/auto-hidden submit. +/// +/// +internal static class RecomputeNurseRating +{ + public static async Task ExecuteAsync( + Review changedReview, + IUnitOfWork unitOfWork, + ISearchIndexMaintainer searchIndex, + CancellationToken cancellationToken) + { + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(changedReview.NurseProfileId, cancellationToken); + if (profile is null) + return; + + var (count, sum) = await unitOfWork.ReviewRepository + .GetPublishedRatingStatsExcludingAsync(changedReview.NurseProfileId, changedReview.Id, cancellationToken); + + if (changedReview.ModerationStatus == ReviewModerationStatus.Published) + { + count += 1; + sum += changedReview.Rating; + } + + var average = count > 0 ? Math.Round(sum / (decimal)count, 2, MidpointRounding.AwayFromZero) : 0m; + profile.SetReviewAggregates(average, count); + + // Any aggregate change must reach the search projection in the same transaction (staged inline). + await searchIndex.ReindexNurseAsync(profile, null, cancellationToken); + } +} diff --git a/server/src/Core/Baya.Application/Features/Reviews/ReviewCache.cs b/server/src/Core/Baya.Application/Features/Reviews/ReviewCache.cs new file mode 100644 index 0000000..bfa5b02 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Reviews/ReviewCache.cs @@ -0,0 +1,19 @@ +#nullable enable +using Baya.Application.Contracts.Common; + +namespace Baya.Application.Features.Reviews; + +/// Cache-key scheme for the public per-nurse rating aggregate. The aggregate read is cached; every +/// moderation transition that can change it invalidates the nurse's key so a stale, inflated average is never +/// served. +internal static class ReviewCache +{ + private static readonly TimeSpan AggregateTtl = TimeSpan.FromMinutes(10); + + public static string AggregateKey(long nurseProfileId) => $"nurse_review_agg:{nurseProfileId}"; + + public static TimeSpan Ttl => AggregateTtl; + + public static ValueTask InvalidateAggregateAsync(ICacheService cache, long nurseProfileId, CancellationToken cancellationToken) + => cache.RemoveAsync(AggregateKey(nurseProfileId), cancellationToken); +} diff --git a/server/src/Core/Baya.Application/Models/Reviews/CareRecordProjections.cs b/server/src/Core/Baya.Application/Models/Reviews/CareRecordProjections.cs new file mode 100644 index 0000000..3c9164d --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Reviews/CareRecordProjections.cs @@ -0,0 +1,28 @@ +#nullable enable +namespace Baya.Application.Models.Reviews; + +/// A patient-care-record row as it leaves the repository — the clinical body is still ciphertext +/// here; the handler decrypts it only after the strict clinical access check passes, then maps to +/// . Keeping the cipher in the projection means no query path can surface plaintext. +public record CareRecordCipherRow( + long Id, + long PatientId, + long? BookingId, + long NurseProfileId, + string? NurseName, + string BodyEncrypted, + DateTime RecordedAt); + +/// A decrypted clinical note returned to an authorized reader (owning customer / nurse with a +/// confirmed booking / admin). Newest first. +public record CareRecordDto( + long Id, + long PatientId, + long? BookingId, + long NurseProfileId, + string? NurseName, + string Body, + DateTime RecordedAt); + +/// What WritePatientCareRecordCommand returns. +public record WriteCareRecordResult(long Id, long PatientId, DateTime RecordedAt); diff --git a/server/src/Core/Baya.Application/Models/Reviews/ReviewProjections.cs b/server/src/Core/Baya.Application/Models/Reviews/ReviewProjections.cs new file mode 100644 index 0000000..2c407bb --- /dev/null +++ b/server/src/Core/Baya.Application/Models/Reviews/ReviewProjections.cs @@ -0,0 +1,54 @@ +#nullable enable +using Baya.Application.Models.Common; + +namespace Baya.Application.Models.Reviews; + +/// The minimal booking facts a review submission needs — resolved from the bookings row to +/// enforce ownership, the completed/closed eligibility gate, and the nurse the aggregate belongs to. +public record ReviewableBooking(long BookingId, long CustomerProfileId, long NurseProfileId, string Status); + +/// What SubmitReviewCommand returns — the new review id and its (always +/// pending_moderation) status, plus whether a low-rating support alert was raised. +public record SubmitReviewResult(long Id, string ModerationStatus, bool LowRatingAlertRaised); + +/// What ModerateReviewCommand returns — the new status and the recomputed nurse aggregate +/// (so a caller/test can see the from-source recompute immediately). Rating is a decimal average. +public record ModerateReviewResult(long Id, string ModerationStatus, decimal AverageRating, int TotalReviews); + +/// The result of attaching/replacing a review's tags — the review id and its resulting tag codes. +public record ReviewTagsResult(long ReviewId, IReadOnlyList TagCodes); + +/// A public, published review line item — never carries moderation internals. +public record ReviewListItemDto( + long Id, + int Rating, + string? Body, + IReadOnlyList TagCodes, + DateTimeOffset CreatedAt); + +/// The public nurse rating aggregate — derived from published reviews only. +public record NurseReviewAggregateDto(decimal AverageRating, int PublishedCount); + +/// The public reviews payload for a nurse — the aggregate plus a page of published reviews. +public record NurseReviewsResult(NurseReviewAggregateDto Aggregate, PagedResult Reviews); + +/// An admin moderation-queue row — the full review plus any linked low-rating alert id (internal use +/// only; support alerts never appear on a user-facing route). +public record ModerationQueueItemDto( + long Id, + long BookingId, + long NurseProfileId, + long CustomerProfileId, + int Rating, + string? Body, + string ModerationStatus, + string? ModerationReason, + long? LowRatingAlertId, + DateTimeOffset CreatedAt); + +/// One tag's rollup for a nurse — the count of published reviews carrying it and that as a percentage +/// of the nurse's published reviews. +public record TagAggregateDto(string Code, string LabelFa, string LabelEn, int Count, decimal Percentage); + +/// The per-nurse tag rollup — the published-review base and each tag's share. +public record NurseTagAggregatesResult(int PublishedReviewCount, IReadOnlyList Tags); diff --git a/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs index 015c9ef..7fb86b1 100644 --- a/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs +++ b/server/src/Core/Baya.Domain/Entities/Identity/NurseProfile.cs @@ -51,4 +51,13 @@ public class NurseProfile : BaseEntity public void MarkUnverified() => IsVerified = false; public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting; + + /// The single sanctioned write path for the denormalized rating aggregates — called only by the + /// b14 reviews phase, which recomputes both values from source (published reviews only) on every + /// moderation transition. Never accepted from a request. + public void SetReviewAggregates(decimal averageRating, int totalReviews) + { + AverageRating = averageRating; + TotalReviews = totalReviews; + } } diff --git a/server/src/Core/Baya.Domain/Entities/Reviews/PatientCareRecord.cs b/server/src/Core/Baya.Domain/Entities/Reviews/PatientCareRecord.cs new file mode 100644 index 0000000..e5f0249 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Reviews/PatientCareRecord.cs @@ -0,0 +1,37 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Reviews; + +/// +/// A nurse-authored clinical note that accumulates into a patient-scoped longitudinal care history — +/// the scoping key is , not a booking. When a different nurse takes over they +/// read the prior history before accepting, so notes must never be siloed per visit; +/// is nullable provenance only (which visit produced the note). +/// +/// The clinical body is encrypted at rest: holds the +/// IFieldEncryptor-produced ciphertext (never plaintext). There is deliberately no EF value converter +/// on this column — the handler encrypts on write and decrypts only after the strict clinical access check +/// passes on read, so no query can accidentally surface plaintext. +/// +/// +public class PatientCareRecord : BaseEntity +{ + /// The scoping key — the patient this note belongs to (tenancy is the patient's owning customer). + public long PatientId { get; set; } + + /// Provenance only: the visit that produced the note. Nullable — a note is not booking-scoped. + public long? BookingId { get; set; } + + /// The authoring nurse's nurse_profiles.id. + public long NurseProfileId { get; set; } + + /// The clinical note, encrypted at rest via IFieldEncryptor. Never plaintext, never logged. + public string BodyEncrypted { get; set; } = string.Empty; + + /// When the note was recorded (UTC). Stored as datetime2 so the history read can order by it + /// on both SQL Server and the SQLite test provider (which cannot translate DateTimeOffset ordering). + public DateTime RecordedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Reviews/Review.cs b/server/src/Core/Baya.Domain/Entities/Reviews/Review.cs new file mode 100644 index 0000000..3d8b017 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Reviews/Review.cs @@ -0,0 +1,57 @@ +#nullable enable +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Reviews; + +/// +/// One customer review of a completed booking (1:1 with the booking, enforced by a UNIQUE on +/// ). A review is public social-proof and enters ; +/// it is never rendered publicly and never counted in the nurse aggregate until a moderation transition +/// publishes it. The moderation fields are guarded (private setters) and mutated only through +/// , so every transition also stamps the moderator and time. Marked +/// so the SaveChanges interceptor writes an append-only audit_logs diff for +/// creation and every moderation transition in the same transaction. +/// +public class Review : BaseEntity, IAuditable +{ + /// 1:1 with the completed booking (UNIQUE) — the anti-fraud, one-review-per-booking backstop. + public long BookingId { get; set; } + + /// The reviewing customer's customer_profiles.id (author). + public long CustomerProfileId { get; set; } + + /// The reviewed nurse's nurse_profiles.id — the aggregate this review drives when published. + public long NurseProfileId { get; set; } + + /// 1–5, enforced by a DB CHECK and by validation. + public int Rating { get; set; } + + /// Optional free-text body. + public string? Body { get; set; } + + /// Guarded — mutated only through . Defaults to + /// ; never public until published. + public string ModerationStatus { get; private set; } = Reviews.ReviewModerationStatus.PendingModeration; + + /// Set on hide/reject. + public string? ModerationReason { get; private set; } + + public int? ModeratedById { get; private set; } + + public DateTimeOffset? ModeratedAt { get; private set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public ICollection TagLinks { get; set; } = new List(); + + /// Applies a moderation transition and stamps the moderator + time. The caller maps the action + /// to a valid target status; a hide/reject carries a reason (cleared on publish/unpublish). The moderator + /// is null when the AI pre-screen set the initial disposition on submit. + public void Moderate(string targetStatus, string? reason, int? moderatedById, DateTimeOffset now) + { + ModerationStatus = targetStatus; + ModerationReason = reason; + ModeratedById = moderatedById; + ModeratedAt = now; + } +} diff --git a/server/src/Core/Baya.Domain/Entities/Reviews/ReviewModerationStatus.cs b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewModerationStatus.cs new file mode 100644 index 0000000..0c93697 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewModerationStatus.cs @@ -0,0 +1,54 @@ +namespace Baya.Domain.Entities.Reviews; + +/// +/// The moderation lifecycle of a , persisted as these stable snake_case codes (never a +/// C# enum member name). A review is born and is never public until an +/// admin/AI transition moves it to . Only reviews are rendered +/// publicly and only reviews count toward the nurse aggregate — which is recomputed +/// from source on every transition so hiding a low rating never leaves a stale, inflated average. +/// +public static class ReviewModerationStatus +{ + /// Default on submit. Not public, not counted in the aggregate. + public const string PendingModeration = "pending_moderation"; + + /// Approved and publicly visible. The only status counted in the nurse aggregate. + public const string Published = "published"; + + /// Withheld from the public list (e.g. off-topic, abusive) — removed from the aggregate. + public const string Hidden = "hidden"; + + /// Rejected outright (e.g. fake/spam) — never public, not counted. + public const string Rejected = "rejected"; + + public static readonly IReadOnlyList All = [PendingModeration, Published, Hidden, Rejected]; + + public static bool IsValid(string status) => All.Contains(status); +} + +/// The moderator action codes accepted by the moderation endpoint, mapped to a target status. +public static class ReviewModerationAction +{ + public const string Publish = "publish"; + public const string Hide = "hide"; + public const string Reject = "reject"; + + /// Pull a published review back to — it + /// leaves the public list and the aggregate is recomputed downward. + public const string Unpublish = "unpublish"; + + public static readonly IReadOnlyList All = [Publish, Hide, Reject, Unpublish]; + + /// Maps an action to its resulting , or null if unknown. + public static string? ToStatus(string action) => action switch + { + Publish => ReviewModerationStatus.Published, + Hide => ReviewModerationStatus.Hidden, + Reject => ReviewModerationStatus.Rejected, + Unpublish => ReviewModerationStatus.PendingModeration, + _ => null + }; + + /// Hide and reject must carry a reason; publish/unpublish need none. + public static bool RequiresReason(string action) => action is Hide or Reject; +} diff --git a/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagLink.cs b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagLink.cs new file mode 100644 index 0000000..a4c2146 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagLink.cs @@ -0,0 +1,16 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Reviews; + +/// +/// The N:N join between a and a . A UNIQUE(review_id, +/// review_tag_master_id) forbids the same tag twice on one review. +/// +public class ReviewTagLink : BaseEntity +{ + public long ReviewId { get; set; } + public Review Review { get; set; } + + public long ReviewTagMasterId { get; set; } + public ReviewTagMaster Tag { get; set; } +} diff --git a/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagMaster.cs b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagMaster.cs new file mode 100644 index 0000000..87adcf2 --- /dev/null +++ b/server/src/Core/Baya.Domain/Entities/Reviews/ReviewTagMaster.cs @@ -0,0 +1,35 @@ +using Baya.Domain.Common; + +namespace Baya.Domain.Entities.Reviews; + +/// +/// The standardized review-tag vocabulary (e.g. punctual, professional) used to turn qualitative +/// feedback into quantitative rollups ("% punctual"). Reference data — seeded via HasData, toggled with +/// , ordered by . Growing the vocabulary is an admin/seed insert, +/// not a schema change. +/// +public class ReviewTagMaster : BaseEntity +{ + /// Stable machine code (UNIQUE), e.g. punctual. + public string Code { get; set; } = string.Empty; + + public string LabelFa { get; set; } = string.Empty; + public string LabelEn { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + public int SortOrder { get; set; } + + public ICollection Links { get; set; } = new List(); +} + +/// The starter tag vocabulary codes seeded with the migration. +public static class ReviewTagCodes +{ + public const string Punctual = "punctual"; + public const string Professional = "professional"; + public const string Clean = "clean"; + public const string Kind = "kind"; + public const string Communicative = "communicative"; + + public static readonly IReadOnlyList All = [Punctual, Professional, Clean, Kind, Communicative]; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs new file mode 100644 index 0000000..4837942 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs @@ -0,0 +1,31 @@ +#nullable enable +using Baya.Application.Contracts.Reviews; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams; + +/// +/// Deterministic mock (b14) — a keyword filter / pass-through with no +/// external call. A banned-word hit → ; otherwise clean text → a +/// human-review by default (so the publish gate holds), or +/// when is set. +/// The real text classifier / LLM endpoint swaps in by a registration change only — the moderation command +/// keeps decision authority and the human override, so it never touches the handler. +/// +public sealed class MockReviewModerationService(IOptions options) : IReviewModerationService +{ + private readonly ReviewModerationOptions _options = options.Value.ReviewModeration; + + public ValueTask ScreenAsync(string? reviewText, CancellationToken cancellationToken = default) + { + var text = reviewText?.Trim() ?? string.Empty; + + var hit = _options.BannedWords.FirstOrDefault( + w => !string.IsNullOrWhiteSpace(w) && text.Contains(w, StringComparison.OrdinalIgnoreCase)); + if (hit is not null) + return ValueTask.FromResult(new ModerationVerdict(ModerationDecision.Reject, $"banned_word:{hit}")); + + var decision = _options.AutoApproveClean ? ModerationDecision.Approve : ModerationDecision.Flag; + return ValueTask.FromResult(new ModerationVerdict(decision, "clean")); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index c2e3811..bcd62b0 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -20,6 +20,22 @@ public sealed class SeamOptions public BnplOptions Bnpl { get; set; } = new(); public CurrencyOptions Currency { get; set; } = new(); public BankTransferOptions BankTransfer { get; set; } = new(); + public ReviewModerationOptions ReviewModeration { get; set; } = new(); +} + +/// +/// Tunes the mock IReviewModerationService (b14 AI review pre-screen). By default clean text returns a +/// human-review Flag (keeping the publish gate on); a banned-word hit returns Reject. Set +/// to have clean text auto-Approve (auto-publish). The real text +/// classifier / LLM endpoint ignores these knobs. +/// +public sealed class ReviewModerationOptions +{ + /// When true, clean text is auto-approved (auto-published) instead of flagged for human review. + public bool AutoApproveClean { get; set; } + + /// Case-insensitive substrings that mark a review for rejection. + public List BannedWords { get; set; } = ["scam", "fraud", "کلاهبردار"]; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 2b46ca3..15a37ed 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -1,6 +1,7 @@ using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Invoices; using Baya.Application.Contracts.Payments; +using Baya.Application.Contracts.Reviews; using Baya.Infrastructure.CrossCutting.Seams; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -80,6 +81,12 @@ public static class ServiceCollectionExtension // the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop. services.AddSingleton(); + // AI review moderation (backend-phase-14). The mock is a keyword filter / pass-through (clean → human + // flag by default so the publish gate holds; banned word → reject; config toggle auto-approves clean). + // A real text classifier / LLM endpoint swaps in by a registration change only — ModerateReviewCommand + // keeps decision authority + the human override, so the real impl never touches the handler. + services.AddSingleton(); + return services; } } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/PatientCareRecordConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/PatientCareRecordConfig.cs new file mode 100644 index 0000000..113c9f2 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/PatientCareRecordConfig.cs @@ -0,0 +1,34 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Reviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig; + +/// +/// patient_care_records — nurse-authored, encrypted, patient-scoped clinical notes. The +/// (patient_id, recorded_at DESC) index serves the longitudinal history read. booking_id is +/// nullable provenance only (which visit produced the note) — the scoping key is patient_id. +/// body_encrypted stores IFieldEncryptor ciphertext (no EF value converter — the handler +/// encrypts/decrypts explicitly, so no query path can surface plaintext). +/// +internal sealed class PatientCareRecordConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PatientCareRecords", "reviews"); + + builder.Property(r => r.BodyEncrypted).IsRequired(); + builder.Property(r => r.RecordedAt).IsRequired(); + + builder.HasIndex(r => new { r.PatientId, r.RecordedAt }) + .HasDatabaseName("IX_PatientCareRecords_Patient_RecordedAt"); + + builder.HasOne().WithMany().HasForeignKey(r => r.PatientId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.NurseProfileId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.BookingId); + + builder.HasQueryFilter(r => r.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewConfig.cs new file mode 100644 index 0000000..c1184ab --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewConfig.cs @@ -0,0 +1,39 @@ +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Reviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig; + +/// +/// reviews — one review per completed booking. The UNIQUE(booking_id) is the authoritative 1:1 +/// backstop; CHECK(rating BETWEEN 1 AND 5) guards the score. Only published reviews are public +/// or counted in the nurse aggregate — the (nurse_profile_id, moderation_status) index serves both the +/// public list and the recompute; the moderation_status index serves the moderation queue. +/// +internal sealed class ReviewConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Reviews", "reviews", t => t.HasCheckConstraint( + "CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5")); + + builder.Property(r => r.Rating).IsRequired(); + builder.Property(r => r.Body).HasMaxLength(2000); + builder.Property(r => r.ModerationStatus).HasMaxLength(30).IsRequired(); + builder.Property(r => r.ModerationReason).HasMaxLength(500); + + builder.HasIndex(r => r.BookingId).IsUnique(); + builder.HasIndex(r => new { r.NurseProfileId, r.ModerationStatus }); + builder.HasIndex(r => r.ModerationStatus); + + builder.HasOne().WithMany().HasForeignKey(r => r.BookingId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.CustomerProfileId).IsRequired(); + builder.HasOne().WithMany().HasForeignKey(r => r.NurseProfileId).IsRequired(); + + builder.HasMany(r => r.TagLinks).WithOne(l => l.Review).HasForeignKey(l => l.ReviewId).IsRequired(); + + builder.HasQueryFilter(r => r.DeletedAt == null); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagLinkConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagLinkConfig.cs new file mode 100644 index 0000000..ede9215 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagLinkConfig.cs @@ -0,0 +1,25 @@ +using Baya.Domain.Entities.Reviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig; + +/// +/// review_tag_links — the N:N join between a review and a master tag. The +/// UNIQUE(review_id, review_tag_master_id) forbids the same tag twice on one review; its leading column +/// is review_id, so it also serves "load a review's tags". +/// +internal sealed class ReviewTagLinkConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ReviewTagLinks", "reviews"); + + builder.HasIndex(l => new { l.ReviewId, l.ReviewTagMasterId }) + .IsUnique() + .HasDatabaseName("UX_ReviewTagLinks_Review_Tag"); + + builder.HasOne(l => l.Review).WithMany(r => r.TagLinks).HasForeignKey(l => l.ReviewId).IsRequired(); + builder.HasOne(l => l.Tag).WithMany(t => t.Links).HasForeignKey(l => l.ReviewTagMasterId).IsRequired(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagMasterConfig.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagMasterConfig.cs new file mode 100644 index 0000000..fba39c6 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewTagMasterConfig.cs @@ -0,0 +1,28 @@ +using Baya.Domain.Entities.Reviews; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig; + +/// +/// review_tags_master — the standardized tag vocabulary (seeded via HasData). code is +/// UNIQUE; ordering/toggling is data (sort_order/is_active). +/// +internal sealed class ReviewTagMasterConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("ReviewTagsMaster", "reviews"); + + builder.Property(t => t.Code).HasMaxLength(50).IsRequired(); + builder.Property(t => t.LabelFa).HasMaxLength(100).IsRequired(); + builder.Property(t => t.LabelEn).HasMaxLength(100).IsRequired(); + builder.Property(t => t.IsActive).HasDefaultValue(true); + builder.Property(t => t.SortOrder).HasDefaultValue(0); + + builder.HasIndex(t => t.Code).IsUnique(); + builder.HasIndex(t => new { t.IsActive, t.SortOrder }); + + builder.HasData(ReviewsSeed.Tags()); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewsSeed.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewsSeed.cs new file mode 100644 index 0000000..a664e57 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/ReviewsConfig/ReviewsSeed.cs @@ -0,0 +1,38 @@ +using Baya.Domain.Entities.Reviews; + +namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig; + +/// +/// The starter review-tag vocabulary, seeded via HasData so it lands with the migration on a fresh DB. +/// Ids are fixed and deterministic (1…5, sort_order = id) so re-running is idempotent and the model snapshot +/// stays stable. Growing the vocabulary is another seeded/admin row, not a schema change. +/// +internal static class ReviewsSeed +{ + // (id, code, label_fa, label_en) + private static readonly (long Id, string Code, string LabelFa, string LabelEn)[] TagRows = + [ + (1, ReviewTagCodes.Punctual, "وقت‌شناس", "Punctual"), + (2, ReviewTagCodes.Professional, "حرفه‌ای", "Professional"), + (3, ReviewTagCodes.Clean, "تمیز و بهداشتی", "Clean"), + (4, ReviewTagCodes.Kind, "مهربان", "Kind"), + (5, ReviewTagCodes.Communicative, "خوش‌برخورد", "Communicative"), + ]; + + public static object[] Tags() + { + var ts = SeedConstants.Timestamp; + return TagRows + .Select(t => (object)new + { + t.Id, + t.Code, + t.LabelFa, + t.LabelEn, + IsActive = true, + SortOrder = (int)t.Id, + CreatedAt = ts + }) + .ToArray(); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.Designer.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.Designer.cs new file mode 100644 index 0000000..122aa3b --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.Designer.cs @@ -0,0 +1,5595 @@ +// +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("20260709010659_ReviewsAndPatientCareRecords")] + partial class ReviewsAndPatientCareRecords + { + /// + 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("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.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.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.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.Notifications.Notification", b => + { + b.HasOne("Baya.Domain.Entities.User.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + 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.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/20260709010659_ReviewsAndPatientCareRecords.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.cs new file mode 100644 index 0000000..d4d1b81 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/20260709010659_ReviewsAndPatientCareRecords.cs @@ -0,0 +1,269 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace Baya.Infrastructure.Persistence.Migrations +{ + /// + public partial class ReviewsAndPatientCareRecords : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "reviews"); + + migrationBuilder.CreateTable( + name: "PatientCareRecords", + schema: "reviews", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + PatientId = table.Column(type: "bigint", nullable: false), + BookingId = table.Column(type: "bigint", nullable: true), + NurseProfileId = table.Column(type: "bigint", nullable: false), + BodyEncrypted = table.Column(type: "nvarchar(max)", nullable: false), + RecordedAt = table.Column(type: "datetime2", 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_PatientCareRecords", x => x.Id); + table.ForeignKey( + name: "FK_PatientCareRecords_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PatientCareRecords_NurseProfiles_NurseProfileId", + column: x => x.NurseProfileId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_PatientCareRecords_Patients_PatientId", + column: x => x.PatientId, + principalSchema: "usr", + principalTable: "Patients", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Reviews", + schema: "reviews", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + BookingId = table.Column(type: "bigint", nullable: false), + CustomerProfileId = table.Column(type: "bigint", nullable: false), + NurseProfileId = table.Column(type: "bigint", nullable: false), + Rating = table.Column(type: "int", nullable: false), + Body = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + ModerationStatus = table.Column(type: "nvarchar(30)", maxLength: 30, nullable: false), + ModerationReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + ModeratedById = table.Column(type: "int", nullable: true), + ModeratedAt = 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_Reviews", x => x.Id); + table.CheckConstraint("CK_Reviews_Rating", "[Rating] BETWEEN 1 AND 5"); + table.ForeignKey( + name: "FK_Reviews_Bookings_BookingId", + column: x => x.BookingId, + principalSchema: "booking", + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Reviews_CustomerProfiles_CustomerProfileId", + column: x => x.CustomerProfileId, + principalSchema: "usr", + principalTable: "CustomerProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Reviews_NurseProfiles_NurseProfileId", + column: x => x.NurseProfileId, + principalSchema: "usr", + principalTable: "NurseProfiles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ReviewTagsMasters", + schema: "reviews", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Code = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + LabelFa = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + LabelEn = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + IsActive = table.Column(type: "bit", nullable: false, defaultValue: true), + SortOrder = table.Column(type: "int", nullable: false, defaultValue: 0), + 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_ReviewTagsMasters", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ReviewTagLinks", + schema: "reviews", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ReviewId = table.Column(type: "bigint", nullable: false), + ReviewTagMasterId = table.Column(type: "bigint", nullable: false), + 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_ReviewTagLinks", x => x.Id); + table.ForeignKey( + name: "FK_ReviewTagLinks_ReviewTagsMasters_ReviewTagMasterId", + column: x => x.ReviewTagMasterId, + principalSchema: "reviews", + principalTable: "ReviewTagsMasters", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ReviewTagLinks_Reviews_ReviewId", + column: x => x.ReviewId, + principalSchema: "reviews", + principalTable: "Reviews", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + schema: "reviews", + table: "ReviewTagsMasters", + columns: new[] { "Id", "Code", "CreatedAt", "CreatedById", "IsActive", "LabelEn", "LabelFa", "ModifiedAt", "ModifiedById", "SortOrder" }, + values: new object[,] + { + { 1L, "punctual", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Punctual", "وقت‌شناس", null, null, 1 }, + { 2L, "professional", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Professional", "حرفه‌ای", null, null, 2 }, + { 3L, "clean", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Clean", "تمیز و بهداشتی", null, null, 3 }, + { 4L, "kind", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Kind", "مهربان", null, null, 4 }, + { 5L, "communicative", new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, true, "Communicative", "خوش‌برخورد", null, null, 5 } + }); + + migrationBuilder.CreateIndex( + name: "IX_PatientCareRecords_BookingId", + schema: "reviews", + table: "PatientCareRecords", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_PatientCareRecords_NurseProfileId", + schema: "reviews", + table: "PatientCareRecords", + column: "NurseProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_PatientCareRecords_Patient_RecordedAt", + schema: "reviews", + table: "PatientCareRecords", + columns: new[] { "PatientId", "RecordedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_BookingId", + schema: "reviews", + table: "Reviews", + column: "BookingId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_CustomerProfileId", + schema: "reviews", + table: "Reviews", + column: "CustomerProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_ModerationStatus", + schema: "reviews", + table: "Reviews", + column: "ModerationStatus"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_NurseProfileId_ModerationStatus", + schema: "reviews", + table: "Reviews", + columns: new[] { "NurseProfileId", "ModerationStatus" }); + + migrationBuilder.CreateIndex( + name: "IX_ReviewTagLinks_ReviewTagMasterId", + schema: "reviews", + table: "ReviewTagLinks", + column: "ReviewTagMasterId"); + + migrationBuilder.CreateIndex( + name: "UX_ReviewTagLinks_Review_Tag", + schema: "reviews", + table: "ReviewTagLinks", + columns: new[] { "ReviewId", "ReviewTagMasterId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReviewTagsMasters_Code", + schema: "reviews", + table: "ReviewTagsMasters", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ReviewTagsMasters_IsActive_SortOrder", + schema: "reviews", + table: "ReviewTagsMasters", + columns: new[] { "IsActive", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PatientCareRecords", + schema: "reviews"); + + migrationBuilder.DropTable( + name: "ReviewTagLinks", + schema: "reviews"); + + migrationBuilder.DropTable( + name: "ReviewTagsMasters", + schema: "reviews"); + + migrationBuilder.DropTable( + name: "Reviews", + schema: "reviews"); + } + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 76c9a8b..6fad541 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -3586,6 +3586,272 @@ namespace Baya.Infrastructure.Persistence.Migrations }); }); + 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") @@ -4979,6 +5245,65 @@ namespace Baya.Infrastructure.Persistence.Migrations .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) @@ -5215,6 +5540,16 @@ namespace Baya.Infrastructure.Persistence.Migrations 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"); 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 c9d9dca..e2b7731 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/Common/UnitOfWork.cs @@ -27,6 +27,8 @@ public class UnitOfWork : IUnitOfWork public IInvoiceRepository InvoiceRepository { get; } public IBnplRepository BnplRepository { get; } public IPayoutRepository PayoutRepository { get; } + public IReviewRepository ReviewRepository { get; } + public IPatientCareRecordRepository PatientCareRecordRepository { get; } public UnitOfWork(ApplicationDbContext db) { @@ -52,6 +54,8 @@ public class UnitOfWork : IUnitOfWork InvoiceRepository = new InvoiceRepository(_db); BnplRepository = new BnplRepository(_db); PayoutRepository = new PayoutRepository(_db); + ReviewRepository = new ReviewRepository(_db); + PatientCareRecordRepository = new PatientCareRecordRepository(_db); } public Task CommitAsync() diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs index 3f703d9..552f7a6 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/CustomerProfileRepository.cs @@ -29,4 +29,10 @@ internal sealed class CustomerProfileRepository : BaseAsyncRepository c.UserId == userId) .Select(c => (long?)c.Id) .FirstOrDefaultAsync(cancellationToken); + + public Task GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken) + => TableNoTracking + .Where(c => c.Id == customerProfileId) + .Select(c => (int?)c.UserId) + .FirstOrDefaultAsync(cancellationToken); } diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientCareRecordRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientCareRecordRepository.cs new file mode 100644 index 0000000..f855560 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PatientCareRecordRepository.cs @@ -0,0 +1,71 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Reviews; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class PatientCareRecordRepository : BaseAsyncRepository, IPatientCareRecordRepository +{ + // A booking that reached (at least) confirmed ties a nurse to a patient — the clinical-access gate. A + // pending_payment or cancelled booking never grants clinical access. + private static readonly string[] QualifyingBookingStatuses = + [ + BookingStatus.Confirmed, BookingStatus.InProgress, BookingStatus.Completed, + BookingStatus.Disputed, BookingStatus.Closed + ]; + + public PatientCareRecordRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(PatientCareRecord record, CancellationToken cancellationToken) => base.AddAsync(record); + + public Task GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(p => p.Id == patientId) + .Select(p => (long?)p.CustomerId) + .FirstOrDefaultAsync(cancellationToken); + + public Task NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .AnyAsync(b => b.NurseId == nurseProfileId + && b.PatientId == patientId + && QualifyingBookingStatuses.Contains(b.Status), cancellationToken); + + public async Task> GetPatientHistoryAsync( + long patientId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = Entities.AsNoTracking().Where(r => r.PatientId == patientId); + + var total = await query.CountAsync(cancellationToken); + var rows = await query + .OrderByDescending(r => r.RecordedAt) + .ThenByDescending(r => r.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => new + { + r.Id, r.PatientId, r.BookingId, r.NurseProfileId, r.BodyEncrypted, r.RecordedAt, + NurseName = DbContext.Set() + .Where(n => n.Id == r.NurseProfileId) + .Select(n => n.User.Name + " " + n.User.FamilyName) + .FirstOrDefault() + }) + .ToListAsync(cancellationToken); + + var items = rows + .Select(r => new CareRecordCipherRow( + r.Id, r.PatientId, r.BookingId, r.NurseProfileId, + string.IsNullOrWhiteSpace(r.NurseName) ? null : r.NurseName.Trim(), + r.BodyEncrypted, r.RecordedAt)) + .ToList(); + + return new PagedResult(items, total, page, pageSize); + } +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/ReviewRepository.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/ReviewRepository.cs new file mode 100644 index 0000000..27c3211 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/ReviewRepository.cs @@ -0,0 +1,144 @@ +#nullable enable +using Baya.Application.Contracts.Persistence; +using Baya.Application.Models.Common; +using Baya.Application.Models.Reviews; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.SupportAlerts; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Infrastructure.Persistence.Repositories; + +internal sealed class ReviewRepository : BaseAsyncRepository, IReviewRepository +{ + public ReviewRepository(ApplicationDbContext dbContext) : base(dbContext) + { + } + + public Task AddAsync(Review review, CancellationToken cancellationToken) => base.AddAsync(review); + + public Task GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken) + => DbContext.Set().AsNoTracking() + .Where(b => b.Id == bookingId) + .Select(b => new ReviewableBooking(b.Id, b.CustomerId, b.NurseId, b.Status)) + .FirstOrDefaultAsync(cancellationToken); + + public Task ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken) + => Entities.AnyAsync(r => r.BookingId == bookingId, cancellationToken); + + public Task GetTrackedAsync(long reviewId, CancellationToken cancellationToken) + => Entities.FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken); + + public Task GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken) + => Entities.Include(r => r.TagLinks).FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken); + + public async Task> GetTagIdsByCodesAsync(IReadOnlyList codes, CancellationToken cancellationToken) + { + if (codes.Count == 0) + return new Dictionary(); + + return await DbContext.Set().AsNoTracking() + .Where(t => t.IsActive && codes.Contains(t.Code)) + .ToDictionaryAsync(t => t.Code, t => t.Id, cancellationToken); + } + + public async Task<(int Count, long Sum)> GetPublishedRatingStatsExcludingAsync( + long nurseProfileId, long excludeReviewId, CancellationToken cancellationToken) + { + var stats = await Entities.AsNoTracking() + .Where(r => r.NurseProfileId == nurseProfileId + && r.ModerationStatus == ReviewModerationStatus.Published + && r.Id != excludeReviewId) + .GroupBy(_ => 1) + .Select(g => new { Count = g.Count(), Sum = g.Sum(x => (long)x.Rating) }) + .FirstOrDefaultAsync(cancellationToken); + + return stats is null ? (0, 0) : (stats.Count, stats.Sum); + } + + public async Task GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken) + { + var aggregate = await DbContext.Set().AsNoTracking() + .Where(p => p.Id == nurseProfileId) + .Select(p => new NurseReviewAggregateDto(p.AverageRating, p.TotalReviews)) + .FirstOrDefaultAsync(cancellationToken); + + return aggregate ?? new NurseReviewAggregateDto(0m, 0); + } + + public async Task> ListPublishedForNurseAsync( + long nurseProfileId, int page, int pageSize, CancellationToken cancellationToken) + { + var query = Entities.AsNoTracking() + .Where(r => r.NurseProfileId == nurseProfileId && r.ModerationStatus == ReviewModerationStatus.Published); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(r => r.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => new ReviewListItemDto( + r.Id, r.Rating, r.Body, + r.TagLinks.Select(l => l.Tag.Code).ToList(), + r.CreatedAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task> GetModerationQueueAsync( + string? status, int page, int pageSize, CancellationToken cancellationToken) + { + var query = Entities.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(r => r.ModerationStatus == status); + + var total = await query.CountAsync(cancellationToken); + var items = await query + .OrderByDescending(r => r.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(r => new ModerationQueueItemDto( + r.Id, r.BookingId, r.NurseProfileId, r.CustomerProfileId, r.Rating, r.Body, + r.ModerationStatus, r.ModerationReason, + DbContext.Set() + .Where(a => a.ReviewId == r.Id && a.Type == SupportAlertType.LowRating) + .Select(a => (long?)a.Id) + .FirstOrDefault(), + r.CreatedAt)) + .ToListAsync(cancellationToken); + + return new PagedResult(items, total, page, pageSize); + } + + public async Task GetTagAggregatesAsync(long nurseProfileId, CancellationToken cancellationToken) + { + var publishedCount = await Entities.AsNoTracking() + .CountAsync(r => r.NurseProfileId == nurseProfileId && r.ModerationStatus == ReviewModerationStatus.Published, cancellationToken); + + var tagCounts = await DbContext.Set().AsNoTracking() + .Where(l => l.Review.NurseProfileId == nurseProfileId && l.Review.ModerationStatus == ReviewModerationStatus.Published) + .GroupBy(l => l.ReviewTagMasterId) + .Select(g => new { TagId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.TagId, x => x.Count, cancellationToken); + + var masters = await DbContext.Set().AsNoTracking() + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder) + .Select(t => new { t.Id, t.Code, t.LabelFa, t.LabelEn }) + .ToListAsync(cancellationToken); + + var tags = masters.Select(m => + { + var count = tagCounts.GetValueOrDefault(m.Id); + var percentage = publishedCount > 0 + ? Math.Round(100m * count / publishedCount, 1, MidpointRounding.AwayFromZero) + : 0m; + return new TagAggregateDto(m.Code, m.LabelFa, m.LabelEn, count, percentage); + }).ToList(); + + return new NurseTagAggregatesResult(publishedCount, tags); + } +} diff --git a/server/src/Tests/Baya.Test.Api/ReviewsApiTests.cs b/server/src/Tests/Baya.Test.Api/ReviewsApiTests.cs new file mode 100644 index 0000000..3e36395 --- /dev/null +++ b/server/src/Tests/Baya.Test.Api/ReviewsApiTests.cs @@ -0,0 +1,48 @@ +using System.Net; + +namespace Baya.Test.Api; + +/// HTTP-pipeline coverage for the b14 review + care-record surface: public reads are anonymous, admin +/// and patient reads are auth-gated (401 without a token). +public class ReviewsApiTests(BayaApiFactory factory) : IClassFixture +{ + [Fact] + public async Task PublicReviews_UnknownNurse_Returns200WithEmptyAggregate() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/nurses/999999/reviews"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var data = await AuthTestClient.ReadDataAsync(response); + Assert.Equal(0, data.GetProperty("aggregate").GetProperty("publishedCount").GetInt32()); + Assert.Empty(data.GetProperty("reviews").GetProperty("items").EnumerateArray()); + } + + [Fact] + public async Task PublicReviewTags_UnknownNurse_Returns200() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/nurses/999999/review_tags"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The seeded active tag vocabulary is always returned (with zero counts for an unknown nurse). + var data = await AuthTestClient.ReadDataAsync(response); + Assert.NotEmpty(data.GetProperty("tags").EnumerateArray()); + } + + [Fact] + public async Task ModerationQueue_Unauthenticated_Returns401() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/admin/reviews/moderation_queue"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PatientCareRecords_Unauthenticated_Returns401() + { + var anon = factory.CreateClient(); + var response = await anon.GetAsync("/api/v1/patients/1/care_records"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Reviews/PatientCareRecordHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Reviews/PatientCareRecordHandlerTests.cs new file mode 100644 index 0000000..a8e42ce --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Reviews/PatientCareRecordHandlerTests.cs @@ -0,0 +1,98 @@ +#nullable enable +using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; +using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Reviews; +using Baya.Tests.Setup.Setups; +using Microsoft.EntityFrameworkCore; + +namespace Baya.Test.Foundation.Reviews; + +public class PatientCareRecordHandlerTests +{ + private const string Note = "Blood pressure 120/80, patient calm, meds taken."; + + [Fact] + public async Task Nurse_with_confirmed_booking_writes_encrypted_record_and_can_read_it_back() + { + using var host = new ReviewsTestHost(); + host.SeedBooking(BookingStatus.Confirmed); + + var write = new WritePatientCareRecordCommandHandler( + host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()); + var written = await write.Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None); + Assert.True(written.IsSuccess); + + // Stored column is ciphertext, not plaintext. + var stored = host.Db.Set().AsNoTracking().Single().BodyEncrypted; + Assert.NotEqual(Note, stored); + Assert.Equal(Note, TestFieldEncryptor.Instance.Decrypt(stored)); + + // The authoring nurse can read the decrypted history. + var read = await new GetPatientHistoryQueryHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance) + .Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None); + Assert.True(read.IsSuccess); + var record = Assert.Single(read.Result.Items); + Assert.Equal(Note, record.Body); + } + + [Fact] + public async Task Owning_customer_can_read_history() + { + using var host = new ReviewsTestHost(); + host.SeedBooking(BookingStatus.Completed); + await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()) + .Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None); + + var read = await new GetPatientHistoryQueryHandler(host.AsCustomer(), host.UnitOfWork, TestFieldEncryptor.Instance) + .Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None); + + Assert.True(read.IsSuccess); + Assert.Equal(Note, Assert.Single(read.Result.Items).Body); + } + + [Fact] + public async Task Nurse_without_a_booking_is_denied_write_and_read() + { + using var host = new ReviewsTestHost(); + host.SeedBooking(BookingStatus.Completed); // for the assigned nurse only + await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()) + .Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None); + + var write = await new WritePatientCareRecordCommandHandler(host.AsOtherNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()) + .Handle(new WritePatientCareRecordCommand(host.PatientId, null, "unauthorized"), CancellationToken.None); + Assert.False(write.IsSuccess); + Assert.True(write.IsForbidden); + + var read = await new GetPatientHistoryQueryHandler(host.AsOtherNurse(), host.UnitOfWork, TestFieldEncryptor.Instance) + .Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None); + Assert.False(read.IsSuccess); + Assert.True(read.IsForbidden); + } + + [Fact] + public async Task Admin_can_read_history() + { + using var host = new ReviewsTestHost(); + host.SeedBooking(BookingStatus.Completed); + await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()) + .Handle(new WritePatientCareRecordCommand(host.PatientId, null, Note), CancellationToken.None); + + var read = await new GetPatientHistoryQueryHandler(host.AsAdmin(), host.UnitOfWork, TestFieldEncryptor.Instance) + .Handle(new GetPatientHistoryQuery(host.PatientId), CancellationToken.None); + + Assert.True(read.IsSuccess); + Assert.Single(read.Result.Items); + } + + [Fact] + public async Task Write_for_missing_patient_is_not_found() + { + using var host = new ReviewsTestHost(); + var write = await new WritePatientCareRecordCommandHandler(host.AsNurse(), host.UnitOfWork, TestFieldEncryptor.Instance, host.Clock()) + .Handle(new WritePatientCareRecordCommand(999999, null, Note), CancellationToken.None); + + Assert.False(write.IsSuccess); + Assert.True(write.IsNotFound); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewHandlerTests.cs b/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewHandlerTests.cs new file mode 100644 index 0000000..366bd8d --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewHandlerTests.cs @@ -0,0 +1,195 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Reviews; +using Baya.Application.Contracts.Search; +using Baya.Application.Contracts.SupportAlerts; +using Baya.Application.Features.Reviews.Commands.ModerateReview; +using Baya.Application.Features.Reviews.Commands.SubmitReview; +using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Reviews; +using Baya.Domain.Entities.SupportAlerts; +using NSubstitute; + +namespace Baya.Test.Foundation.Reviews; + +public class ReviewHandlerTests +{ + private static SubmitReviewCommandHandler SubmitHandler( + ReviewsTestHost host, ISupportAlertService alerts, ModerationDecision decision = ModerationDecision.Flag) + => new( + host.AsCustomer(), host.UnitOfWork, host.Config(), host.Moderation(decision), + alerts, Substitute.For(), Substitute.For(), host.Clock()); + + private static ModerateReviewCommandHandler ModerateHandler(ReviewsTestHost host) + => new( + host.AsAdmin(), host.UnitOfWork, Substitute.For(), + Substitute.For(), host.Clock(), Substitute.For()); + + [Fact] + public async Task Submit_on_completed_booking_creates_pending_and_not_public() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + + var result = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ReviewModerationStatus.PendingModeration, result.Result.ModerationStatus); + + // Publish gate: a pending review is not in the public list and not counted in the aggregate. + var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache()) + .Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None); + Assert.Empty(list.Result.Reviews.Items); + Assert.Equal(0, list.Result.Aggregate.PublishedCount); + } + + [Fact] + public async Task Submit_on_cancelled_booking_fails_and_duplicate_is_conflict() + { + using var host = new ReviewsTestHost(); + + var cancelled = host.SeedBooking(BookingStatus.Cancelled); + var onCancelled = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(cancelled, 5, null, null), CancellationToken.None); + Assert.False(onCancelled.IsSuccess); + Assert.False(onCancelled.IsException); + + var completed = host.SeedBooking(BookingStatus.Completed); + var first = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(completed, 4, null, null), CancellationToken.None); + Assert.True(first.IsSuccess); + + var second = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(completed, 3, null, null), CancellationToken.None); + Assert.False(second.IsSuccess); + Assert.True(second.IsConflict); + } + + [Fact] + public async Task Submit_by_non_owner_is_not_found() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + + // A different user with a customer role but not the booking owner (no matching customer profile). + var stranger = new SubmitReviewCommandHandler( + host.AsUser(4242, Domain.Entities.User.RoleNames.Customer), host.UnitOfWork, host.Config(), + host.Moderation(), Substitute.For(), Substitute.For(), + Substitute.For(), host.Clock()); + + var result = await stranger.Handle(new SubmitReviewCommand(bookingId, 5, null, null), CancellationToken.None); + Assert.False(result.IsSuccess); + Assert.True(result.IsForbidden || result.IsNotFound); + } + + [Fact] + public async Task Moderation_recomputes_aggregate_from_source_on_publish_and_hide() + { + using var host = new ReviewsTestHost(); + var high = host.SeedBooking(BookingStatus.Completed); + var low = host.SeedBooking(BookingStatus.Completed); + + var r5 = (await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(high, 5, "excellent", null), CancellationToken.None)).Result.Id; + var r1 = (await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(low, 1, "poor", null), CancellationToken.None)).Result.Id; + + // Publish both → count 2, avg 3.0. + await ModerateHandler(host).Handle(new ModerateReviewCommand(r5, ReviewModerationAction.Publish, null), CancellationToken.None); + var afterBoth = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Publish, null), CancellationToken.None); + Assert.Equal(2, afterBoth.Result.TotalReviews); + Assert.Equal(3.0m, afterBoth.Result.AverageRating); + Assert.Equal(2, host.TotalReviewsOf(host.NurseId)); + + // Hide the 1★ → count 1, avg 5.0 (re-derived from source, not stale). + var afterHide = await ModerateHandler(host).Handle(new ModerateReviewCommand(r1, ReviewModerationAction.Hide, "off_topic"), CancellationToken.None); + Assert.Equal(1, afterHide.Result.TotalReviews); + Assert.Equal(5.0m, afterHide.Result.AverageRating); + Assert.Equal(1, host.TotalReviewsOf(host.NurseId)); + Assert.Equal(5.0m, host.AverageRatingOf(host.NurseId)); + } + + [Fact] + public async Task Low_rating_raises_support_alert() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + var alerts = Substitute.For(); + + var result = await SubmitHandler(host, alerts) + .Handle(new SubmitReviewCommand(bookingId, 1, "unhappy", null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.True(result.Result.LowRatingAlertRaised); + await alerts.Received(1).RaiseAsync( + SupportAlertType.LowRating, "review", Arg.Any(), SupportAlertSeverity.High, + bookingId, result.Result.Id, Arg.Any()); + } + + [Fact] + public async Task High_rating_does_not_raise_alert() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + var alerts = Substitute.For(); + + var result = await SubmitHandler(host, alerts) + .Handle(new SubmitReviewCommand(bookingId, 5, "great", null), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(result.Result.LowRatingAlertRaised); + await alerts.DidNotReceive().RaiseAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Submit_with_tags_persists_links_and_surfaces_on_publish() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + + var submit = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(bookingId, 5, "great", [ReviewTagCodes.Punctual, ReviewTagCodes.Kind]), CancellationToken.None); + Assert.True(submit.IsSuccess); + + await ModerateHandler(host).Handle(new ModerateReviewCommand(submit.Result.Id, ReviewModerationAction.Publish, null), CancellationToken.None); + + var list = await new ListReviewsForNurseQueryHandler(host.UnitOfWork, new PassThroughCache()) + .Handle(new ListReviewsForNurseQuery(host.NurseId), CancellationToken.None); + var item = Assert.Single(list.Result.Reviews.Items); + Assert.Contains(ReviewTagCodes.Punctual, item.TagCodes); + Assert.Contains(ReviewTagCodes.Kind, item.TagCodes); + } + + [Fact] + public async Task Submit_with_unknown_tag_fails() + { + using var host = new ReviewsTestHost(); + var bookingId = host.SeedBooking(BookingStatus.Completed); + + var result = await SubmitHandler(host, Substitute.For()) + .Handle(new SubmitReviewCommand(bookingId, 5, null, ["not_a_real_tag"]), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.False(result.IsException); + } + + /// A cache that always runs the factory — exercises the real aggregate read without caching. + private sealed class PassThroughCache : ICacheService + { + public ValueTask GetAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.FromResult(default); + + public ValueTask SetAsync(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; + + public ValueTask GetOrCreateAsync(string key, Func> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default) + => factory(cancellationToken); + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewsTestHost.cs b/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewsTestHost.cs new file mode 100644 index 0000000..0e01a5e --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Reviews/ReviewsTestHost.cs @@ -0,0 +1,219 @@ +#nullable enable +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Configuration; +using Baya.Application.Contracts.Reviews; +using Baya.Domain.Entities.Booking; +using Baya.Domain.Entities.Catalog; +using Baya.Domain.Entities.Geography; +using Baya.Domain.Entities.Identity; +using Baya.Domain.Entities.User; +using Baya.Infrastructure.Persistence; +using Baya.Infrastructure.Persistence.Repositories.Common; +using Baya.Tests.Setup.Setups; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using BookingEntity = Baya.Domain.Entities.Booking.Booking; + +namespace Baya.Test.Foundation.Reviews; + +/// +/// A self-contained SQLite host exercising the real EF model (schema, CHECK, unique indexes, seed) for the b14 +/// reviews + patient-care-records engine. Seeds one bookable nurse, a second (unassigned) nurse, one customer + +/// patient + address, and can create a completed booking so a test can drive the real handlers against the real +/// with substituted seams. +/// +public sealed class ReviewsTestHost : IDisposable +{ + private readonly SqliteConnection _connection; + public ApplicationDbContext Db { get; } + public UnitOfWork UnitOfWork { get; } + + public long CustomerId { get; } + public int CustomerUserId { get; } + public long NurseId { get; } + public int NurseUserId { get; } + public long OtherNurseId { get; } + public int OtherNurseUserId { get; } + public long PatientId { get; } + + private readonly long _cityId; + private readonly long _categoryId; + private readonly long _addressId; + + public ReviewsTestHost() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance); + Db.Database.EnsureCreated(); + UnitOfWork = new UnitOfWork(Db); + + var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(province); + Db.SaveChanges(); + var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true }; + Db.Set().Add(city); + var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true }; + Db.Set().Add(category); + Db.SaveChanges(); + _cityId = city.Id; + _categoryId = category.Id; + + var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true }; + Db.Users.Add(customerUser); + Db.SaveChanges(); + CustomerUserId = customerUser.Id; + var customer = new CustomerProfile { UserId = customerUser.Id }; + Db.Set().Add(customer); + Db.SaveChanges(); + CustomerId = customer.Id; + + var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true }; + Db.Set().Add(patient); + var address = new CustomerAddress + { + CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول", + PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001", + Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true + }; + Db.Set().Add(address); + Db.SaveChanges(); + PatientId = patient.Id; + _addressId = address.Id; + + (NurseId, NurseUserId) = SeedNurse("nurse1", "09120000002"); + (OtherNurseId, OtherNurseUserId) = SeedNurse("nurse2", "09120000003"); + } + + private (long NurseId, int UserId) SeedNurse(string userName, string phone) + { + var nurseUser = new User { UserName = userName, PhoneNumber = phone, Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true }; + Db.Users.Add(nurseUser); + Db.SaveChanges(); + var nurse = new NurseProfile { UserId = nurseUser.Id }; + nurse.MarkVerified(); + nurse.SetAcceptingBookings(true); + Db.Set().Add(nurse); + Db.SaveChanges(); + return (nurse.Id, nurseUser.Id); + } + + /// Seeds a booking for the seeded nurse + patient in the given status (default completed). + public long SeedBooking(string status = BookingStatus.Completed, long? nurseId = null) + { + var owningNurse = nurseId ?? NurseId; + var variant = new NurseServiceVariant + { + NurseId = owningNurse, ServiceCategoryId = _categoryId, Price = 10_000_000, PriceUnit = "per_day", + SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true + }; + Db.Set().Add(variant); + Db.SaveChanges(); + + var request = new BookingRequest + { + CustomerId = CustomerId, NurseId = owningNurse, PatientId = PatientId, VariantId = variant.Id, + CustomerAddressId = _addressId, RequiredCaregiverGender = CaregiverGender.Any, + RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0), + CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc) + }; + Db.Set().Add(request); + Db.SaveChanges(); + request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + Db.SaveChanges(); + + var booking = new BookingEntity + { + BookingRequestId = request.Id, CustomerId = CustomerId, NurseId = owningNurse, PatientId = PatientId, + VariantId = variant.Id, CustomerAddressId = _addressId, VariantSnapshotJson = "{}", AddressSnapshotJson = "{}", + GrossPriceIrr = 10_000_000, BalinyaarCommissionIrr = 1_500_000, PlatformFeeRate = 0.15m, + NursePayoutAmount = 8_500_000, SessionCount = 1, + ScheduledDate = new DateOnly(2026, 8, 1), ScheduledTimeStart = new TimeOnly(9, 0), ScheduledTimeEnd = new TimeOnly(13, 0) + }; + var now = new DateTime(2026, 8, 2, 0, 0, 0, DateTimeKind.Utc); + WalkTo(booking, status, now); + Db.Set().Add(booking); + Db.SaveChanges(); + + return booking.Id; + } + + private static void WalkTo(BookingEntity booking, string status, DateTime now) + { + if (status == BookingStatus.PendingPayment) + return; + + booking.TransitionTo(BookingStatus.Confirmed, now); + if (status == BookingStatus.Confirmed) + return; + + // Cancelled is only legal from confirmed/in-progress, never from a completed booking. + if (status == BookingStatus.Cancelled) + { + booking.TransitionTo(BookingStatus.Cancelled, now, actor: CancellationActor.Customer, reason: "test"); + return; + } + + booking.TransitionTo(BookingStatus.InProgress, now); + if (status == BookingStatus.InProgress) + return; + + booking.TransitionTo(BookingStatus.Completed, now); + if (status is BookingStatus.Closed or BookingStatus.Disputed) + booking.TransitionTo(status, now); + } + + public decimal AverageRatingOf(long nurseId) + => Db.Set().AsNoTracking().Where(p => p.Id == nurseId).Select(p => p.AverageRating).First(); + + public int TotalReviewsOf(long nurseId) + => Db.Set().AsNoTracking().Where(p => p.Id == nurseId).Select(p => p.TotalReviews).First(); + + public ICurrentUser AsCustomer() => User(CustomerUserId, RoleNames.Customer); + public ICurrentUser AsNurse() => User(NurseUserId, RoleNames.Nurse); + public ICurrentUser AsOtherNurse() => User(OtherNurseUserId, RoleNames.Nurse); + public ICurrentUser AsAdmin(int userId = 9999) => User(userId, RoleNames.Admin); + public ICurrentUser AsUser(int userId, params string[] roles) => User(userId, roles); + + private static ICurrentUser User(int userId, params string[] roles) + { + var u = Substitute.For(); + u.UserId.Returns(userId); + u.IsAuthenticated.Returns(true); + u.Roles.Returns(roles); + return u; + } + + public IDateTimeProvider Clock() => Clock(new DateTimeOffset(2026, 8, 3, 10, 0, 0, TimeSpan.Zero)); + + public IDateTimeProvider Clock(DateTimeOffset now) + { + var c = Substitute.For(); + c.UtcNow.Returns(now); + return c; + } + + public IPlatformConfig Config(decimal lowRatingThreshold = 2m) + { + var cfg = Substitute.For(); + cfg.GetConfig("min_rating_for_support_alert", Arg.Any()).Returns(lowRatingThreshold); + return cfg; + } + + public IReviewModerationService Moderation(ModerationDecision decision = ModerationDecision.Flag) + { + var m = Substitute.For(); + m.ScreenAsync(Arg.Any(), Arg.Any()) + .Returns(new ModerationVerdict(decision, "test")); + return m; + } + + public void Dispose() + { + Db.Dispose(); + _connection.Dispose(); + } +}