backend phase 14 & frontend phase 7

This commit is contained in:
hamid
2026-07-09 15:30:03 +03:30
parent de53f9d8a6
commit 93cc5ecb98
101 changed files with 12930 additions and 39 deletions
+8 -2
View File
@@ -127,7 +127,9 @@ client/
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/ │ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /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) │ │ │ ├── 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) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet │ │ │ ├── wallet/page.tsx # /wallet
@@ -135,6 +137,7 @@ client/
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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) │ ├── 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) │ ├── 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) │ ├── 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) │ ├── 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 │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
├── i18n/ ├── 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 │ ├── 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 │ ├── 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 │ ├── 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}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical) │ ├── 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) - `'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) - `'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 - `'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 - `'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) - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
+109 -5
View File
@@ -9,6 +9,7 @@
"coverage": "Coverage", "coverage": "Coverage",
"services": "Services", "services": "Services",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"requests": "Requests",
"verification": "Verification", "verification": "Verification",
"visits": "Visits", "visits": "Visits",
"admin": "Admin", "admin": "Admin",
@@ -365,12 +366,115 @@
"request_booking": "Request booking" "request_booking": "Request booking"
}, },
"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", "request_title": "Booking request",
"deferred": "The booking request form arrives in the next phase.", "form_subtitle": "Send a request to this nurse. No payment is taken yet — the nurse reviews it first.",
"handoff_echo": "Nurse #{nurse}, service #{variant}, caregiver: {gender}.", "missing_nurse_title": "No nurse selected",
"gender_female": "woman", "missing_nurse_body": "Choose a nurse from search, then send a request.",
"gender_male": "man", "missing_nurse_cta": "Find a nurse",
"gender_any": "no preference" "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": { "auth": {
"customer_title": "Sign in to Balinyaar", "customer_title": "Sign in to Balinyaar",
+108 -4
View File
@@ -9,6 +9,7 @@
"coverage": "پوشش", "coverage": "پوشش",
"services": "خدمات", "services": "خدمات",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"requests": "درخواست‌ها",
"verification": "احراز هویت", "verification": "احراز هویت",
"visits": "ویزیت‌ها", "visits": "ویزیت‌ها",
"admin": "مدیریت", "admin": "مدیریت",
@@ -365,12 +366,115 @@
"request_booking": "درخواست رزرو" "request_booking": "درخواست رزرو"
}, },
"booking": { "booking": {
"request_title": "درخواست رزرو",
"deferred": "فرم درخواست رزرو در فاز بعدی اضافه می‌شود.",
"handoff_echo": "پرستار #{nurse}، خدمت #{variant}، جنسیت مراقب: {gender}.",
"gender_female": "خانم", "gender_female": "خانم",
"gender_male": "آقا", "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": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بلینیار",
@@ -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 (
<Suspense fallback={<AppLoading />}>
<CheckoutDeferred />
</Suspense>
);
}
function CheckoutDeferred() {
const t = useTranslations('booking');
const params = useSearchParams();
const requestId = params.get('request_id') ?? '—';
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
}
@@ -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 <StatusSkeleton />;
if (isError || !request) {
return (
<TerminalCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={t('retry')}
onCta={() => refetch()}
/>
);
}
const goToSearch = () => router.push(`/${locale}${ROUTES.SEARCH}`);
const addressLabel = customerAddressLabel(request, locale, t('address_whole_city'));
const summary = (
<BookingRequestSummaryCard
nurseName={request.nurseName}
nurseRating={request.nurseRating}
patientName={request.patientName}
variantLabel={request.variantLabel}
variantPrice={request.variantPrice}
variantPriceUnit={request.variantPriceUnit}
addressLabel={addressLabel}
requestedDate={request.requestedDate}
requestedTimeStart={request.requestedTimeStart}
requestedTimeEnd={request.requestedTimeEnd}
/>
);
// Terminal states — each is its own card with a re-request path back into discovery (or booking).
if (request.status === 'rejected_by_nurse') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="rejected"
tone="var(--bal-error)"
title={t('rejected_title')}
body={request.nurseRejectionReason ? `${t('rejected_reason_label')}: ${request.nurseRejectionReason}` : undefined}
ctaLabel={t('terminal_rerequest')}
onCta={goToSearch}
/>
</Stack>
);
}
if (request.status === 'expired_no_response') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'payment_deadline_expired') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="pending" tone="var(--bal-warning)" title={t('payment_expired_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'cancelled_by_customer') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard icon="rejected" tone="var(--bal-text-secondary)" title={t('cancelled_title')} ctaLabel={t('terminal_rerequest')} onCta={goToSearch} />
</Stack>
);
}
if (request.status === 'converted') {
return (
<Stack sx={{ gap: 3 }}>
{summary}
<TerminalCard
icon="verified"
tone="var(--bal-success)"
title={t('converted_title')}
ctaLabel={t('converted_cta')}
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}
/>
</Stack>
);
}
const accepted = request.status === 'accepted_awaiting_payment';
const activeStep = accepted ? 2 : 1;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 0.5, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="pending" size={40} color="var(--bal-primary)" />
<Typography variant="h6" component="h1">
{t('awaiting_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('awaiting_subtitle')}
</Typography>
</Stack>
<StepperHeader
steps={[t('step_submitted'), t('step_awaiting'), t('step_payment')]}
activeStep={activeStep}
/>
{summary}
{accepted ? (
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: 'var(--bal-secondary)',
}}
>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status="verified" label={t('accepted_badge')} />
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{t('accepted_body')}
</Typography>
{request.paymentDeadlineAt ? (
<CountdownTimer
deadlineIso={request.paymentDeadlineAt}
label={t('payment_countdown_label')}
elapsedText={t('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
) : null}
<AppButton
color="secondary"
variant="contained"
size="large"
endIcon="payment"
onClick={() => router.push(`/${locale}${ROUTES.CHECKOUT}?request_id=${request.id}`)}
sx={{ m: 0, py: 1.25 }}
>
{t('continue_payment')}
</AppButton>
</Stack>
</Paper>
) : (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
</Paper>
)}
<AppButton
variant="text"
color="error"
disabled={cancelRequest.isPending}
onClick={() => setConfirmCancel(true)}
sx={{ m: 0, alignSelf: 'center' }}
>
{cancelRequest.isPending ? t('cancelling') : t('cancel_request')}
</AppButton>
<Dialog open={confirmCancel} onClose={() => setConfirmCancel(false)}>
<DialogTitle>{t('cancel_confirm_title')}</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('cancel_confirm_body')}
</Typography>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setConfirmCancel(false)}>
{t('cancel_request')}
</AppButton>
<AppButton
color="error"
variant="contained"
onClick={() => {
setConfirmCancel(false);
cancelRequest.mutate(request.id);
}}
>
{t('cancel_confirm_yes')}
</AppButton>
</DialogActions>
</Dialog>
</Stack>
);
}
/** "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 (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function StatusSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="circular" width={44} height={44} />
<Skeleton variant="text" width="60%" height={28} />
</Stack>
<Skeleton variant="rounded" height={72} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={96} />
</Stack>
);
}
@@ -1,32 +1,545 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useLocale, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation';
import { AppLoading, PlaceholderScreen } from '@/components'; 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 * C4 — Booking-request form (فرم درخواست). The destination of the C3 "درخواست رزرو" CTA (it carries the
* carrying the selected nurse + variant + the same-gender intent (`required_gender`, which becomes * `nurse_id`, an optional `variant_id`, and the same-gender `required_gender` intent from search). The
* `required_caregiver_gender` in b8) + city/category. f7 builds the actual request form; this placeholder * family picks a patient (f2), one of the nurse's service variants (f4/search profile), a saved address
* confirms the intent arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. * (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 ( return (
<Suspense fallback={<AppLoading />}> <Suspense fallback={<AppLoading />}>
<BookingRequestDeferred /> <BookingRequestForm />
</Suspense> </Suspense>
); );
} }
function BookingRequestDeferred() { function BookingRequestForm() {
const t = useTranslations('booking'); const t = useTranslations('booking');
const params = useSearchParams(); const tAddress = useTranslations('address');
const gender = params.get('required_gender'); const locale = useLocale();
const echo = t('handoff_echo', { const router = useRouter();
nurse: params.get('nurse_id') ?? '—', const query = useSearchParams();
variant: params.get('variant_id') ?? '—',
gender: gender ? t(`gender_${gender}`) : t('gender_any'),
});
return <PlaceholderScreen icon="bookings" title={t('request_title')} description={[t('deferred'), echo].join(' ')} />; 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<number | ''>('');
const [variantSel, setVariantSel] = useState<number | ''>(variantIdParam ?? '');
const [addressSel, setAddressSel] = useState<number | ''>('');
const [gender, setGender] = useState<RequiredCaregiverGender | ''>(
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<string | null>(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 (
<EmptyState
icon="search"
title={t('missing_nurse_title')}
body={t('missing_nurse_body')}
ctaLabel={t('missing_nurse_cta')}
onCta={() => router.push(`/${locale}${ROUTES.SEARCH}`)}
/>
);
}
if (profileQuery.isLoading) return <FormSkeleton />;
const timeError = attempted && timeStart !== '' && timeEnd !== '' && timeEnd <= timeStart;
const pastError = pastDateError;
return (
<Stack sx={{ gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('request_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('form_subtitle')}
</Typography>
</Box>
{/* Patient */}
{patients.length === 0 ? (
<FieldEmpty
label={t('patient_label')}
message={t('patient_empty')}
ctaLabel={t('patient_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.PATIENTS}`)}
/>
) : (
<TextField
select
label={t('patient_label')}
value={patientId}
error={attempted && patientId === ''}
helperText={attempted && patientId === '' ? t('error_patient_required') : undefined}
onChange={(event) => setPatientId(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('patient_placeholder')}
</MenuItem>
{patients.map((patient) => (
<MenuItem key={patient.id} value={patient.id}>
{patient.displayName}
</MenuItem>
))}
</TextField>
)}
{/* Service variant */}
{services.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('service_empty')}
</Typography>
) : (
<TextField
select
label={t('service_label')}
value={variantId}
error={attempted && variantId === ''}
helperText={attempted && variantId === '' ? t('error_service_required') : undefined}
onChange={(event) => setVariantSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('service_placeholder')}
</MenuItem>
{services.map((service) => (
<MenuItem key={service.variantId} value={service.variantId}>
{service.displayName}
</MenuItem>
))}
</TextField>
)}
{selectedVariant ? (
<Box sx={{ mt: -1.5 }}>
<PriceDisplay
price={selectedVariant.priceIrr}
priceUnit={selectedVariant.priceUnit}
sessionCount={selectedVariant.sessionCount}
/>
</Box>
) : null}
{/* Address */}
{addresses.length === 0 ? (
<FieldEmpty
label={t('address_label')}
message={t('address_empty')}
ctaLabel={t('address_add_cta')}
onCta={() => router.push(`/${locale}${ROUTES.ADDRESSES}`)}
/>
) : (
<Stack sx={{ gap: 1 }}>
<TextField
select
label={t('address_label')}
value={addressId}
error={attempted && addressId === ''}
helperText={attempted && addressId === '' ? t('error_address_required') : undefined}
onChange={(event) => setAddressSel(Number(event.target.value))}
fullWidth
>
<MenuItem value="" disabled>
{t('address_placeholder')}
</MenuItem>
{addresses.map((address) => (
<MenuItem key={address.id} value={address.id}>
{address.title} · {locale === 'en' ? address.cityNameEn : address.cityNameFa}
</MenuItem>
))}
</TextField>
{selectedAddress ? (
<Paper elevation={0} sx={{ p: 1.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
{regionLabel()}
{selectedAddress.addressLine ? `${selectedAddress.addressLine}` : ''}
</Typography>
{selectedAddress.latitude != null && selectedAddress.longitude != null ? (
// Read-only preview of the address's stored pin (the pin itself is set in the f3 book).
<Box sx={{ pointerEvents: 'none' }}>
<AddressMapPicker
value={{ latitude: selectedAddress.latitude, longitude: selectedAddress.longitude }}
onChange={() => undefined}
center={cityCentroid(selectedAddress.cityId)}
helperText={regionLabel()}
latLabel={tAddress('map_lat')}
lngLabel={tAddress('map_lng')}
/>
</Box>
) : null}
</Paper>
) : null}
</Stack>
)}
{/* Date + time */}
<Stack direction={{ xs: 'column', sm: 'row' }} sx={{ gap: 2 }}>
<TextField
type="date"
label={t('date_label')}
value={date}
error={(attempted && date === '') || pastError}
helperText={pastError ? t('error_past_date') : attempted && date === '' ? t('error_date_required') : undefined}
onChange={(event) => {
setDate(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_start_label')}
value={timeStart}
onChange={(event) => {
setTimeStart(event.target.value);
if (pastDateError) setPastDateError(false);
}}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
type="time"
label={t('time_end_label')}
value={timeEnd}
error={timeError}
helperText={timeError ? t('error_time_range') : undefined}
onChange={(event) => setTimeEnd(event.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
{/* Caregiver gender — first-class, three-way, never silently defaulted */}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('gender_label')}
</Typography>
<ToggleButtonGroup
exclusive
color="primary"
value={gender || null}
onChange={(_event, next: RequiredCaregiverGender | null) => {
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) => (
<ToggleButton key={option} value={option} data-gender={option}>
{t(`gender_${option}`)}
</ToggleButton>
))}
</ToggleButtonGroup>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gender_hint')}
</Typography>
{attempted && gender === '' ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_required')}
</Typography>
) : null}
{genderMismatch ? (
<Typography variant="caption" sx={{ color: 'var(--bal-error)' }}>
{t('error_gender_mismatch')}
</Typography>
) : null}
</Stack>
{/* Stage-1 notes */}
<TextField
label={t('notes_label')}
placeholder={t('notes_placeholder')}
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, CUSTOMER_NOTES_MAX_LENGTH))}
multiline
minRows={3}
fullWidth
helperText={t('notes_hint')}
/>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'end', mt: -2 }}>
{t('notes_counter', { count: notes.length, max: CUSTOMER_NOTES_MAX_LENGTH })}
</Typography>
{formError ? (
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{formError}
</Typography>
) : null}
<AppButton
color="primary"
variant="contained"
size="large"
startIcon="requests"
disabled={!requiredChosen || genderMismatch || createRequest.isPending}
onClick={handleSubmit}
sx={{ m: 0, py: 1.5 }}
>
{createRequest.isPending ? t('submitting') : t('submit')}
</AppButton>
</Stack>
);
}
/** 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 (
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{label}
</Typography>
<Paper elevation={0} sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="add" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Stack>
</Paper>
</Stack>
);
}
function EmptyState({
icon,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
title: string;
body: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: 0.5 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function FormSkeleton() {
return (
<Stack sx={{ gap: 2.5 }}>
<Skeleton variant="text" width="50%" height={36} />
{[0, 1, 2, 3].map((key) => (
<Skeleton key={key} variant="rounded" height={56} />
))}
<Skeleton variant="rounded" height={96} />
</Stack>
);
} }
@@ -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 <DetailSkeleton />;
if (isError || !request) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2, maxWidth: 640 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
{t('not_found_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{t('not_found_body')}
</Typography>
<AppButton variant="outlined" color="primary" onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}`)} sx={{ m: 0 }}>
{t('inbox_title')}
</AppButton>
</Paper>
);
}
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
<StatusChip status={statusKind(request.status)} label={t(`status_${request.status}`)} />
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<DetailRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.patientName}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{request.variantLabel}
</Typography>
{request.variantPrice ? (
<PriceDisplay price={request.variantPrice} priceUnit={request.variantPriceUnit} />
) : null}
</Stack>
</DetailRow>
<DetailRow caption={t('location_label')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{location}
</Typography>
</DetailRow>
<DetailRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</DetailRow>
{request.requiredCaregiverGender ? (
<DetailRow caption={t('gender_label')}>
<Chip
size="small"
label={t(`gender_${request.requiredCaregiverGender}`)}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</DetailRow>
) : null}
</Stack>
</Paper>
{/* Stage-1 clinical context — ONLY the family's notes, never a clinical/care field. */}
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: request.customerNotes ? 'text.primary' : 'text.secondary' }}>
{request.customerNotes || '—'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('disclosure_note')}
</Typography>
</Stack>
</Stack>
</Paper>
{pending ? (
<Stack sx={{ gap: 1.5 }}>
<CountdownTimer
deadlineIso={request.nurseResponseDeadlineAt}
label={t('response_countdown_label')}
elapsedText={t('response_elapsed')}
onElapsed={() => refetch()}
/>
<Stack direction="row" sx={{ gap: 1 }}>
<AppButton
color="primary"
variant="contained"
startIcon="verified"
disabled={acceptRequest.isPending}
onClick={handleAccept}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{acceptRequest.isPending ? t('accepting') : t('accept')}
</AppButton>
<AppButton
color="error"
variant="outlined"
startIcon="rejected"
disabled={acceptRequest.isPending}
onClick={() => setRejectOpen(true)}
sx={{ m: 0, flex: 1, py: 1.25 }}
>
{t('reject')}
</AppButton>
</Stack>
</Stack>
) : (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStartWidth: 4,
borderInlineStartColor: isTerminal ? 'var(--bal-text-secondary)' : 'var(--bal-secondary)',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`status_${request.status}`)}
</Typography>
</Paper>
)}
<Dialog open={rejectOpen} onClose={() => setRejectOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{t('reject_dialog_title')}</DialogTitle>
<DialogContent>
<TextField
autoFocus
label={t('reject_reason_label')}
placeholder={t('reject_reason_placeholder')}
value={reason}
onChange={(event) => {
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 }}
/>
</DialogContent>
<DialogActions>
<AppButton variant="text" onClick={() => setRejectOpen(false)}>
{t('cancel_request')}
</AppButton>
<AppButton color="error" variant="contained" disabled={rejectRequest.isPending} onClick={handleReject}>
{rejectRequest.isPending ? t('rejecting') : t('reject_submit')}
</AppButton>
</DialogActions>
</Dialog>
</Box>
);
}
/** 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 (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
function DetailSkeleton() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={180} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={56} />
</Box>
);
}
@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 640 }}>
<Box>
<Typography variant="h5" component="h1">
{t('inbox_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('inbox_subtitle')}
</Typography>
</Box>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((key) => (
<Skeleton key={key} variant="rounded" height={140} />
))}
</Stack>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="requests" size={40} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
{t('inbox_empty')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<InboxCard key={item.id} item={item} />
))}
</Stack>
)}
</Box>
);
}
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 (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 2 }}>
<Stack sx={{ gap: 0.5, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{item.counterpartyName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{whenLabel}
</Typography>
</Stack>
<CountdownTimer deadlineIso={item.nurseResponseDeadlineAt} elapsedText={t('response_elapsed')} />
</Stack>
{item.requiredCaregiverGender ? (
<Box>
<Chip
size="small"
label={t('required_gender_chip', { gender: t(`gender_${item.requiredCaregiverGender}`) })}
sx={{ bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 600 }}
/>
</Box>
) : null}
{item.customerNotes ? (
<Box>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('inbox_notes_label')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }} noWrap>
{item.customerNotes}
</Typography>
</Box>
) : null}
<AppButton
variant="outlined"
color="primary"
endIcon="requests"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_REQUESTS}/${item.id}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('open_detail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -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<BookingRequestSummaryCardProps> = {}) {
return render(
<ThemeProvider>
<BookingRequestSummaryCard {...BASE} {...props} />
</ThemeProvider>,
);
}
describe('<BookingRequestSummaryCard/> 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();
});
});
@@ -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<BookingRequestSummaryCardProps> = ({
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 (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center' }}>
<Avatar
src={nurseAvatarUrl ?? undefined}
sx={{ width: 56, height: 56, bgcolor: 'var(--bal-primary-soft)', color: 'var(--bal-primary)', fontWeight: 700 }}
>
{name.charAt(0)}
</Avatar>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
{ratingLabel ? (
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="star" size={16} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{ratingLabel}
</Typography>
</Stack>
) : null}
</Stack>
</Stack>
<Divider />
<Stack sx={{ gap: 1.5 }}>
<SummaryRow caption={t('summary_patient')}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{patientName}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_service')}>
<Stack sx={{ gap: 0.25, alignItems: 'flex-end' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{variantLabel}
</Typography>
{variantPrice ? (
<PriceDisplay price={variantPrice} priceUnit={variantPriceUnit} align="start" />
) : null}
</Stack>
</SummaryRow>
<SummaryRow caption={t('summary_address')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{addressLabel}
</Typography>
</SummaryRow>
<SummaryRow caption={t('summary_when')}>
<Typography variant="body2" sx={{ fontWeight: 600, textAlign: 'end' }}>
{whenLabel}
</Typography>
</SummaryRow>
</Stack>
</Stack>
</Paper>
);
};
function SummaryRow({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 2, justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', flexShrink: 0 }}>
{caption}
</Typography>
{children}
</Stack>
);
}
export default BookingRequestSummaryCard;
@@ -0,0 +1,2 @@
export { default } from './BookingRequestSummaryCard';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
@@ -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(
<ThemeProvider>
<CountdownTimer {...props} />
</ThemeProvider>,
);
}
describe('<CountdownTimer/> 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);
});
});
@@ -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<CountdownTimerProps> = ({
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 (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', color: 'text.secondary' }}>
<AppIcon icon="pending" size={18} color="var(--bal-text-secondary)" />
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{elapsedText}
</Typography>
</Stack>
);
}
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 (
<Stack sx={{ gap: 0.25, alignItems: 'center' }}>
{label ? (
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{label}
</Typography>
) : null}
<Stack direction="row" sx={{ gap: 0.5, alignItems: 'center' }}>
<AppIcon icon="pending" size={20} color={accent} />
<Typography
component="span"
dir="ltr"
sx={{ fontWeight: 700, fontSize: '1.5rem', fontVariantNumeric: 'tabular-nums', color: accent }}
>
{clock}
</Typography>
</Stack>
</Stack>
);
};
export default CountdownTimer;
@@ -0,0 +1,2 @@
export { default } from './CountdownTimer';
export type { CountdownTimerProps } from './CountdownTimer';
@@ -58,6 +58,9 @@ import PublishIcon from '@mui/icons-material/RocketLaunchOutlined';
// Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls // Search & discovery — the customer nurse-finding flow (f6/b7): rating star, filter controls
import StarIcon from '@mui/icons-material/Star'; import StarIcon from '@mui/icons-material/Star';
import TuneIcon from '@mui/icons-material/TuneOutlined'; 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 * List of all available Icon names
@@ -128,4 +131,6 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
publish: PublishIcon, publish: PublishIcon,
star: StarIcon, star: StarIcon,
tune: TuneIcon, tune: TuneIcon,
requests: RequestsIcon,
payment: PaymentIcon,
}; };
+6
View File
@@ -19,6 +19,8 @@ import TrustBadge from './TrustBadge';
import DocumentUpload from './DocumentUpload'; import DocumentUpload from './DocumentUpload';
import NurseResultCard from './NurseResultCard'; import NurseResultCard from './NurseResultCard';
import ServicePriceRow from './ServicePriceRow'; import ServicePriceRow from './ServicePriceRow';
import CountdownTimer from './CountdownTimer';
import BookingRequestSummaryCard from './BookingRequestSummaryCard';
export { export {
UserInfo, UserInfo,
@@ -40,6 +42,8 @@ export {
DocumentUpload, DocumentUpload,
NurseResultCard, NurseResultCard,
ServicePriceRow, ServicePriceRow,
CountdownTimer,
BookingRequestSummaryCard,
}; };
export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput'; export type { OtpInputProps } from './OtpInput';
@@ -59,3 +63,5 @@ export type { TrustBadgeProps } from './TrustBadge';
export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload'; export type { DocumentUploadProps, UploadedDocInfo } from './DocumentUpload';
export type { NurseResultCardProps } from './NurseResultCard'; export type { NurseResultCardProps } from './NurseResultCard';
export type { ServicePriceRowProps } from './ServicePriceRow'; export type { ServicePriceRowProps } from './ServicePriceRow';
export type { CountdownTimerProps } from './CountdownTimer';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
+7 -1
View File
@@ -14,8 +14,12 @@ export const ROUTES = {
// C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this). // C3 nurse profile base — append `/{nurseId}` (results cards + the booking handoff read this).
SEARCH_NURSE: '/search/nurse', SEARCH_NURSE: '/search/nurse',
BOOKINGS: '/bookings', 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', 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', PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses', ADDRESSES: '/addresses',
@@ -30,6 +34,8 @@ export const ROUTES = {
// Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search). // Coverage-area editor — the cities/districts the nurse will travel to (feeds f6 search).
NURSE_COVERAGE: '/nurse/coverage', NURSE_COVERAGE: '/nurse/coverage',
NURSE_BANK: '/nurse/bank', 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). // Verification (trust engine) subtree — B3 hub + the staged submission screens (B4/B5/B6).
NURSE_VERIFICATION: '/nurse/verification', NURSE_VERIFICATION: '/nurse/verification',
NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity', NURSE_VERIFICATION_IDENTITY: '/nurse/verification/identity',
+1
View File
@@ -18,6 +18,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const sidebarItems: Array<LinkToPage> = useMemo( const sidebarItems: Array<LinkToPage> = useMemo(
() => [ () => [
{ title: t('dashboard'), path: ROUTES.NURSE, icon: 'dashboard' }, { 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('profile'), path: ROUTES.NURSE_PROFILE, icon: 'profile' },
{ title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' }, { title: t('services'), path: ROUTES.NURSE_SERVICES, icon: 'services' },
{ title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' }, { title: t('coverage'), path: ROUTES.NURSE_COVERAGE, icon: 'coverage' },
@@ -0,0 +1,82 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { BOOKING_REQUEST_PAGE_SIZE } from '../constants';
import type {
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
} from '../types';
const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null };
}
/**
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
*
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse,
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice`
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
*/
export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
get: async (id: number) =>
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams();
query.set('role', params.role);
if (params.status) query.set('status', params.status);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<BookingRequestListItem>>>(`${BASE}/list?${query.toString()}`),
);
},
accept: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto(
unwrap(
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, {
method: 'POST',
body: JSON.stringify(payload),
}),
),
),
cancel: async (id: number) =>
toDto(
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
};
@@ -0,0 +1,12 @@
import { USE_BOOKING_REQUESTS_MOCK } from '../constants';
import type { BookingRequestsApi } from '../types';
import { bookingRequestsClientApi } from './clientApi';
import { bookingRequestsMockApi } from './mockApi';
/**
* The selected `BookingRequestsApi` implementation — the single seam the hooks import. Selection is by
* config (`USE_BOOKING_REQUESTS_MOCK`), never by scattered `if (mock)` checks.
*/
export const bookingRequestsApi: BookingRequestsApi = USE_BOOKING_REQUESTS_MOCK
? bookingRequestsMockApi
: bookingRequestsClientApi;
@@ -0,0 +1,285 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { Paginated } from '@/lib/api/types';
import {
BOOKING_REQUEST_PAGE_SIZE,
CUSTOMER_NOTES_MAX_LENGTH,
MOCK_PAYMENT_DEADLINE_MINUTES,
MOCK_RESPONSE_DEADLINE_MINUTES,
REJECTION_REASON_MAX_LENGTH,
} from '../constants';
import type {
BookingRequestDisplayContext,
BookingRequestDto,
BookingRequestListItem,
BookingRequestListParams,
BookingRequestsApi,
BookingRequestStatus,
CreateBookingRequestPayload,
RejectBookingRequestPayload,
RequestRole,
} from '../types';
import { isTerminalBookingRequestStatus } from '../types';
const MOCK_LATENCY_MS = 350;
// Shared, module-level store so the customer's created request appears in the nurse inbox and a nurse
// accept/reject flips the customer's polled C5 — all within one browser session (one module instance).
// Seeded with two pending requests so a nurse-only visit sees a non-empty inbox before any create.
let store: BookingRequestDto[] = [];
let nextId = 1;
const minutesFromNow = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString();
function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
const id = nextId++;
return {
id,
status: 'pending_nurse_response',
nurseId: 1,
nurseName: 'مریم رضایی',
nurseRating: 4.9,
nurseTotalReviews: 37,
patientId: 900 + id,
patientName: 'حاج‌آقا موسوی',
variantId: 11,
variantLabel: 'مراقبت سالمند — شیفت روز',
variantPriceUnit: 'per_hour',
variantPrice: '2800000',
customerAddressId: 800 + id,
addressTitle: 'منزل',
cityId: 101,
cityNameFa: 'تهران',
cityNameEn: 'Tehran',
districtId: 1003,
districtNameFa: 'سعادت‌آباد',
districtNameEn: 'Saadat Abad',
addressLine: 'خیابان نمونه، کوچه دوم، پلاک ۱۲',
postalCode: '1998887766',
recipientName: 'زهرا موسوی',
recipientPhone: '09121234567',
requiredCaregiverGender: 'female',
requestedDate: new Date(Date.now() + 2 * 86_400_000).toISOString().slice(0, 10),
requestedTimeStart: '09:00:00',
requestedTimeEnd: '13:00:00',
customerNotes: 'لطفاً در تعویض سِرُم دقت شود.',
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
...overrides,
};
}
store = [
seedRow({}),
seedRow({
nurseName: 'مریم رضایی',
patientName: 'خانم احمدی',
variantLabel: 'مراقبت پس از جراحی',
requiredCaregiverGender: 'female',
customerNotes: 'بیمار پس از عمل زانو، نیاز به کمک در جابه‌جایی دارد.',
requestedTimeStart: '15:00:00',
requestedTimeEnd: '19:00:00',
}),
];
/**
* Lazily transition any past-deadline row to its terminal state — the client stand-in for the server's
* background expiry sweep, so `expired_no_response` / `payment_deadline_expired` surface through the poll
* without a real cron. Runs on every read/write.
*/
function sweep(): void {
const now = Date.now();
store = store.map((row) => {
if (row.status === 'pending_nurse_response' && Date.parse(row.nurseResponseDeadlineAt) < now) {
return { ...row, status: 'expired_no_response' as BookingRequestStatus };
}
if (
row.status === 'accepted_awaiting_payment' &&
row.paymentDeadlineAt != null &&
Date.parse(row.paymentDeadlineAt) < now
) {
return { ...row, status: 'payment_deadline_expired' as BookingRequestStatus };
}
return row;
});
}
function find(id: number): BookingRequestDto {
const row = store.find((r) => r.id === id);
// Party-scoping is not modelled in the single-session mock; existence is not leaked either way (404).
if (!row) throw new ApiError(404, 'Booking request not found', 'not_found');
return row;
}
/** The nurse view masks the full address (two-stage disclosure) — coarse city/district only. */
function maskForNurse(dto: BookingRequestDto): BookingRequestDto {
return { ...dto, addressLine: null, postalCode: null, recipientName: null, recipientPhone: null };
}
function toListItem(dto: BookingRequestDto, role: RequestRole): BookingRequestListItem {
return {
id: dto.id,
status: dto.status,
counterpartyName: role === 'nurse' ? dto.patientName : dto.nurseName,
nurseRating: role === 'customer' ? dto.nurseRating : null,
requiredCaregiverGender: dto.requiredCaregiverGender,
requestedDate: dto.requestedDate,
requestedTimeStart: dto.requestedTimeStart,
requestedTimeEnd: dto.requestedTimeEnd,
nurseResponseDeadlineAt: dto.nurseResponseDeadlineAt,
paymentDeadlineAt: dto.paymentDeadlineAt,
customerNotes: role === 'nurse' ? dto.customerNotes : null,
};
}
// Actionable (non-terminal) rows first, then by soonest response deadline — mirrors the contract's
// "actionable rows sort first".
function actionableFirst(a: BookingRequestDto, b: BookingRequestDto): number {
const at = isTerminalBookingRequestStatus(a.status) ? 1 : 0;
const bt = isTerminalBookingRequestStatus(b.status) ? 1 : 0;
if (at !== bt) return at - bt;
return Date.parse(a.nurseResponseDeadlineAt) - Date.parse(b.nurseResponseDeadlineAt);
}
function assertCreateValid(payload: CreateBookingRequestPayload): void {
if (!payload.requiredCaregiverGender) {
throw new ApiError(400, 'requiredCaregiverGender is required', 'gender_required');
}
if (payload.requestedTimeEnd <= payload.requestedTimeStart) {
throw new ApiError(400, 'requestedTimeEnd must be after requestedTimeStart', 'invalid_time_range');
}
if ((payload.customerNotes?.length ?? 0) > CUSTOMER_NOTES_MAX_LENGTH) {
throw new ApiError(400, 'customerNotes too long', 'notes_too_long');
}
const start = Date.parse(`${payload.requestedDate}T${payload.requestedTimeStart}`);
if (Number.isFinite(start) && start < Date.now()) {
throw new ApiError(400, 'requestedDate/time is in the past', 'past_date');
}
}
function buildFromContext(
id: number,
payload: CreateBookingRequestPayload,
context: BookingRequestDisplayContext | undefined,
): BookingRequestDto {
return {
id,
status: 'pending_nurse_response',
nurseId: payload.nurseId,
nurseName: context?.nurseName ?? '',
nurseRating: context?.nurseRating ?? 0,
nurseTotalReviews: context?.nurseTotalReviews ?? 0,
patientId: payload.patientId,
patientName: context?.patientName ?? '',
variantId: payload.variantId,
variantLabel: context?.variantLabel ?? '',
variantPriceUnit: context?.variantPriceUnit ?? 'per_hour',
variantPrice: context?.variantPrice ?? null,
customerAddressId: payload.customerAddressId,
addressTitle: context?.addressTitle ?? '',
cityId: context?.cityId ?? 0,
cityNameFa: context?.cityNameFa ?? '',
cityNameEn: context?.cityNameEn ?? '',
districtId: context?.districtId ?? null,
districtNameFa: context?.districtNameFa ?? null,
districtNameEn: context?.districtNameEn ?? null,
addressLine: context?.addressLine ?? null,
postalCode: context?.postalCode ?? null,
recipientName: context?.recipientName ?? null,
recipientPhone: context?.recipientPhone ?? null,
requiredCaregiverGender: payload.requiredCaregiverGender,
requestedDate: payload.requestedDate,
requestedTimeStart: payload.requestedTimeStart,
requestedTimeEnd: payload.requestedTimeEnd,
customerNotes: payload.customerNotes?.trim() || null,
nurseResponseDeadlineAt: minutesFromNow(MOCK_RESPONSE_DEADLINE_MINUTES),
paymentDeadlineAt: null,
nurseRejectionReason: null,
createdAt: new Date().toISOString(),
};
}
/**
* In-memory mock behind the `BookingRequestsApi` seam. Drives the full request lifecycle over shared
* session state so C4 → C5 → the nurse inbox → accept/reject/expire all demo end-to-end without a live
* b8 backend (and without the upstream mock domains' ids needing to exist server-side). Mirrors the real
* shapes + status/deadline/masking semantics for a one-line swap once the stack is live
* (`USE_BOOKING_REQUESTS_MOCK = false`).
*/
export const bookingRequestsMockApi: BookingRequestsApi = {
create: async (payload, context) => {
await sleep(MOCK_LATENCY_MS);
assertCreateValid(payload);
const dto = buildFromContext(nextId++, payload, context);
store = [dto, ...store];
return dto;
},
get: async (id, role) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
return role === 'nurse' ? maskForNurse(dto) : dto;
},
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
await sleep(MOCK_LATENCY_MS);
sweep();
const matched = [...store]
.filter((row) => (params.status ? row.status === params.status : true))
.sort(actionableFirst)
.map((row) => toListItem(row, params.role));
const page = params.page ?? 1;
const pageSize = params.pageSize ?? BOOKING_REQUEST_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
accept: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = {
...dto,
status: 'accepted_awaiting_payment',
paymentDeadlineAt: minutesFromNow(MOCK_PAYMENT_DEADLINE_MINUTES),
};
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
reject: async (id, payload: RejectBookingRequestPayload) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
const reason = payload.reason?.trim() ?? '';
if (!reason) throw new ApiError(400, 'reason is required', 'reason_required');
if (reason.length > REJECTION_REASON_MAX_LENGTH) {
throw new ApiError(400, 'reason too long', 'reason_too_long');
}
if (dto.status !== 'pending_nurse_response') {
throw new ApiError(409, 'Request is not pending', 'not_pending');
}
const updated: BookingRequestDto = { ...dto, status: 'rejected_by_nurse', nurseRejectionReason: reason };
store = store.map((row) => (row.id === id ? updated : row));
return maskForNurse(updated);
},
cancel: async (id) => {
await sleep(MOCK_LATENCY_MS);
sweep();
const dto = find(id);
if (dto.status !== 'pending_nurse_response' && dto.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request can no longer be cancelled', 'not_cancellable');
}
const updated: BookingRequestDto = { ...dto, status: 'cancelled_by_customer' };
store = store.map((row) => (row.id === id ? updated : row));
return updated;
},
};
@@ -0,0 +1,43 @@
/**
* When true, the booking-requests domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `BookingRequestsApi` seam.
*
* **Mock is primary this phase.** The b8 endpoints are live server-side, but every *input* to a request
* — the nurse (search, f6), the patient (patients, f2), the address (addresses, f3) — is itself served by
* a **mock-primary** client domain today, so a real `booking_requests/create` would reference ids that
* exist only in those in-memory stores. Running the whole flow end-to-end (create → nurse inbox → accept
* → the customer's C5 flips → expiry) therefore needs a mock that shares the same session state. The mock
* drives exactly that. Additionally the contract DTO omits the variant price the summary card renders
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
*/
export const USE_BOOKING_REQUESTS_MOCK = true;
/**
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
* (accept / reject / expire) surfaces without a manual refresh; polling **stops** on a terminal/`converted`
* status (see the hooks' `refetchInterval` guard). 15s balances freshness against request volume.
*/
export const BOOKING_REQUEST_POLL_MS = 15 * 1000;
/** A single request is cheap and changes only on the other party's action — a short stale window. */
export const BOOKING_REQUEST_STALE_TIME = 10 * 1000;
export const BOOKING_REQUEST_GC_TIME = 5 * 60 * 1000;
/** api-conventions default/max page sizes (max 100 server-side); an inbox page. */
export const BOOKING_REQUEST_PAGE_SIZE = 20;
/** `customerNotes` limit (contract: ≤ 1000) — enforced client-side before the CTA. */
export const CUSTOMER_NOTES_MAX_LENGTH = 1000;
/** `nurseRejectionReason` limit (contract: ≤ 500) — enforced client-side in the reject dialog. */
export const REJECTION_REASON_MAX_LENGTH = 500;
/**
* Mock-only deadline windows. The **payment** window is contract-accurate (30 min); the **response**
* window is a demo-shortened stand-in for the server's real 24h so a session can observe the
* `expired_no_response` terminal path. Neither is used on the real path — the server freezes the true
* deadlines from `IPlatformConfig`. Documented in the phase report + mocks note.
*/
export const MOCK_RESPONSE_DEADLINE_MINUTES = 30;
export const MOCK_PAYMENT_DEADLINE_MINUTES = 30;
@@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Nurse accept — opens the 30-minute payment window server-side. On success it invalidates every inbox
* list (so the request leaves the pending inbox immediately) and the request detail (so the customer's
* polled C5 reflects the accept). A stale accept (past deadline / not pending) returns `409` via
* `mutation.error` — the caller surfaces it and refetches.
*/
export function useAcceptBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.accept(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import { BOOKING_REQUEST_GC_TIME, BOOKING_REQUEST_POLL_MS, BOOKING_REQUEST_STALE_TIME } from '../constants';
import { isTerminalBookingRequestStatus, type RequestRole } from '../types';
/**
* A single booking request (C5 customer view + the nurse request detail). **Polls** while the status is
* non-terminal so an accept / reject / expire transition surfaces without a manual refresh, and **stops**
* polling once a terminal/`converted` status is reached. `role` drives the mock's nurse-view masking (the
* real server infers it from auth). Enabled only when an id is present.
*/
export function useBookingRequest(id: number | undefined, role: RequestRole) {
return useQuery({
queryKey: bookingRequestKeys.detail(id ?? -1),
queryFn: () => bookingRequestsApi.get(id as number, role),
enabled: id != null && id > 0,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && isTerminalBookingRequestStatus(status) ? false : BOOKING_REQUEST_POLL_MS;
},
});
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
/**
* Customer cancel — withdraws a request that is still `pending_nurse_response` or
* `accepted_awaiting_payment` (before paying). Invalidates the lists + the request detail so both inboxes
* and the C5 reflect the cancellation. A cancel on a terminal request returns `409` via `mutation.error`.
*/
export function useCancelBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => bookingRequestsApi.cancel(id),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { BookingRequestDisplayContext, CreateBookingRequestPayload } from '../types';
interface CreateArgs {
payload: CreateBookingRequestPayload;
/** Mock-only display aid (ignored by the real client) so the created request renders fully on C5. */
context?: BookingRequestDisplayContext;
}
/**
* Creates a booking request (C4 → C5). On success it seeds the new request into the detail cache and
* invalidates the customer inbox so both reflect it immediately; the page owns the navigation to C5.
* Domain `400`s (same-gender mismatch, tenancy 404, inactive variant, invalid time/date) surface via
* `mutation.error` — the fetch layer owns 401/403/5xx toasts.
*/
export function useCreateBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ payload, context }: CreateArgs) => bookingRequestsApi.create(payload, context),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
},
});
}
@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The customer's own booking-requests inbox (`list?role=customer`), optionally filtered by status. C5 is
* keyed by a single request id, so this list is not required by a phase-7 screen — it exists so f8's
* "my bookings" surface (and any future customer request list) reads the same cached domain, and so a
* create/cancel invalidation has a list to refresh.
*/
export function useCustomerRequests(status?: BookingRequestStatus, page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'customer' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.list(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
});
}
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { useIsAuthenticated } from '@/hooks';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import {
BOOKING_REQUEST_GC_TIME,
BOOKING_REQUEST_PAGE_SIZE,
BOOKING_REQUEST_POLL_MS,
BOOKING_REQUEST_STALE_TIME,
} from '../constants';
import type { BookingRequestStatus } from '../types';
/**
* The nurse incoming-requests inbox (`list?role=nurse`), defaulting to `pending_nurse_response`. Lightly
* polls so newly-arrived requests appear without a manual refresh; every accept/reject invalidates this
* list so an actioned request leaves the pending inbox immediately.
*/
export function useNurseRequestInbox(status: BookingRequestStatus | undefined = 'pending_nurse_response', page = 1) {
const isAuthenticated = useIsAuthenticated();
const params = { role: 'nurse' as const, status, page, pageSize: BOOKING_REQUEST_PAGE_SIZE };
return useQuery({
queryKey: bookingRequestKeys.nurseInbox(params),
queryFn: () => bookingRequestsApi.list(params),
enabled: isAuthenticated,
staleTime: BOOKING_REQUEST_STALE_TIME,
gcTime: BOOKING_REQUEST_GC_TIME,
refetchInterval: BOOKING_REQUEST_POLL_MS,
});
}
@@ -0,0 +1,26 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingRequestsApi } from '../apis';
import { bookingRequestKeys } from '../keys';
import type { RejectBookingRequestPayload } from '../types';
interface RejectArgs {
id: number;
payload: RejectBookingRequestPayload;
}
/**
* Nurse reject (with a required reason). On success it invalidates every inbox list (the request leaves
* the pending inbox) and the request detail (the customer's polled C5 shows the rejected terminal card
* with the reason). A stale reject returns `409` via `mutation.error`.
*/
export function useRejectBookingRequest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: RejectArgs) => bookingRequestsApi.reject(id, payload),
onSuccess: (dto) => {
queryClient.setQueryData(bookingRequestKeys.detail(dto.id), dto);
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(dto.id) });
},
});
}
@@ -0,0 +1,11 @@
/**
* Booking-requests domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCreateBookingRequest } from './hooks/useCreateBookingRequest';
export { useBookingRequest } from './hooks/useBookingRequest';
export { useNurseRequestInbox } from './hooks/useNurseRequestInbox';
export { useCustomerRequests } from './hooks/useCustomerRequests';
export { useAcceptBookingRequest } from './hooks/useAcceptBookingRequest';
export { useRejectBookingRequest } from './hooks/useRejectBookingRequest';
export { useCancelBookingRequest } from './hooks/useCancelBookingRequest';
@@ -0,0 +1,23 @@
import type { BookingRequestListParams } from './types';
/**
* React Query key factory for the booking-requests domain (hierarchical, per the `services/{domain}`
* pattern). The list key carries the role + status filter so the customer inbox and the nurse inbox are
* distinct cache entries and a nurse accept/reject invalidates exactly the right list.
*/
export const bookingRequestKeys = {
all: ['bookingRequests'] as const,
lists: () => [...bookingRequestKeys.all, 'list'] as const,
list: (params: BookingRequestListParams) =>
[
...bookingRequestKeys.lists(),
params.role,
params.status ?? 'all',
params.page ?? 1,
params.pageSize ?? 0,
] as const,
/** The nurse incoming-requests inbox (a `list` with `role='nurse'`), factored for readability. */
nurseInbox: (params: BookingRequestListParams) => bookingRequestKeys.list(params),
details: () => [...bookingRequestKeys.all, 'detail'] as const,
detail: (id: number) => [...bookingRequestKeys.details(), id] as const,
};
@@ -0,0 +1,198 @@
import type { PageParams, Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types';
/**
* Booking-requests domain — the **pre-payment intent** layer of the engagement lifecycle (b8). A
* customer requests a nurse for a patient/variant/address/date; the nurse accepts (opening a
* config-driven 30-minute payment window) or rejects; both sides read a role-scoped inbox and a single
* request. **There is no money and no `bookings` row here** — that conversion is b9/b10. Shapes mirror
* the b8 contract (`dev/contracts/domains/booking-requests.md`); the wire is **camelCase** and
* `clientFetch` unwraps the `ApiEnvelope<T>`, so these are the post-`unwrap()` payloads.
*
* Load-bearing semantics (contract "Key semantics" + phase §5):
* - **Deadlines are server-frozen absolute UTC instants.** The client renders countdowns from
* `nurseResponseDeadlineAt` / `paymentDeadlineAt` against `Date.now()` — it never computes a deadline.
* `paymentDeadlineAt` is `null` until the nurse accepts.
* - **`requiredCaregiverGender` is first-class and required on create** (`male`/`female`/`any`) — never
* silently defaulted. `male`/`female` must match the nurse's gender (a mismatch is a `400`).
* - **Two-stage clinical disclosure.** The nurse sees **only** `customerNotes` (stage-1 plaintext) and a
* **masked** address (`addressLine`/`postalCode`/recipient are `null` in the nurse view). Full
* clinical/care instructions do not exist until b9 and must never appear in this UI.
* - **Forward-only status machine.** Terminal states have no outgoing edges; a stale accept/reject/cancel
* returns `409`.
*/
/** The same-gender matching facet carried from search into the request (`any` = فرقی ندارد). */
export type RequiredCaregiverGender = 'male' | 'female' | 'any';
/** The full `booking_request_status` enum (contract). */
export type BookingRequestStatus =
| 'pending_nurse_response'
| 'accepted_awaiting_payment'
| 'converted'
| 'rejected_by_nurse'
| 'expired_no_response'
| 'payment_deadline_expired'
| 'cancelled_by_customer';
/** Which inbox to read — disambiguates a user who holds both roles (contract `list?role=`). */
export type RequestRole = 'customer' | 'nurse';
/** Terminal states: no outgoing edges, so polling stops and no accept/reject/cancel is offered. */
export const TERMINAL_BOOKING_REQUEST_STATUSES: readonly BookingRequestStatus[] = [
'converted',
'rejected_by_nurse',
'expired_no_response',
'payment_deadline_expired',
'cancelled_by_customer',
] as const;
export function isTerminalBookingRequestStatus(status: BookingRequestStatus): boolean {
return TERMINAL_BOOKING_REQUEST_STATUSES.includes(status);
}
/**
* `BookingRequestDto` — the full single-request view. The customer/admin view carries the full address;
* the **nurse view masks it** (`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are `null`),
* leaving only the coarse city/district.
*
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
* real client leaves it `null` (the summary then hides the amount) until the field lands.
*/
export interface BookingRequestDto {
id: number;
status: BookingRequestStatus;
nurseId: number;
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientId: number;
patientName: string;
variantId: number;
variantLabel: string;
variantPriceUnit: PriceUnit;
/** Client-augmented (REQ-013): IRR digit-string, or `null` on the real path until the DTO carries it. */
variantPrice: string | null;
customerAddressId: number;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
/** Full-address PII — present in the customer/admin view, **`null` in the nurse view** (masked). */
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Stage-1 plaintext — the ONLY clinical text the nurse sees before accepting. */
customerNotes: string | null;
/** Server-frozen absolute UTC instant. */
nurseResponseDeadlineAt: string;
/** Server-frozen UTC; `null` until the nurse accepts. */
paymentDeadlineAt: string | null;
nurseRejectionReason: string | null;
createdAt: string;
}
/**
* `BookingRequestListItemDto` — an inbox row. The **customer** inbox sets `counterpartyName` = nurse name
* (+ `nurseRating`); the **nurse** inbox sets `counterpartyName` = patient name (+ `customerNotes`,
* stage-1 only). Actionable rows sort first server-side.
*/
export interface BookingRequestListItem {
id: number;
status: BookingRequestStatus;
counterpartyName: string;
/** Customer view only; `null` in the nurse inbox. */
nurseRating: number | null;
requiredCaregiverGender: RequiredCaregiverGender | null;
requestedDate: string;
requestedTimeStart: string;
requestedTimeEnd: string;
nurseResponseDeadlineAt: string;
paymentDeadlineAt: string | null;
/** Nurse view only (stage-1 plaintext); `null` in the customer inbox. */
customerNotes: string | null;
}
/** The `booking_requests/create` body (contract). Money-free; ids come from search/patients/addresses. */
export interface CreateBookingRequestPayload {
nurseId: number;
variantId: number;
patientId: number;
customerAddressId: number;
/** `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Required, never silently defaulted. */
requiredCaregiverGender: RequiredCaregiverGender;
/** ≤ 1000 chars; the only text the nurse sees pre-accept. */
customerNotes?: string | null;
}
/** The `booking_requests/reject` body. */
export interface RejectBookingRequestPayload {
/** Required, ≤ 500 chars. */
reason: string;
}
/** `booking_requests/list` query params (role-scoped, paginated, optional status filter). */
export interface BookingRequestListParams extends PageParams {
role: RequestRole;
status?: BookingRequestStatus;
}
/**
* Display fields the mock needs to build a faithful DTO, resolved by the C4 form from the already-loaded
* nurse profile / patient / address / variant queries. **The real `clientApi` ignores this** — the
* server returns the joined DTO from the ids alone; only the mock (which cannot read the other domains'
* in-memory stores) uses it. This is the b8 analogue of the search/patients/addresses "client-augmented"
* display fields, kept off the wire `CreateBookingRequestPayload`.
*/
export interface BookingRequestDisplayContext {
nurseName: string;
nurseRating: number;
nurseTotalReviews: number;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
variantPrice: string | null;
addressTitle: string;
cityId: number;
cityNameFa: string;
cityNameEn: string;
districtId: number | null;
districtNameFa: string | null;
districtNameEn: string | null;
addressLine: string | null;
postalCode: string | null;
recipientName: string | null;
recipientPhone: string | null;
}
/**
* The booking-requests API seam — the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_BOOKING_REQUESTS_MOCK`), never scattered `if (mock)` checks.
*
* `get`/`create` take an optional `role`/`context` that only the mock uses (to mask the nurse view and
* to build a faithful DTO respectively); the real client infers the view from auth and ignores them.
*/
export interface BookingRequestsApi {
create(payload: CreateBookingRequestPayload, context?: BookingRequestDisplayContext): Promise<BookingRequestDto>;
get(id: number, role?: RequestRole): Promise<BookingRequestDto>;
list(params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>>;
accept(id: number): Promise<BookingRequestDto>;
reject(id: number, payload: RejectBookingRequestPayload): Promise<BookingRequestDto>;
cancel(id: number): Promise<BookingRequestDto>;
}
+121
View File
@@ -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 15; 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` 15
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 15 / 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> }`. `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<ModerationQueueItemDto>` — `{ 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>` — `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).
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,24 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing> - **Notes for frontend:** <anything load-bearing>
--> -->
## backend-phase-14 — Reviews, ratings & patient care records — 2026-07-09
- **Shipped:** new `reviews` schema, 4 tables — `Reviews` (`UNIQUE(booking_id)`, `CHECK(rating 15)`, 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 ## backend-phase-13 — Weekly nurse payouts (mocked PAYA/SATNA) — 2026-07-09
- **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates), - **Shipped:** new `payouts` schema, 3 tables — `NursePayoutBatches` (holiday-shifted period/processing dates),
`NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks` `NursePayouts` (net-split CHECK, encrypted `iban_snapshot`, forward-only `PayoutStatus`), `NursePayoutBookingLinks`
@@ -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 15, 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.
@@ -12,6 +12,36 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **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 ## 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 - **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 `filterParams.ts` C1↔C2 URL serializer) and screens **C1** `/search` (reused category grid + f3 region
@@ -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 - **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. `GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
- **Status:** open - **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
@@ -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 17 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.
@@ -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.
@@ -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` | 🟡 | | `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 | 🟡 | | `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 | 🔴 | | `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 | 🟡 | | `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 | 🟡 | | `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 | 🔴 | | `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
+36 -5
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
``` ```
src/ src/
├── Core/ ├── 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.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); + 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.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/ ├── 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.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 │ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/ ├── 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) │ ├── 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) │ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base ├── 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 (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. 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 15)`, `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 **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 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 dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -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;
/// <summary>Admin review console: the moderation queue (defaults to <c>pending_moderation</c>), with any linked
/// low-rating alert id. Support alerts themselves stay internal — only their id is surfaced here for triage.</summary>
[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<PagedResult<ModerationQueueItemDto>>]
public async Task<IActionResult> ModerationQueue([FromQuery] GetReviewModerationQueueQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<SubmitReviewResult>]
public async Task<IActionResult> Review(long bookingId, SubmitReviewBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken));
/// <summary>The review body (the booking id comes from the route).</summary>
public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes);
}
@@ -1,6 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; 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.Features.Verification.Queries.GetTrustBadge;
using Baya.Application.Models.Reviews;
using Baya.Application.Models.Verification; using Baya.Application.Models.Verification;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
@@ -14,7 +17,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController] [ApiController]
[Route("api/v{version:apiVersion}/[controller]")] [Route("api/v{version:apiVersion}/[controller]")]
[AllowAnonymous] [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 sealed class NursesController(ISender sender) : BaseController
{ {
// Public: the verified badge exposes credential *types* held, never the encrypted numbers. // Public: the verified badge exposes credential *types* held, never the encrypted numbers.
@@ -22,4 +25,16 @@ public sealed class NursesController(ISender sender) : BaseController
[ProducesOkApiResponseType<TrustBadgeDto>] [ProducesOkApiResponseType<TrustBadgeDto>]
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken) public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), 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<NurseReviewsResult>]
public async Task<IActionResult> 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<NurseTagAggregatesResult>]
public async Task<IActionResult> ReviewTags(long nurseProfileId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetTagAggregatesQuery(nurseProfileId), cancellationToken));
} }
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<WriteCareRecordResult>]
public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body), cancellationToken));
[HttpGet("{patientId}/care_records")]
[ProducesOkApiResponseType<PagedResult<CareRecordDto>>]
public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
=> OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken));
/// <summary>The care-record body (the patient id comes from the route).</summary>
public record WriteCareRecordBody(long? BookingId, string Body);
}
@@ -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;
/// <summary>
/// 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 <see cref="ConstantPolicies.DynamicPermission"/>
/// policy overrides the controller-level authorize).
/// </summary>
[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<ReviewTagsResult>]
public async Task<IActionResult> Tags(long reviewId, AttachReviewTagsBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new AttachReviewTagsCommand(reviewId, body.TagCodes), cancellationToken));
[HttpPatch("{reviewId}/status")]
[Authorize(ConstantPolicies.DynamicPermission)]
[ProducesOkApiResponseType<ModerateReviewResult>]
public async Task<IActionResult> Status(long reviewId, ModerateReviewBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ModerateReviewCommand(reviewId, body.Action, body.Reason), cancellationToken));
/// <summary>Tag-attach body (the review id comes from the route).</summary>
public record AttachReviewTagsBody(IReadOnlyList<string> TagCodes);
/// <summary>Moderation body (the review id comes from the route): <c>publish</c>|<c>hide</c>|<c>reject</c>|<c>unpublish</c>.</summary>
public record ModerateReviewBody(string Action, string? Reason);
}
@@ -18,4 +18,8 @@ public interface ICustomerProfileRepository
/// <summary>The customer's <c>customer_profiles.id</c> from their user id — the tenancy anchor for /// <summary>The customer's <c>customer_profiles.id</c> from their user id — the tenancy anchor for
/// patient operations. NULL when the user has no customer profile yet.</summary> /// patient operations. NULL when the user has no customer profile yet.</summary>
Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken); Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken);
/// <summary>The owning <c>users.id</c> for a customer profile — the notification recipient for a review
/// outcome. NULL when the profile does not exist.</summary>
Task<int?> GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken);
} }
@@ -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;
/// <summary>
/// The <c>patient_care_records</c> 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.
/// </summary>
public interface IPatientCareRecordRepository
{
Task AddAsync(PatientCareRecord record, CancellationToken cancellationToken);
/// <summary>The patient's owning <c>customer_profiles.id</c>, or null if the patient does not exist — the
/// tenancy anchor for the owning-customer access branch.</summary>
Task<long?> GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken);
/// <summary>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.</summary>
Task<bool> NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken);
/// <summary>Patient-scoped longitudinal history, paginated, newest first — <b>ciphertext</b> bodies; the
/// handler decrypts post-check.</summary>
Task<PagedResult<CareRecordCipherRow>> GetPatientHistoryAsync(long patientId, int page, int pageSize, CancellationToken cancellationToken);
}
@@ -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;
/// <summary>
/// The <c>reviews</c> aggregate (reviews + tag links). Writes load tracked rows; reads project to DTOs and
/// return <b>published</b> reviews only on any public path. The nurse rating aggregate is always derived from
/// source (<c>AVG</c>/<c>COUNT</c> over currently-published reviews) — never an incremental delta.
/// </summary>
public interface IReviewRepository
{
Task AddAsync(Review review, CancellationToken cancellationToken);
/// <summary>The booking's ownership + status + nurse, for the submit-eligibility guards. Null if absent.</summary>
Task<ReviewableBooking?> GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>True if a (non-deleted) review already exists for the booking — the 1:1 pre-check.</summary>
Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken);
/// <summary>Tracked review for a moderation transition. Null if absent.</summary>
Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken);
/// <summary>Tracked review with its tag links loaded — for the add/replace-tags command. Null if absent.</summary>
Task<Review?> GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken);
/// <summary>Validates tag codes against the active master vocabulary; returns the matched <c>code → id</c>
/// (an unknown/inactive code is simply absent from the map, so the handler can reject it cleanly).</summary>
Task<IReadOnlyDictionary<string, long>> GetTagIdsByCodesAsync(IReadOnlyList<string> codes, CancellationToken cancellationToken);
/// <summary>
/// The recompute-from-source stats for a nurse — <c>COUNT</c> and <c>SUM(rating)</c> over the nurse's
/// currently <b>published</b> reviews <b>excluding</b> <paramref name="excludeReviewId"/>. The caller then
/// folds in the transitioning review's <i>new</i> status in memory, so the aggregate is derived from source
/// (not an incremental delta) and is correct <b>before</b> the single commit — a fresh query can't yet see
/// the tracked, uncommitted status change. Pass <c>0</c> to exclude nothing (a brand-new review).
/// </summary>
Task<(int Count, long Sum)> GetPublishedRatingStatsExcludingAsync(long nurseProfileId, long excludeReviewId, CancellationToken cancellationToken);
/// <summary>The nurse's denormalized, published-only rating aggregate (kept correct by the recompute) —
/// what the public reviews read returns and caches.</summary>
Task<NurseReviewAggregateDto> GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken);
/// <summary>Public, paginated list of a nurse's <b>published</b> reviews (with tag codes), newest first.</summary>
Task<PagedResult<ReviewListItemDto>> ListPublishedForNurseAsync(long nurseProfileId, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Admin moderation queue, filtered by status (default caller-supplied), newest first, with any
/// linked low-rating alert id joined in.</summary>
Task<PagedResult<ModerationQueueItemDto>> GetModerationQueueAsync(string? status, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>Per-nurse tag rollup over <b>published</b> reviews — each active tag's count and share.</summary>
Task<NurseTagAggregatesResult> GetTagAggregatesAsync(long nurseProfileId, CancellationToken cancellationToken);
}
@@ -23,6 +23,8 @@ public interface IUnitOfWork
public IInvoiceRepository InvoiceRepository { get; } public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; } public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; } public IPayoutRepository PayoutRepository { get; }
public IReviewRepository ReviewRepository { get; }
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
Task CommitAsync(); Task CommitAsync();
ValueTask RollBackAsync(); ValueTask RollBackAsync();
} }
@@ -0,0 +1,31 @@
#nullable enable
namespace Baya.Application.Contracts.Reviews;
/// <summary>The AI/automated pre-screen decision for a submitted review.</summary>
public enum ModerationDecision
{
/// <summary>Clean text — safe to auto-publish (subject to the auto-approve config).</summary>
Approve,
/// <summary>Suspicious — keep <c>pending_moderation</c> for a human to decide.</summary>
Flag,
/// <summary>Clearly disallowed — a human can still override, but the pre-screen recommends rejecting.</summary>
Reject
}
/// <summary>The verdict a pre-screen returns: the decision plus a short machine reason.</summary>
/// <param name="Decision">The recommended disposition.</param>
/// <param name="Reason">A short reason code/text (e.g. <c>clean</c>, <c>banned_word:scam</c>).</param>
public sealed record ModerationVerdict(ModerationDecision Decision, string Reason);
/// <summary>
/// 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 — <c>ModerateReviewCommand</c> keeps decision authority and always allows a human override, so
/// the real implementation never needs to touch the handler.
/// </summary>
public interface IReviewModerationService
{
ValueTask<ModerationVerdict> ScreenAsync(string? reviewText, CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// A nurse writes a patient-scoped clinical note. Guards: the caller is a nurse, the patient exists, and the
/// nurse has a <b>confirmed</b> (or later) booking for that patient — a nurse never assigned is denied. The
/// clinical body is encrypted through <see cref="IFieldEncryptor"/> before it is persisted; plaintext never
/// touches the column.
/// </summary>
internal sealed class WritePatientCareRecordCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IFieldEncryptor fieldEncryptor,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<WritePatientCareRecordCommand, OperationResult<WriteCareRecordResult>>
{
public async ValueTask<OperationResult<WriteCareRecordResult>> Handle(
WritePatientCareRecordCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<WriteCareRecordResult>.UnauthorizedResult("Not authenticated.");
var nurseProfileId = await unitOfWork.NurseProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (nurseProfileId is not { } nurseId)
return OperationResult<WriteCareRecordResult>.ForbiddenResult("Only a nurse can write a care record.");
var owner = await unitOfWork.PatientCareRecordRepository.GetPatientOwnerCustomerIdAsync(request.PatientId, cancellationToken);
if (owner is null)
return OperationResult<WriteCareRecordResult>.NotFoundResult("Patient not found.");
var qualifies = await unitOfWork.PatientCareRecordRepository
.NurseHasQualifyingBookingForPatientAsync(nurseId, request.PatientId, cancellationToken);
if (!qualifies)
return OperationResult<WriteCareRecordResult>.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<WriteCareRecordResult>.SuccessResult(
new WriteCareRecordResult(record.Id, record.PatientId, record.RecordedAt));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
public sealed class WritePatientCareRecordCommandValidator : AbstractValidator<WritePatientCareRecordCommand>
{
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);
}
}
@@ -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;
/// <summary>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.</summary>
public record WritePatientCareRecordCommand(long PatientId, long? BookingId, string Body)
: IRequest<OperationResult<WriteCareRecordResult>>;
@@ -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;
/// <summary>
/// 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 <see cref="IFieldEncryptor"/>.
/// </summary>
internal sealed class GetPatientHistoryQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IFieldEncryptor fieldEncryptor)
: IRequestHandler<GetPatientHistoryQuery, OperationResult<PagedResult<CareRecordDto>>>
{
private static readonly string[] AdminRoles = [RoleNames.Admin, RoleNames.SuperAdmin];
public async ValueTask<OperationResult<PagedResult<CareRecordDto>>> Handle(
GetPatientHistoryQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<PagedResult<CareRecordDto>>.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<PagedResult<CareRecordDto>>.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<PagedResult<CareRecordDto>>.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<PagedResult<CareRecordDto>>.SuccessResult(
new PagedResult<CareRecordDto>(items, cipher.Total, cipher.Page, cipher.PageSize));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Reviews;
using Mediator;
namespace Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
/// <summary>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.</summary>
public record GetPatientHistoryQuery(long PatientId, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<CareRecordDto>>>;
@@ -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;
/// <summary>
/// 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 <c>UNIQUE(review_id, review_tag_master_id)</c> is
/// never violated.
/// </summary>
internal sealed class AttachReviewTagsCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork)
: IRequestHandler<AttachReviewTagsCommand, OperationResult<ReviewTagsResult>>
{
private static readonly string[] ModeratorRoles = [RoleNames.Admin, RoleNames.SuperAdmin, RoleNames.Moderation];
public async ValueTask<OperationResult<ReviewTagsResult>> Handle(AttachReviewTagsCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<ReviewTagsResult>.UnauthorizedResult("Not authenticated.");
var review = await unitOfWork.ReviewRepository.GetTrackedWithTagsAsync(request.ReviewId, cancellationToken);
if (review is null)
return OperationResult<ReviewTagsResult>.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<ReviewTagsResult>.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<ReviewTagsResult>.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<ReviewTagsResult>.SuccessResult(new ReviewTagsResult(review.Id, distinct));
}
}
@@ -0,0 +1,13 @@
using FluentValidation;
namespace Baya.Application.Features.Reviews.Commands.AttachReviewTags;
public sealed class AttachReviewTagsCommandValidator : AbstractValidator<AttachReviewTagsCommand>
{
public AttachReviewTagsCommandValidator()
{
RuleFor(x => x.ReviewId).GreaterThan(0);
RuleFor(x => x.TagCodes).NotNull();
RuleForEach(x => x.TagCodes).NotEmpty().MaximumLength(50);
}
}
@@ -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;
/// <summary>Sets (replaces) the standardized tags on a review the caller owns (or a moderator manages). The
/// resulting set is exactly <see cref="TagCodes"/>; the <c>UNIQUE(review_id, review_tag_master_id)</c> forbids
/// a duplicate tag.</summary>
public record AttachReviewTagsCommand(long ReviewId, IReadOnlyList<string> TagCodes)
: IRequest<OperationResult<ReviewTagsResult>>;
@@ -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;
/// <summary>
/// Applies a moderation transition and, in the <b>same transaction</b>: recomputes the nurse aggregate from
/// source (§ <see cref="RecomputeNurseRating"/>) and stages the b7 search-index refresh. The transition is
/// audited automatically because <see cref="Review"/> is <c>IAuditable</c> (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.
/// </summary>
internal sealed class ModerateReviewCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
ISearchIndexMaintainer searchIndex,
ICacheService cache,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<ModerateReviewCommand, OperationResult<ModerateReviewResult>>
{
public async ValueTask<OperationResult<ModerateReviewResult>> Handle(ModerateReviewCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } moderatorId)
return OperationResult<ModerateReviewResult>.UnauthorizedResult("Not authenticated.");
var targetStatus = ReviewModerationAction.ToStatus(request.Action);
if (targetStatus is null)
return OperationResult<ModerateReviewResult>.FailureResult($"Unknown moderation action '{request.Action}'.");
var review = await unitOfWork.ReviewRepository.GetTrackedAsync(request.ReviewId, cancellationToken);
if (review is null)
return OperationResult<ModerateReviewResult>.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<ModerateReviewResult>.SuccessResult(
new ModerateReviewResult(review.Id, review.ModerationStatus, aggregate.AverageRating, aggregate.PublishedCount));
}
}
@@ -0,0 +1,22 @@
using Baya.Domain.Entities.Reviews;
using FluentValidation;
namespace Baya.Application.Features.Reviews.Commands.ModerateReview;
public sealed class ModerateReviewCommandValidator : AbstractValidator<ModerateReviewCommand>
{
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));
}
}
@@ -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;
/// <summary>An admin/moderator transitions a review: <c>publish</c> | <c>hide</c> | <c>reject</c> |
/// <c>unpublish</c>. Every transition recomputes the nurse aggregate from source and refreshes the search
/// index in the same transaction. Hide/reject require a reason.</summary>
public record ModerateReviewCommand(long ReviewId, string Action, string? Reason)
: IRequest<OperationResult<ModerateReviewResult>>;
@@ -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;
/// <summary>
/// Creates the one allowed review for a completed booking. Guards (all clean <see cref="OperationResult"/>
/// 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 <c>pending_moderation</c> 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).
/// </summary>
internal sealed class SubmitReviewCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IReviewModerationService moderation,
ISupportAlertService supportAlerts,
ISearchIndexMaintainer searchIndex,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<SubmitReviewCommand, OperationResult<SubmitReviewResult>>
{
public async ValueTask<OperationResult<SubmitReviewResult>> Handle(SubmitReviewCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<SubmitReviewResult>.UnauthorizedResult("Not authenticated.");
var customerProfileId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerProfileId is not { } customerId)
return OperationResult<SubmitReviewResult>.ForbiddenResult("Only a customer can review a booking.");
var booking = await unitOfWork.ReviewRepository.GetReviewableBookingAsync(request.BookingId, cancellationToken);
if (booking is null)
return OperationResult<SubmitReviewResult>.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<SubmitReviewResult>.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<SubmitReviewResult>.FailureResult("A review can only be left for a completed booking.");
if (await unitOfWork.ReviewRepository.ExistsForBookingAsync(request.BookingId, cancellationToken))
return OperationResult<SubmitReviewResult>.ConflictResult("This booking has already been reviewed.");
var tagMasterIds = new List<long>();
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<SubmitReviewResult>.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<decimal>("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<SubmitReviewResult>.SuccessResult(
new SubmitReviewResult(review.Id, review.ModerationStatus, lowRatingAlertRaised));
}
}
@@ -0,0 +1,14 @@
using FluentValidation;
namespace Baya.Application.Features.Reviews.Commands.SubmitReview;
public sealed class SubmitReviewCommandValidator : AbstractValidator<SubmitReviewCommand>
{
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);
}
}
@@ -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;
/// <summary>A customer leaves the one allowed review for a completed booking: a 15 rating, optional free-text
/// body, and optional standardized tag codes. The booking id comes from the route, not the body.</summary>
public record SubmitReviewCommand(
long BookingId,
int Rating,
string? Body,
IReadOnlyList<string>? TagCodes) : IRequest<OperationResult<SubmitReviewResult>>;
@@ -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<GetReviewModerationQueueQuery, OperationResult<PagedResult<ModerationQueueItemDto>>>
{
public async ValueTask<OperationResult<PagedResult<ModerationQueueItemDto>>> 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<PagedResult<ModerationQueueItemDto>>.FailureResult(
$"Unknown moderation status '{status}'.");
var result = await unitOfWork.ReviewRepository.GetModerationQueueAsync(status, page, pageSize, cancellationToken);
return OperationResult<PagedResult<ModerationQueueItemDto>>.SuccessResult(result);
}
}
@@ -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;
/// <summary>Admin moderation queue, paginated and filterable by <c>moderation_status</c> (defaults to
/// <c>pending_moderation</c>). Includes any linked low-rating alert id for staff triage.</summary>
public record GetReviewModerationQueueQuery(string? Status = null, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<PagedResult<ModerationQueueItemDto>>>;
@@ -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<GetTagAggregatesQuery, OperationResult<NurseTagAggregatesResult>>
{
public async ValueTask<OperationResult<NurseTagAggregatesResult>> Handle(
GetTagAggregatesQuery request, CancellationToken cancellationToken)
{
var result = await unitOfWork.ReviewRepository.GetTagAggregatesAsync(request.NurseProfileId, cancellationToken);
return OperationResult<NurseTagAggregatesResult>.SuccessResult(result);
}
}
@@ -0,0 +1,8 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Reviews;
using Mediator;
namespace Baya.Application.Features.Reviews.Queries.GetTagAggregates;
/// <summary>Public per-nurse tag rollup ("% punctual", …) computed over the nurse's <b>published</b> reviews.</summary>
public record GetTagAggregatesQuery(long NurseProfileId) : IRequest<OperationResult<NurseTagAggregatesResult>>;
@@ -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;
/// <summary>
/// Public reviews read. Returns <b>published</b> 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.
/// </summary>
internal sealed class ListReviewsForNurseQueryHandler(
IUnitOfWork unitOfWork,
ICacheService cache)
: IRequestHandler<ListReviewsForNurseQuery, OperationResult<NurseReviewsResult>>
{
public async ValueTask<OperationResult<NurseReviewsResult>> 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<NurseReviewsResult>.SuccessResult(new NurseReviewsResult(aggregate, reviews));
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Reviews;
using Mediator;
namespace Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
/// <summary>Public, paginated list of a nurse's <b>published</b> reviews plus the cached rating aggregate.</summary>
public record ListReviewsForNurseQuery(long NurseProfileId, int Page = 1, int PageSize = 20)
: IRequest<OperationResult<NurseReviewsResult>>;
@@ -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;
/// <summary>
/// Recomputes a nurse's denormalized rating aggregate <b>from source</b> — never by an incremental
/// <c>+delta</c>/<c>-delta</c>. It reads <c>COUNT</c>/<c>SUM(rating)</c> over the nurse's currently
/// <b>published</b> reviews (excluding the transitioning review), then folds in that review's <i>new</i>
/// 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.
/// <para>
/// It mutates the tracked <see cref="Baya.Domain.Entities.Identity.NurseProfile"/> and <b>stages</b> the b7
/// search-index refresh on the same unit of work; the caller's single <c>CommitAsync</c> persists the review
/// transition, the aggregate, and the projection atomically. It does not commit. Invoked by <b>every</b>
/// moderation transition and by an auto-published/auto-hidden submit.
/// </para>
/// </summary>
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);
}
}
@@ -0,0 +1,19 @@
#nullable enable
using Baya.Application.Contracts.Common;
namespace Baya.Application.Features.Reviews;
/// <summary>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.</summary>
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);
}
@@ -0,0 +1,28 @@
#nullable enable
namespace Baya.Application.Models.Reviews;
/// <summary>A patient-care-record row as it leaves the repository — the clinical body is still <b>ciphertext</b>
/// here; the handler decrypts it only after the strict clinical access check passes, then maps to
/// <see cref="CareRecordDto"/>. Keeping the cipher in the projection means no query path can surface plaintext.</summary>
public record CareRecordCipherRow(
long Id,
long PatientId,
long? BookingId,
long NurseProfileId,
string? NurseName,
string BodyEncrypted,
DateTime RecordedAt);
/// <summary>A decrypted clinical note returned to an authorized reader (owning customer / nurse with a
/// confirmed booking / admin). Newest first.</summary>
public record CareRecordDto(
long Id,
long PatientId,
long? BookingId,
long NurseProfileId,
string? NurseName,
string Body,
DateTime RecordedAt);
/// <summary>What <c>WritePatientCareRecordCommand</c> returns.</summary>
public record WriteCareRecordResult(long Id, long PatientId, DateTime RecordedAt);
@@ -0,0 +1,54 @@
#nullable enable
using Baya.Application.Models.Common;
namespace Baya.Application.Models.Reviews;
/// <summary>The minimal booking facts a review submission needs — resolved from the <c>bookings</c> row to
/// enforce ownership, the completed/closed eligibility gate, and the nurse the aggregate belongs to.</summary>
public record ReviewableBooking(long BookingId, long CustomerProfileId, long NurseProfileId, string Status);
/// <summary>What <c>SubmitReviewCommand</c> returns — the new review id and its (always
/// <c>pending_moderation</c>) status, plus whether a low-rating support alert was raised.</summary>
public record SubmitReviewResult(long Id, string ModerationStatus, bool LowRatingAlertRaised);
/// <summary>What <c>ModerateReviewCommand</c> 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.</summary>
public record ModerateReviewResult(long Id, string ModerationStatus, decimal AverageRating, int TotalReviews);
/// <summary>The result of attaching/replacing a review's tags — the review id and its resulting tag codes.</summary>
public record ReviewTagsResult(long ReviewId, IReadOnlyList<string> TagCodes);
/// <summary>A public, published review line item — never carries moderation internals.</summary>
public record ReviewListItemDto(
long Id,
int Rating,
string? Body,
IReadOnlyList<string> TagCodes,
DateTimeOffset CreatedAt);
/// <summary>The public nurse rating aggregate — derived from <b>published</b> reviews only.</summary>
public record NurseReviewAggregateDto(decimal AverageRating, int PublishedCount);
/// <summary>The public reviews payload for a nurse — the aggregate plus a page of published reviews.</summary>
public record NurseReviewsResult(NurseReviewAggregateDto Aggregate, PagedResult<ReviewListItemDto> Reviews);
/// <summary>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).</summary>
public record ModerationQueueItemDto(
long Id,
long BookingId,
long NurseProfileId,
long CustomerProfileId,
int Rating,
string? Body,
string ModerationStatus,
string? ModerationReason,
long? LowRatingAlertId,
DateTimeOffset CreatedAt);
/// <summary>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.</summary>
public record TagAggregateDto(string Code, string LabelFa, string LabelEn, int Count, decimal Percentage);
/// <summary>The per-nurse tag rollup — the published-review base and each tag's share.</summary>
public record NurseTagAggregatesResult(int PublishedReviewCount, IReadOnlyList<TagAggregateDto> Tags);
@@ -51,4 +51,13 @@ public class NurseProfile : BaseEntity<long>
public void MarkUnverified() => IsVerified = false; public void MarkUnverified() => IsVerified = false;
public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting; public void SetAcceptingBookings(bool accepting) => IsAcceptingBookings = accepting;
/// <summary>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.</summary>
public void SetReviewAggregates(decimal averageRating, int totalReviews)
{
AverageRating = averageRating;
TotalReviews = totalReviews;
}
} }
@@ -0,0 +1,37 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Reviews;
/// <summary>
/// A nurse-authored clinical note that accumulates into a <b>patient-scoped</b> longitudinal care history —
/// the scoping key is <see cref="PatientId"/>, <b>not</b> a booking. When a different nurse takes over they
/// read the prior history before accepting, so notes must never be siloed per visit; <see cref="BookingId"/>
/// is nullable provenance only (which visit produced the note).
/// <para>
/// The clinical body is <b>encrypted at rest</b>: <see cref="BodyEncrypted"/> holds the
/// <c>IFieldEncryptor</c>-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.
/// </para>
/// </summary>
public class PatientCareRecord : BaseEntity<long>
{
/// <summary>The scoping key — the patient this note belongs to (tenancy is the patient's owning customer).</summary>
public long PatientId { get; set; }
/// <summary>Provenance only: the visit that produced the note. Nullable — a note is not booking-scoped.</summary>
public long? BookingId { get; set; }
/// <summary>The authoring nurse's <c>nurse_profiles.id</c>.</summary>
public long NurseProfileId { get; set; }
/// <summary>The clinical note, encrypted at rest via <c>IFieldEncryptor</c>. Never plaintext, never logged.</summary>
public string BodyEncrypted { get; set; } = string.Empty;
/// <summary>When the note was recorded (UTC). Stored as <c>datetime2</c> so the history read can order by it
/// on both SQL Server and the SQLite test provider (which cannot translate <c>DateTimeOffset</c> ordering).</summary>
public DateTime RecordedAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}
@@ -0,0 +1,57 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Reviews;
/// <summary>
/// One customer review of a <b>completed</b> booking (1:1 with the booking, enforced by a UNIQUE on
/// <see cref="BookingId"/>). A review is public social-proof and enters <see cref="ReviewModerationStatus.PendingModeration"/>;
/// 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
/// <see cref="Moderate"/>, so every transition also stamps the moderator and time. Marked
/// <see cref="IAuditable"/> so the SaveChanges interceptor writes an append-only <c>audit_logs</c> diff for
/// creation and every moderation transition in the same transaction.
/// </summary>
public class Review : BaseEntity<long>, IAuditable
{
/// <summary>1:1 with the completed booking (UNIQUE) — the anti-fraud, one-review-per-booking backstop.</summary>
public long BookingId { get; set; }
/// <summary>The reviewing customer's <c>customer_profiles.id</c> (author).</summary>
public long CustomerProfileId { get; set; }
/// <summary>The reviewed nurse's <c>nurse_profiles.id</c> — the aggregate this review drives when published.</summary>
public long NurseProfileId { get; set; }
/// <summary>15, enforced by a DB CHECK and by validation.</summary>
public int Rating { get; set; }
/// <summary>Optional free-text body.</summary>
public string? Body { get; set; }
/// <summary>Guarded — mutated only through <see cref="Moderate"/>. Defaults to
/// <see cref="ReviewModerationStatus.PendingModeration"/>; never public until published.</summary>
public string ModerationStatus { get; private set; } = Reviews.ReviewModerationStatus.PendingModeration;
/// <summary>Set on hide/reject.</summary>
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<ReviewTagLink> TagLinks { get; set; } = new List<ReviewTagLink>();
/// <summary>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.</summary>
public void Moderate(string targetStatus, string? reason, int? moderatedById, DateTimeOffset now)
{
ModerationStatus = targetStatus;
ModerationReason = reason;
ModeratedById = moderatedById;
ModeratedAt = now;
}
}
@@ -0,0 +1,54 @@
namespace Baya.Domain.Entities.Reviews;
/// <summary>
/// The moderation lifecycle of a <see cref="Review"/>, persisted as these stable snake_case codes (never a
/// C# enum member name). A review is born <see cref="PendingModeration"/> and is <b>never public</b> until an
/// admin/AI transition moves it to <see cref="Published"/>. Only <see cref="Published"/> reviews are rendered
/// publicly and only <see cref="Published"/> reviews count toward the nurse aggregate — which is recomputed
/// from source on <b>every</b> transition so hiding a low rating never leaves a stale, inflated average.
/// </summary>
public static class ReviewModerationStatus
{
/// <summary>Default on submit. Not public, not counted in the aggregate.</summary>
public const string PendingModeration = "pending_moderation";
/// <summary>Approved and publicly visible. The only status counted in the nurse aggregate.</summary>
public const string Published = "published";
/// <summary>Withheld from the public list (e.g. off-topic, abusive) — removed from the aggregate.</summary>
public const string Hidden = "hidden";
/// <summary>Rejected outright (e.g. fake/spam) — never public, not counted.</summary>
public const string Rejected = "rejected";
public static readonly IReadOnlyList<string> All = [PendingModeration, Published, Hidden, Rejected];
public static bool IsValid(string status) => All.Contains(status);
}
/// <summary>The moderator action codes accepted by the moderation endpoint, mapped to a target status.</summary>
public static class ReviewModerationAction
{
public const string Publish = "publish";
public const string Hide = "hide";
public const string Reject = "reject";
/// <summary>Pull a published review back to <see cref="ReviewModerationStatus.PendingModeration"/> — it
/// leaves the public list and the aggregate is recomputed downward.</summary>
public const string Unpublish = "unpublish";
public static readonly IReadOnlyList<string> All = [Publish, Hide, Reject, Unpublish];
/// <summary>Maps an action to its resulting <see cref="ReviewModerationStatus"/>, or null if unknown.</summary>
public static string? ToStatus(string action) => action switch
{
Publish => ReviewModerationStatus.Published,
Hide => ReviewModerationStatus.Hidden,
Reject => ReviewModerationStatus.Rejected,
Unpublish => ReviewModerationStatus.PendingModeration,
_ => null
};
/// <summary>Hide and reject must carry a reason; publish/unpublish need none.</summary>
public static bool RequiresReason(string action) => action is Hide or Reject;
}
@@ -0,0 +1,16 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Reviews;
/// <summary>
/// The N:N join between a <see cref="Review"/> and a <see cref="ReviewTagMaster"/>. A <c>UNIQUE(review_id,
/// review_tag_master_id)</c> forbids the same tag twice on one review.
/// </summary>
public class ReviewTagLink : BaseEntity<long>
{
public long ReviewId { get; set; }
public Review Review { get; set; }
public long ReviewTagMasterId { get; set; }
public ReviewTagMaster Tag { get; set; }
}
@@ -0,0 +1,35 @@
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Reviews;
/// <summary>
/// The standardized review-tag vocabulary (e.g. <c>punctual</c>, <c>professional</c>) used to turn qualitative
/// feedback into quantitative rollups ("% punctual"). Reference data — seeded via <c>HasData</c>, toggled with
/// <see cref="IsActive"/>, ordered by <see cref="SortOrder"/>. Growing the vocabulary is an admin/seed insert,
/// not a schema change.
/// </summary>
public class ReviewTagMaster : BaseEntity<long>
{
/// <summary>Stable machine code (UNIQUE), e.g. <c>punctual</c>.</summary>
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<ReviewTagLink> Links { get; set; } = new List<ReviewTagLink>();
}
/// <summary>The starter tag vocabulary codes seeded with the migration.</summary>
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<string> All = [Punctual, Professional, Clean, Kind, Communicative];
}
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Contracts.Reviews;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="IReviewModerationService"/> (b14) — a keyword filter / pass-through with no
/// external call. A banned-word hit → <see cref="ModerationDecision.Reject"/>; otherwise clean text → a
/// human-review <see cref="ModerationDecision.Flag"/> by default (so the publish gate holds), or
/// <see cref="ModerationDecision.Approve"/> when <see cref="ReviewModerationOptions.AutoApproveClean"/> 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.
/// </summary>
public sealed class MockReviewModerationService(IOptions<SeamOptions> options) : IReviewModerationService
{
private readonly ReviewModerationOptions _options = options.Value.ReviewModeration;
public ValueTask<ModerationVerdict> 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"));
}
}
@@ -20,6 +20,22 @@ public sealed class SeamOptions
public BnplOptions Bnpl { get; set; } = new(); public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { get; set; } = new(); public CurrencyOptions Currency { get; set; } = new();
public BankTransferOptions BankTransfer { get; set; } = new(); public BankTransferOptions BankTransfer { get; set; } = new();
public ReviewModerationOptions ReviewModeration { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IReviewModerationService</c> (b14 AI review pre-screen). By default clean text returns a
/// human-review <c>Flag</c> (keeping the publish gate on); a banned-word hit returns <c>Reject</c>. Set
/// <see cref="AutoApproveClean"/> to have clean text auto-<c>Approve</c> (auto-publish). The real text
/// classifier / LLM endpoint ignores these knobs.
/// </summary>
public sealed class ReviewModerationOptions
{
/// <summary>When true, clean text is auto-approved (auto-published) instead of flagged for human review.</summary>
public bool AutoApproveClean { get; set; }
/// <summary>Case-insensitive substrings that mark a review for rejection.</summary>
public List<string> BannedWords { get; set; } = ["scam", "fraud", "کلاهبردار"];
} }
/// <summary> /// <summary>
@@ -1,6 +1,7 @@
using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Invoices; using Baya.Application.Contracts.Invoices;
using Baya.Application.Contracts.Payments; using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Reviews;
using Baya.Infrastructure.CrossCutting.Seams; using Baya.Infrastructure.CrossCutting.Seams;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; 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. // the payout status machine + the nurse_payout_booking_links UNIQUE remain the irreversible-transfer backstop.
services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>(); services.AddSingleton<IBankTransferProvider, MockBankTransferProvider>();
// 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<IReviewModerationService, MockReviewModerationService>();
return services; return services;
} }
} }
@@ -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;
/// <summary>
/// <c>patient_care_records</c> — nurse-authored, encrypted, <b>patient-scoped</b> clinical notes. The
/// <c>(patient_id, recorded_at DESC)</c> index serves the longitudinal history read. <c>booking_id</c> is
/// nullable provenance only (which visit produced the note) — the scoping key is <c>patient_id</c>.
/// <c>body_encrypted</c> stores <c>IFieldEncryptor</c> ciphertext (no EF value converter — the handler
/// encrypts/decrypts explicitly, so no query path can surface plaintext).
/// </summary>
internal sealed class PatientCareRecordConfig : IEntityTypeConfiguration<PatientCareRecord>
{
public void Configure(EntityTypeBuilder<PatientCareRecord> 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<Patient>().WithMany().HasForeignKey(r => r.PatientId).IsRequired();
builder.HasOne<NurseProfile>().WithMany().HasForeignKey(r => r.NurseProfileId).IsRequired();
builder.HasOne<Booking>().WithMany().HasForeignKey(r => r.BookingId);
builder.HasQueryFilter(r => r.DeletedAt == null);
}
}
@@ -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;
/// <summary>
/// <c>reviews</c> — one review per completed booking. The <c>UNIQUE(booking_id)</c> is the authoritative 1:1
/// backstop; <c>CHECK(rating BETWEEN 1 AND 5)</c> guards the score. Only <c>published</c> reviews are public
/// or counted in the nurse aggregate — the <c>(nurse_profile_id, moderation_status)</c> index serves both the
/// public list and the recompute; the <c>moderation_status</c> index serves the moderation queue.
/// </summary>
internal sealed class ReviewConfig : IEntityTypeConfiguration<Review>
{
public void Configure(EntityTypeBuilder<Review> 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<Booking>().WithMany().HasForeignKey(r => r.BookingId).IsRequired();
builder.HasOne<CustomerProfile>().WithMany().HasForeignKey(r => r.CustomerProfileId).IsRequired();
builder.HasOne<NurseProfile>().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);
}
}
@@ -0,0 +1,25 @@
using Baya.Domain.Entities.Reviews;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
/// <summary>
/// <c>review_tag_links</c> — the N:N join between a review and a master tag. The
/// <c>UNIQUE(review_id, review_tag_master_id)</c> forbids the same tag twice on one review; its leading column
/// is <c>review_id</c>, so it also serves "load a review's tags".
/// </summary>
internal sealed class ReviewTagLinkConfig : IEntityTypeConfiguration<ReviewTagLink>
{
public void Configure(EntityTypeBuilder<ReviewTagLink> 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();
}
}
@@ -0,0 +1,28 @@
using Baya.Domain.Entities.Reviews;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
/// <summary>
/// <c>review_tags_master</c> — the standardized tag vocabulary (seeded via <c>HasData</c>). <c>code</c> is
/// UNIQUE; ordering/toggling is data (<c>sort_order</c>/<c>is_active</c>).
/// </summary>
internal sealed class ReviewTagMasterConfig : IEntityTypeConfiguration<ReviewTagMaster>
{
public void Configure(EntityTypeBuilder<ReviewTagMaster> 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());
}
}
@@ -0,0 +1,38 @@
using Baya.Domain.Entities.Reviews;
namespace Baya.Infrastructure.Persistence.Configuration.ReviewsConfig;
/// <summary>
/// The starter review-tag vocabulary, seeded via <c>HasData</c> 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.
/// </summary>
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();
}
}
@@ -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
{
/// <inheritdoc />
public partial class ReviewsAndPatientCareRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "reviews");
migrationBuilder.CreateTable(
name: "PatientCareRecords",
schema: "reviews",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PatientId = table.Column<long>(type: "bigint", nullable: false),
BookingId = table.Column<long>(type: "bigint", nullable: true),
NurseProfileId = table.Column<long>(type: "bigint", nullable: false),
BodyEncrypted = table.Column<string>(type: "nvarchar(max)", nullable: false),
RecordedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
BookingId = table.Column<long>(type: "bigint", nullable: false),
CustomerProfileId = table.Column<long>(type: "bigint", nullable: false),
NurseProfileId = table.Column<long>(type: "bigint", nullable: false),
Rating = table.Column<int>(type: "int", nullable: false),
Body = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
ModerationStatus = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
ModerationReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
ModeratedById = table.Column<int>(type: "int", nullable: true),
ModeratedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Code = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
LabelFa = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
LabelEn = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ReviewId = table.Column<long>(type: "bigint", nullable: false),
ReviewTagMasterId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(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" });
}
/// <inheritdoc />
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");
}
}
}
@@ -3586,6 +3586,272 @@ namespace Baya.Infrastructure.Persistence.Migrations
}); });
}); });
modelBuilder.Entity("Baya.Domain.Entities.Reviews.PatientCareRecord", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("BodyEncrypted")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long?>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseProfileId")
.HasColumnType("bigint");
b.Property<long>("PatientId")
.HasColumnType("bigint");
b.Property<DateTime>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<long>("BookingId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerProfileId")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<DateTimeOffset?>("ModeratedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModeratedById")
.HasColumnType("int");
b.Property<string>("ModerationReason")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("ModerationStatus")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseProfileId")
.HasColumnType("bigint");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("ReviewId")
.HasColumnType("bigint");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<string>("LabelEn")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("LabelFa")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("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 => modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -4979,6 +5245,65 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired(); .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 => modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{ {
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null) b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
@@ -5215,6 +5540,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("Payouts"); 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 => modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{ {
b.Navigation("Claims"); b.Navigation("Claims");
@@ -27,6 +27,8 @@ public class UnitOfWork : IUnitOfWork
public IInvoiceRepository InvoiceRepository { get; } public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; } public IBnplRepository BnplRepository { get; }
public IPayoutRepository PayoutRepository { get; } public IPayoutRepository PayoutRepository { get; }
public IReviewRepository ReviewRepository { get; }
public IPatientCareRecordRepository PatientCareRecordRepository { get; }
public UnitOfWork(ApplicationDbContext db) public UnitOfWork(ApplicationDbContext db)
{ {
@@ -52,6 +54,8 @@ public class UnitOfWork : IUnitOfWork
InvoiceRepository = new InvoiceRepository(_db); InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db); BnplRepository = new BnplRepository(_db);
PayoutRepository = new PayoutRepository(_db); PayoutRepository = new PayoutRepository(_db);
ReviewRepository = new ReviewRepository(_db);
PatientCareRecordRepository = new PatientCareRecordRepository(_db);
} }
public Task CommitAsync() public Task CommitAsync()
@@ -29,4 +29,10 @@ internal sealed class CustomerProfileRepository : BaseAsyncRepository<CustomerPr
.Where(c => c.UserId == userId) .Where(c => c.UserId == userId)
.Select(c => (long?)c.Id) .Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
public Task<int?> GetUserIdByProfileIdAsync(long customerProfileId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(c => c.Id == customerProfileId)
.Select(c => (int?)c.UserId)
.FirstOrDefaultAsync(cancellationToken);
} }
@@ -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<PatientCareRecord>, 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<long?> GetPatientOwnerCustomerIdAsync(long patientId, CancellationToken cancellationToken)
=> DbContext.Set<Patient>().AsNoTracking()
.Where(p => p.Id == patientId)
.Select(p => (long?)p.CustomerId)
.FirstOrDefaultAsync(cancellationToken);
public Task<bool> NurseHasQualifyingBookingForPatientAsync(long nurseProfileId, long patientId, CancellationToken cancellationToken)
=> DbContext.Set<Booking>().AsNoTracking()
.AnyAsync(b => b.NurseId == nurseProfileId
&& b.PatientId == patientId
&& QualifyingBookingStatuses.Contains(b.Status), cancellationToken);
public async Task<PagedResult<CareRecordCipherRow>> 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<NurseProfile>()
.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<CareRecordCipherRow>(items, total, page, pageSize);
}
}
@@ -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<Review>, IReviewRepository
{
public ReviewRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Review review, CancellationToken cancellationToken) => base.AddAsync(review);
public Task<ReviewableBooking?> GetReviewableBookingAsync(long bookingId, CancellationToken cancellationToken)
=> DbContext.Set<Booking>().AsNoTracking()
.Where(b => b.Id == bookingId)
.Select(b => new ReviewableBooking(b.Id, b.CustomerId, b.NurseId, b.Status))
.FirstOrDefaultAsync(cancellationToken);
public Task<bool> ExistsForBookingAsync(long bookingId, CancellationToken cancellationToken)
=> Entities.AnyAsync(r => r.BookingId == bookingId, cancellationToken);
public Task<Review?> GetTrackedAsync(long reviewId, CancellationToken cancellationToken)
=> Entities.FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken);
public Task<Review?> GetTrackedWithTagsAsync(long reviewId, CancellationToken cancellationToken)
=> Entities.Include(r => r.TagLinks).FirstOrDefaultAsync(r => r.Id == reviewId, cancellationToken);
public async Task<IReadOnlyDictionary<string, long>> GetTagIdsByCodesAsync(IReadOnlyList<string> codes, CancellationToken cancellationToken)
{
if (codes.Count == 0)
return new Dictionary<string, long>();
return await DbContext.Set<ReviewTagMaster>().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<NurseReviewAggregateDto> GetNurseAggregateAsync(long nurseProfileId, CancellationToken cancellationToken)
{
var aggregate = await DbContext.Set<NurseProfile>().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<PagedResult<ReviewListItemDto>> 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<ReviewListItemDto>(items, total, page, pageSize);
}
public async Task<PagedResult<ModerationQueueItemDto>> 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<SupportAlert>()
.Where(a => a.ReviewId == r.Id && a.Type == SupportAlertType.LowRating)
.Select(a => (long?)a.Id)
.FirstOrDefault(),
r.CreatedAt))
.ToListAsync(cancellationToken);
return new PagedResult<ModerationQueueItemDto>(items, total, page, pageSize);
}
public async Task<NurseTagAggregatesResult> 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<ReviewTagLink>().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<ReviewTagMaster>().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);
}
}
@@ -0,0 +1,48 @@
using System.Net;
namespace Baya.Test.Api;
/// <summary>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).</summary>
public class ReviewsApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[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);
}
}
@@ -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<PatientCareRecord>().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);
}
}
@@ -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<ISearchIndexMaintainer>(), Substitute.For<ICacheService>(), host.Clock());
private static ModerateReviewCommandHandler ModerateHandler(ReviewsTestHost host)
=> new(
host.AsAdmin(), host.UnitOfWork, Substitute.For<ISearchIndexMaintainer>(),
Substitute.For<ICacheService>(), host.Clock(), Substitute.For<INotificationDispatcher>());
[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<ISupportAlertService>())
.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<ISupportAlertService>())
.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<ISupportAlertService>())
.Handle(new SubmitReviewCommand(completed, 4, null, null), CancellationToken.None);
Assert.True(first.IsSuccess);
var second = await SubmitHandler(host, Substitute.For<ISupportAlertService>())
.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<ISupportAlertService>(), Substitute.For<ISearchIndexMaintainer>(),
Substitute.For<ICacheService>(), 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<ISupportAlertService>())
.Handle(new SubmitReviewCommand(high, 5, "excellent", null), CancellationToken.None)).Result.Id;
var r1 = (await SubmitHandler(host, Substitute.For<ISupportAlertService>())
.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<ISupportAlertService>();
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<string>(), SupportAlertSeverity.High,
bookingId, result.Result.Id, Arg.Any<CancellationToken>());
}
[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<ISupportAlertService>();
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<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<long?>(), Arg.Any<long?>(), Arg.Any<CancellationToken>());
}
[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<ISupportAlertService>())
.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<ISupportAlertService>())
.Handle(new SubmitReviewCommand(bookingId, 5, null, ["not_a_real_tag"]), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.False(result.IsException);
}
/// <summary>A cache that always runs the factory — exercises the real aggregate read without caching.</summary>
private sealed class PassThroughCache : ICacheService
{
public ValueTask<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default)
=> ValueTask.FromResult<T?>(default);
public ValueTask SetAsync<T>(string key, T value, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
public ValueTask<T> GetOrCreateAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
=> factory(cancellationToken);
}
}

Some files were not shown because too many files have changed in this diff Show More