From 66a60ce874599e129e9064fa74ad1a9abb0068e3 Mon Sep 17 00:00:00 2001 From: hamid Date: Sun, 2 Aug 2026 23:12:44 +0330 Subject: [PATCH] 3 blocker phases --- client/messages/en.json | 1 + client/messages/fa.json | 1 + .../search/nurse/[nurseId]/page.tsx | 5 +- .../admin/verification/[nurseId]/page.tsx | 15 +++- .../CancellationPolicyDisclosure.test.tsx | 2 +- .../CancellationPolicyDisclosure.tsx | 7 +- .../NurseResultCard/NurseResultCard.test.tsx | 9 +++ .../NurseResultCard/NurseResultCard.tsx | 3 +- .../RefundStatusCard.test.tsx | 2 +- .../components/admin/DocumentViewer.test.tsx | 30 +++----- .../src/components/admin/DocumentViewer.tsx | 40 +++++------ client/src/services/refunds/apis/clientApi.ts | 50 +++++++++----- client/src/services/refunds/apis/index.ts | 24 +++++-- client/src/services/refunds/apis/mockApi.ts | 23 ++++--- client/src/services/refunds/constants.ts | 28 ++++---- client/src/services/refunds/types.ts | 8 ++- client/src/services/search/apis/clientApi.ts | 3 + client/src/services/search/apis/mockApi.ts | 55 ++++++++++----- client/src/services/search/types.ts | 12 +++- .../services/verification/apis/clientApi.ts | 13 +--- .../src/services/verification/apis/mockApi.ts | 17 +---- client/src/services/verification/constants.ts | 22 +++--- .../hooks/useVerificationDocumentUrl.ts | 21 ------ client/src/services/verification/index.ts | 1 - client/src/services/verification/keys.ts | 10 +-- client/src/services/verification/types.ts | 14 ++-- mvp/blockers.md | 21 ++---- mvp/fix-plan.md | 19 ++++- .../V1/AdminVerificationsController.cs | 20 ++++++ ...tCancellationPolicyPreviewQuery.Handler.cs | 8 ++- ...AdminApproveVerificationCommand.Handler.cs | 63 +++++++++++++++++ .../AdminApproveVerificationCommand.cs | 14 ++++ .../AdminRejectVerificationCommand.Handler.cs | 63 +++++++++++++++++ ...dminRejectVerificationCommand.Validator.cs | 12 ++++ .../AdminRejectVerificationCommand.cs | 12 ++++ .../Models/Search/NurseSearchResultDto.cs | 15 ++-- .../Services/Search/SqlNurseSearch.cs | 69 +++++++++++++++---- .../Search/SearchIndexTests.cs | 38 ++++++++++ 38 files changed, 536 insertions(+), 234 deletions(-) delete mode 100644 client/src/services/verification/hooks/useVerificationDocumentUrl.ts create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Handler.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Validator.cs create mode 100644 server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.cs diff --git a/client/messages/en.json b/client/messages/en.json index f037bde..07638c5 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -479,6 +479,7 @@ "unnamed_nurse": "Nurse", "unnamed_service": "Service", "completed_visits": "{count, number} successful visits", + "more_services_count": "+{count, plural, one {# more service} other {# more services}}", "reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})", "distance_km": "{km} km", "price_from": "from", diff --git a/client/messages/fa.json b/client/messages/fa.json index b411b6c..128a5bd 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -479,6 +479,7 @@ "unnamed_nurse": "پرستار", "unnamed_service": "خدمت", "completed_visits": "{count, number} ویزیت موفق", + "more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}", "reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})", "distance_km": "{km} کیلومتر", "price_from": "از", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx index be01445..9c017e9 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/search/nurse/[nurseId]/page.tsx @@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) { minimumFractionDigits: 1, maximumFractionDigits: 1, }); + // Reached by direct URL, not gated by the search-index verified-only invariant — unlike the result card + // (NurseResultCard), an unverified nurse's profile CAN be opened this way, so the badge must reflect + // `profile.isVerified` (already correctly fetched by `getNurseProfile`), never an assumed-verified literal. return ( @@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) { - + {profile.inoMembership ? ( } diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx index b01541b..336d692 100644 --- a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx @@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() { const caps = useAdminCapabilities(); const { enqueueSnackbar } = useSnackbar(); - const { data, isLoading, isError, refetch } = useVerificationCase( + const { data, isLoading, isError, isFetching, refetch } = useVerificationCase( Number.isFinite(nurseVerificationId) ? nurseVerificationId : null, ); const approve = useApproveVerification(); @@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() { step={step} nurseVerificationId={nurseVerificationId} canVerify={caps.canVerify} + onReloadDocuments={refetch} + reloadingDocuments={isFetching} /> ))} @@ -290,10 +292,14 @@ function StepCard({ step, nurseVerificationId, canVerify, + onReloadDocuments, + reloadingDocuments, }: { step: AdminVerificationStepDetail; nurseVerificationId: number; canVerify: boolean; + onReloadDocuments: () => void; + reloadingDocuments: boolean; }) { const t = useTranslations('admin'); const { enqueueSnackbar } = useSnackbar(); @@ -339,7 +345,12 @@ function StepCard({ {step.documents.length > 0 ? ( {step.documents.map((doc) => ( - + ))} ) : isManual ? ( diff --git a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx index f945f8a..2a86460 100644 --- a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx +++ b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx @@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = { bookingId: 5003, cancellable: true, cancellationPolicyCode: 'free_24h', - refundPercentageApplied: 1, + refundPercentageApplied: 100, feePercentage: 0, refundAmountIrr: '17600000', feeAmountIrr: '0', diff --git a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx index 9c20df8..1bb382d 100644 --- a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx +++ b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx @@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps { preview: CancellationPolicyPreview; } -/** Percent (integer) from a 0–1 fraction — a small display number, never money, so JS math is safe. */ -function toPercent(fraction: number): number { - return Math.round(fraction * 100); +/** Rounds an already-0–100 percent value for display — a small display number, never money, so JS math is + * safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */ +function toPercent(percent: number): number { + return Math.round(percent); } /** diff --git a/client/src/components/NurseResultCard/NurseResultCard.test.tsx b/client/src/components/NurseResultCard/NurseResultCard.test.tsx index 792e200..37280d8 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.test.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.test.tsx @@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = { nurseId: 1, variantId: 11, serviceCategoryId: 1, + matchingServiceCount: 1, nurseName: 'Maryam Rezaei', avatarUrl: null, isVerified: true, @@ -112,6 +113,14 @@ describe(' component', () => { expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument(); }); + it('discloses "+N more services" only when the nurse matched more than one variant', () => { + renderCard(NURSE); + expect(screen.queryByText(/more_services_count/)).not.toBeInTheDocument(); + + renderCard({ ...NURSE, matchingServiceCount: 3 }); + expect(screen.getByText(/more_services_count:2/)).toBeInTheDocument(); + }); + it('falls back to a label when the name is missing (b7 join gap)', () => { renderCard({ ...NURSE, nurseName: '' }); expect(screen.getByText('unnamed_nurse')).toBeInTheDocument(); diff --git a/client/src/components/NurseResultCard/NurseResultCard.tsx b/client/src/components/NurseResultCard/NurseResultCard.tsx index 6833455..dd2c650 100644 --- a/client/src/components/NurseResultCard/NurseResultCard.tsx +++ b/client/src/components/NurseResultCard/NurseResultCard.tsx @@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps {name} - + {serviceLabel} + {nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''} diff --git a/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx b/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx index d4a6c71..3bd0c66 100644 --- a/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx +++ b/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx @@ -24,7 +24,7 @@ const base: RefundSummary = { totalRefundedIrr: '45000000', expectedCustomerRefundEta: null, externalRevertReference: null, - refundPercentageApplied: 1, + refundPercentageApplied: 100, cancellationPolicyCode: 'free_24h', platformFeeRefundedIrr: '5400000', nursePayoutRefundedIrr: '39600000', diff --git a/client/src/components/admin/DocumentViewer.test.tsx b/client/src/components/admin/DocumentViewer.test.tsx index 7446be3..e6bdbc5 100644 --- a/client/src/components/admin/DocumentViewer.test.tsx +++ b/client/src/components/admin/DocumentViewer.test.tsx @@ -3,9 +3,6 @@ import { ThemeProvider } from '../../theme'; jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' })); -const mockUseDocUrl = jest.fn(); -jest.mock('@/services/verification', () => ({ useVerificationDocumentUrl: (...a: unknown[]) => mockUseDocUrl(...a) })); - import DocumentViewer from './DocumentViewer'; import type { VerificationDocument } from '@/services/verification/types'; @@ -14,35 +11,28 @@ const DOC: VerificationDocument = { contentType: 'image/png', fileSizeBytes: 2048, originalFileName: 'license.png', - url: 'ignored-embedded-url', + url: 'https://signed/fresh.png', }; describe('', () => { const wrap = (ui: React.ReactNode) => render({ui}); - afterEach(() => mockUseDocUrl.mockReset()); - it('renders the loaded image from the on-demand signed url (never the embedded url)', () => { - mockUseDocUrl.mockReturnValue({ data: { url: 'https://signed/fresh.png', expiresInSeconds: 60 }, isLoading: false, isFetching: false, isError: false, refetch: jest.fn() }); - const { container } = wrap(); + it('renders the image from the case-embedded signed url', () => { + const { container } = wrap(); const img = container.querySelector('img') as HTMLImageElement; expect(img).toBeTruthy(); expect(img.src).toContain('signed/fresh.png'); - expect(img.src).not.toContain('ignored-embedded-url'); }); - it('offers a re-request affordance on error and calls refetch', () => { - const refetch = jest.fn(); - mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch }); - wrap(); - expect(screen.getByText('doc_error')).toBeInTheDocument(); - // Two re-request buttons (header + error panel); click the first. - fireEvent.click(screen.getAllByText('doc_reload')[0]); - expect(refetch).toHaveBeenCalled(); + it('calls onReload — there is no per-document endpoint, so reload refetches the parent case', () => { + const onReload = jest.fn(); + wrap(); + fireEvent.click(screen.getByText('doc_reload')); + expect(onReload).toHaveBeenCalled(); }); - it('shows a skeleton while the signed url is loading', () => { - mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() }); - const { container } = wrap(); + it('shows a skeleton while the parent case is (re)loading', () => { + const { container } = wrap(); expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); }); }); diff --git a/client/src/components/admin/DocumentViewer.tsx b/client/src/components/admin/DocumentViewer.tsx index 17d3fd7..e1351d0 100644 --- a/client/src/components/admin/DocumentViewer.tsx +++ b/client/src/components/admin/DocumentViewer.tsx @@ -3,12 +3,16 @@ import { FunctionComponent } from 'react'; import { useTranslations } from 'next-intl'; import { Box, Skeleton, Stack, Typography } from '@mui/material'; import type { VerificationDocument } from '@/services/verification/types'; -import { useVerificationDocumentUrl } from '@/services/verification'; import AppButton from '../common/AppButton'; import AppIcon from '../common/AppIcon'; export interface DocumentViewerProps { document: VerificationDocument; + /** Refetches the parent case — there is no per-document re-sign route, so a reopen re-fetches the whole + * case to get a freshly-signed `document.url`. */ + onReload: () => void; + /** True while the parent case is (re)loading — drives the skeleton. */ + reloading: boolean; } /** Human-readable file size. */ @@ -19,16 +23,15 @@ function formatSize(bytes: number): string { } /** - * A verification-document viewer that fetches its **signed URL on demand** (never the embedded one — those - * are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link, - * **error / expired → re-request** (the URL is short-lived, so a manual re-request re-signs it), and the - * loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII → only - * the signed URL is ever surfaced, never a public asset (phase §5). + * A verification-document viewer. `document.url` is the **short-lived signed GET URL the case detail + * already carries** — there is no separate per-document re-sign route (b6 gap), so "reload" refetches the + * whole case (`onReload`) rather than calling a document-specific endpoint. Handles the loading (parent + * case fetching) and loaded states (inline image preview for images, otherwise an "open in a new tab" + * affordance). PII → only the signed URL is ever surfaced, never a public asset (phase §5). * @component DocumentViewer */ -const DocumentViewer: FunctionComponent = ({ document }) => { +const DocumentViewer: FunctionComponent = ({ document, onReload, reloading }) => { const t = useTranslations('admin'); - const signed = useVerificationDocumentUrl(document.id); const isImage = document.contentType.startsWith('image/'); return ( @@ -45,36 +48,27 @@ const DocumentViewer: FunctionComponent = ({ document }) => variant="text" color="primary" startIcon="refresh" - onClick={() => signed.refetch()} - disabled={signed.isFetching} + onClick={onReload} + disabled={reloading} sx={{ minWidth: 0 }} > {t('doc_reload')} - {signed.isLoading || signed.isFetching ? ( + {reloading ? ( - ) : signed.isError ? ( - - - {t('doc_error')} - - signed.refetch()}> - {t('doc_reload')} - - - ) : signed.data?.url ? ( + ) : document.url ? ( isImage ? ( // Signed, short-lived, cross-host URL (not a static asset) — a plain via Box, not next/image. ) : ( - + {t('doc_open_new')} ) diff --git a/client/src/services/refunds/apis/clientApi.ts b/client/src/services/refunds/apis/clientApi.ts index 386a4ec..ac9e8f9 100644 --- a/client/src/services/refunds/apis/clientApi.ts +++ b/client/src/services/refunds/apis/clientApi.ts @@ -32,6 +32,19 @@ interface RefundStatusWire { reference: string | null; } +/** + * `CancellationPolicyPreviewDto` — everything `CancellationPolicyPreview` has except `refundableSessionIds`, + * which the server doesn't serve as its own field (derived below from `sessions` instead). + */ +type CancellationPolicyPreviewWire = Omit; + +function toPreview(wire: CancellationPolicyPreviewWire): CancellationPolicyPreview { + return { + ...wire, + refundableSessionIds: wire.sessions.filter((s) => s.refundable).map((s) => s.bookingSessionId), + }; +} + function toSummary(wire: RefundStatusWire): RefundSummary { return { id: wire.id, @@ -53,23 +66,20 @@ function toSummary(wire: RefundStatusWire): RefundSummary { } /** - * Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route - * (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed - * (which is why the domain stays mock-primary — see `constants.ts`): - * - `resolveCancellationPolicy` → REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the - * policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time - * + per-session refundability. - * - `cancelBooking` → REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path. - * - `getRefundByBooking` → REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund - * id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund. - * - * NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`). + * Real HTTP implementation of the `RefundsApi` seam. The customer half (`resolveCancellationPolicy`/ + * `cancelBooking`/`getRefundByBooking`/`getRefund`/`getMyRefunds`) is the primary implementation as of + * phase 08 (REQ-019/020/021 routes are live: `BookingsController.CancellationPolicy`/`.Cancel`, + * `RefundsController.Status`/`.ByBooking`). The admin console methods below (`getRefundPreview`/ + * `approveRefund`/`rejectRefund`) still target proposed routes (REQ-035) — `AdminRefundsController` only + * has create-and-execute — so `USE_ADMIN_REFUNDS_MOCK` keeps them on the mock (see `constants.ts`). */ export const refundsClientApi: RefundsApi = { resolveCancellationPolicy: async (bookingId: number) => - unwrap( - await clientFetch>( - `${BOOKINGS}/${bookingId}/cancellation_policy`, + toPreview( + unwrap( + await clientFetch>( + `${BOOKINGS}/${bookingId}/cancellation_policy`, + ), ), ), @@ -99,10 +109,16 @@ export const refundsClientApi: RefundsApi = { toSummary(unwrap(await clientFetch>(`${REFUNDS}/${refundId}/status`))), // REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s - // until delivered (the wallet «استردادها» tab renders its empty state until then). + // until delivered, so the wallet «استردادها» tab renders its empty state until then rather than an + // error (a 404 here means "not built yet", not "something went wrong"). getMyRefunds: async () => { - const wire = unwrap(await clientFetch>(`${REFUNDS}/my`)); - return wire.map(toSummary); + try { + const wire = unwrap(await clientFetch>(`${REFUNDS}/my`)); + return wire.map(toSummary); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return []; + throw error; + } }, // REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no diff --git a/client/src/services/refunds/apis/index.ts b/client/src/services/refunds/apis/index.ts index d629577..f6bd96e 100644 --- a/client/src/services/refunds/apis/index.ts +++ b/client/src/services/refunds/apis/index.ts @@ -1,10 +1,26 @@ -import { USE_REFUNDS_MOCK } from '../constants'; +import { USE_ADMIN_REFUNDS_MOCK, USE_CUSTOMER_REFUNDS_MOCK } from '../constants'; import type { RefundsApi } from '../types'; import { refundsClientApi } from './clientApi'; import { refundsMockApi } from './mockApi'; +const customer = USE_CUSTOMER_REFUNDS_MOCK ? refundsMockApi : refundsClientApi; +const admin = USE_ADMIN_REFUNDS_MOCK ? refundsMockApi : refundsClientApi; + /** - * The selected `RefundsApi` implementation — the single seam the hooks import. Selection is by config - * (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks. + * The selected `RefundsApi` implementation — the single seam the hooks import. The customer surface and + * the admin console are two independently-selected halves (`USE_CUSTOMER_REFUNDS_MOCK` / + * `USE_ADMIN_REFUNDS_MOCK`), composed into one object here rather than scattered `if (mock)` checks — + * see the constants for why the admin group must never be split across real and mock. */ -export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi; +export const refundsApi: RefundsApi = { + resolveCancellationPolicy: customer.resolveCancellationPolicy, + cancelBooking: customer.cancelBooking, + getRefundByBooking: customer.getRefundByBooking, + getRefund: customer.getRefund, + getMyRefunds: customer.getMyRefunds, + + getRefundPreview: admin.getRefundPreview, + initiateRefund: admin.initiateRefund, + approveRefund: admin.approveRefund, + rejectRefund: admin.rejectRefund, +}; diff --git a/client/src/services/refunds/apis/mockApi.ts b/client/src/services/refunds/apis/mockApi.ts index b0f8e21..74ee3a4 100644 --- a/client/src/services/refunds/apis/mockApi.ts +++ b/client/src/services/refunds/apis/mockApi.ts @@ -124,8 +124,10 @@ function computePreview(bookingId: number): CancellationPolicyPreview { bookingId, cancellable, cancellationPolicyCode: policyCode, - refundPercentageApplied: tier.refundFraction, - feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100, + // 0–100 (matches the real server's decimal `RefundPercentageApplied`/`FeePercentage`), not the 0–1 + // fraction `tier.refundFraction` uses internally for the BigInt math above. + refundPercentageApplied: tier.refundFraction * 100, + feePercentage: (1 - tier.refundFraction) * 100, refundAmountIrr: refundAmount.toString(), feeAmountIrr: feeAmount.toString(), refundableAmountIrr: refundableGross.toString(), @@ -158,7 +160,7 @@ refundsByBooking[5004] = { totalRefundedIrr: '6000000', expectedCustomerRefundEta: null, externalRevertReference: maskedReference(5004), - refundPercentageApplied: 0.5, + refundPercentageApplied: 50, cancellationPolicyCode: 'partial_under_24h', platformFeeRefundedIrr: '720000', nursePayoutRefundedIrr: '5280000', @@ -282,13 +284,14 @@ const adminRefundsById: Record = {}; const adminInitiateAttempts: Record = {}; /** - * In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't - * serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the - * customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per- - * session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects - * it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer - * steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the - * real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`). + * In-memory mock behind the `RefundsApi` seam. The customer half (cancel/preview/status) is a config- + * selectable fallback now that REQ-019/020/021 are real (`USE_CUSTOMER_REFUNDS_MOCK = false` by default) — + * kept for local demo/dev without a backend. It reads the shared f8 bookings store to resolve the tier + + * per-session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache + * reflects it after invalidation), enforces the outside-policy `409`, and drives the refund through the + * customer steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). + * The admin half below (preview/initiate/approve/reject) is the **primary** implementation + * (`USE_ADMIN_REFUNDS_MOCK = true`) — REQ-035's real endpoints don't exist yet. */ export const refundsMockApi: RefundsApi = { resolveCancellationPolicy: async (bookingId) => { diff --git a/client/src/services/refunds/constants.ts b/client/src/services/refunds/constants.ts index 2510c7f..074b42a 100644 --- a/client/src/services/refunds/constants.ts +++ b/client/src/services/refunds/constants.ts @@ -2,20 +2,24 @@ import { REFUND_ETA_MAX_BUSINESS_DAYS } from '@/constants'; import type { CancellationPolicyCode } from './types'; /** - * When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the - * `RefundsApi` seam. + * When true, the **customer-facing** refund surface (`resolveCancellationPolicy`/`cancelBooking`/ + * `getRefundByBooking`/`getRefund`/`getMyRefunds`) is served by the in-memory mock (`apis/mockApi.ts`). * - * **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only - * customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref). - * There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking - * lookup, and the customer status carries **no** fee-leg decomposition — all filed as REQ-019/020/021. - * So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads - * the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled` - * on confirm (so the booking-detail cache reflects it), and drives a refund through - * `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false` - * once REQ-019/020/021 land — no hook/component change. + * **Real as of phase 08 (blocker-phases/08-refunds-demock.md).** REQ-019/020/021's routes are live and + * shape-matched (`BookingsController.CancellationPolicy`/`.Cancel`, `RefundsController.Status`/`.ByBooking`) + * — the mock stays only as a config-selectable fallback for local demo/dev without a backend. */ -export const USE_REFUNDS_MOCK = true; +export const USE_CUSTOMER_REFUNDS_MOCK = false; + +/** + * When true, the **admin refund console** (`getRefundPreview`/`initiateRefund`/`approveRefund`/ + * `rejectRefund`) is served by the mock. `AdminRefundsController` only implements create-and-execute + * (`initiateRefund`'s real endpoint) — there is no real read-only preview, no retry/approve, and no + * reject route (REQ-035). Mixing a real `initiateRefund` with a mocked preview would let an admin approve + * against numbers that don't match what actually executes, so the whole admin group stays on the mock + * together until all four land. Flip once REQ-035 ships. + */ +export const USE_ADMIN_REFUNDS_MOCK = true; /** * The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit diff --git a/client/src/services/refunds/types.ts b/client/src/services/refunds/types.ts index 34b9c62..6fd32e3 100644 --- a/client/src/services/refunds/types.ts +++ b/client/src/services/refunds/types.ts @@ -106,9 +106,9 @@ export interface CancellationPolicyPreview { /** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */ cancellable: boolean; cancellationPolicyCode: CancellationPolicyCode; - /** 0–1 fraction of the refundable amount returned to the customer. */ + /** 0–100 percent of the refundable amount returned to the customer (matches the server's decimal). */ refundPercentageApplied: number; - /** 0–1 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */ + /** 0–100 percent retained as the cancellation fee/penalty (`= 100 - refundPercentageApplied`). */ feePercentage: number; /** IRR digit-string — the amount refunded to the customer. */ refundAmountIrr: string; @@ -125,7 +125,8 @@ export interface CancellationPolicyPreview { refundChannel: RefundChannel; /** Populated only for `bnpl_revert` (the ~7–10 business-day window); a date `YYYY-MM-DD`. */ expectedCustomerRefundEta: string | null; - /** The refundable session ids the confirm submits (all un-started sessions). */ + /** The refundable session ids the confirm submits (all un-started sessions); derived from `sessions` on + * the real path (the server doesn't serve it as its own field). */ refundableSessionIds: number[]; sessions: CancellationSessionPreview[]; } @@ -164,6 +165,7 @@ export interface RefundSummary { /** Opaque, **masked** (last 4 only) external reference — never parse it. */ externalRevertReference: string | null; /** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */ + /** 0–100 percent, matching `CancellationPolicyPreview.refundPercentageApplied`'s scale. */ refundPercentageApplied: number | null; cancellationPolicyCode: string | null; platformFeeRefundedIrr: string | null; diff --git a/client/src/services/search/apis/clientApi.ts b/client/src/services/search/apis/clientApi.ts index 9dc4ccb..bfd69a0 100644 --- a/client/src/services/search/apis/clientApi.ts +++ b/client/src/services/search/apis/clientApi.ts @@ -32,6 +32,8 @@ interface NurseSearchResultDto { nurseName: string | null; avatarUrl: string | null; distanceKm: number | null; + /** Phase 10: how many of the nurse's variants matched this query (server-side dedup). */ + matchingServiceCount: number; /** REQ-040 (proposed) — not yet served; absent until the backend lands it. */ topReviewTag?: string | null; } @@ -94,6 +96,7 @@ export const searchClientApi: SearchApi = { nurseId: dto.nurseId, variantId: dto.variantId, serviceCategoryId: dto.serviceCategoryId, + matchingServiceCount: dto.matchingServiceCount, // REQ-012 — identity denormalized onto the index row; card falls back to a label only when null. nurseName: dto.nurseName ?? '', avatarUrl: dto.avatarUrl, diff --git a/client/src/services/search/apis/mockApi.ts b/client/src/services/search/apis/mockApi.ts index fa08d82..25c5a36 100644 --- a/client/src/services/search/apis/mockApi.ts +++ b/client/src/services/search/apis/mockApi.ts @@ -34,11 +34,12 @@ function withinPrice(priceIrr: string, min?: string, max?: string): boolean { return true; } -function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult { +function toResult(nurse: SeedNurse, variant: SeedVariant, matchingServiceCount: number): NurseSearchResult { return { nurseId: nurse.nurseId, variantId: variant.variantId, serviceCategoryId: variant.serviceCategoryId, + matchingServiceCount, nurseName: nurse.nurseName, avatarUrl: nurse.avatarUrl, isVerified: true, @@ -54,6 +55,25 @@ function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult { }; } +/** One card per nurse (phase 10): collapse the matched variant rows down to her cheapest matching + * variant, counting how many others matched. Mirrors the real `SqlNurseSearch` grouping. */ +function dedupeByNurse(rows: { nurse: SeedNurse; variant: SeedVariant }[]): NurseSearchResult[] { + const byNurse = new Map(); + for (const { nurse, variant } of rows) { + const existing = byNurse.get(nurse.nurseId); + if (!existing) { + byNurse.set(nurse.nurseId, { nurse, cheapest: variant, matchCount: 1 }); + continue; + } + existing.matchCount += 1; + const isCheaper = + BigInt(variant.priceIrr) < BigInt(existing.cheapest.priceIrr) || + (BigInt(variant.priceIrr) === BigInt(existing.cheapest.priceIrr) && variant.variantId < existing.cheapest.variantId); + if (isCheaper) existing.cheapest = variant; + } + return Array.from(byNurse.values()).map(({ nurse, cheapest, matchCount }) => toResult(nurse, cheapest, matchCount)); +} + /** * In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort * semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert) @@ -71,25 +91,24 @@ export const searchMockApi: SearchApi = { throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range'); } - const matched = allRows() - .filter(({ nurse, variant }) => { - if (variant.serviceCategoryId !== filters.serviceCategoryId) return false; - if (variant.cityId !== filters.cityId) return false; - if (!matchesDistrict(variant.districtId, filters.districtId)) return false; - if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false; - if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false; - if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false; - return true; - }) - // Rating desc, tiebroken by review count then ids so paging is deterministic (contract order). + const matchedRows = allRows().filter(({ nurse, variant }) => { + if (variant.serviceCategoryId !== filters.serviceCategoryId) return false; + if (variant.cityId !== filters.cityId) return false; + if (!matchesDistrict(variant.districtId, filters.districtId)) return false; + if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false; + if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false; + if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false; + return true; + }); + + const matched = dedupeByNurse(matchedRows) + // Rating desc, tiebroken by review count then nurse id so paging is deterministic (contract order). .sort( (a, b) => - b.nurse.averageRating - a.nurse.averageRating || - b.nurse.totalReviews - a.nurse.totalReviews || - a.nurse.nurseId - b.nurse.nurseId || - a.variant.variantId - b.variant.variantId, - ) - .map(({ nurse, variant }) => toResult(nurse, variant)); + b.averageRating - a.averageRating || + b.totalReviews - a.totalReviews || + a.nurseId - b.nurseId, + ); const pageSize = filters.pageSize || SEARCH_PAGE_SIZE; const page = filters.page || 1; diff --git a/client/src/services/search/types.ts b/client/src/services/search/types.ts index d8bc73a..faca9b2 100644 --- a/client/src/services/search/types.ts +++ b/client/src/services/search/types.ts @@ -11,8 +11,10 @@ import type { PriceUnit } from '@/services/catalog/types'; * - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit * only when the nurse is verified + not suspended + accepting + the variant is active. The UI must * **never** re-filter for verification, and never surface an unverified/paused nurse. - * - **The result unit is the variant, not the nurse** — a nurse with several variants/areas can appear - * as several hits. + * - **The result unit is the nurse, one card per nurse** (phase 10 — previously the variant, so a nurse + * with several matching variants/areas surfaced as several hits). The server groups the underlying + * per-variant index rows and picks the cheapest matching variant as the card's representative; + * `matchingServiceCount` says how many of her variants matched. * - **`districtId = null` ⇒ whole city**, both directions; the client omits `districtId` for a * whole-city search rather than sending a bogus value. * - **Same-gender is first-class** — `nurseGender` is an up-front filter, never silently defaulted or @@ -52,11 +54,15 @@ export interface NurseSearchFilters { pageSize: number; } -/** A single C2 result card row (one bookable variant matched in a covered area). */ +/** A single C2 result card — one nurse (phase 10: server-deduplicated; was previously one bookable + * variant matched in a covered area, so the same nurse could repeat across several cards). */ export interface NurseSearchResult { nurseId: number; + /** The nurse's cheapest matching variant — the card's "from X" price and the profile deep-link target. */ variantId: number; serviceCategoryId: number; + /** How many of the nurse's variants matched this query (>= 1); the card discloses "+N more" when > 1. */ + matchingServiceCount: number; /** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */ nurseName: string; avatarUrl: string | null; diff --git a/client/src/services/verification/apis/clientApi.ts b/client/src/services/verification/apis/clientApi.ts index 40792e3..1bdb2f4 100644 --- a/client/src/services/verification/apis/clientApi.ts +++ b/client/src/services/verification/apis/clientApi.ts @@ -15,7 +15,6 @@ import type { IdentityKycInput, NurseCredential, RunStepResult, - SignedDocumentUrl, TrustBadge, UploadUrlResult, VerificationAggregateStatus, @@ -249,12 +248,6 @@ export const verificationClientApi: VerificationApi = { }; }, - // REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on - // the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an - // on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`. - getDocumentSignedUrl: async (documentId: number): Promise => - unwrap(await clientFetch>(`${ADMIN_BASE}/documents/${documentId}/url`)), - decideStep: async (stepId: number, input: DecideStepInput): Promise => unwrap( await clientFetch>(`${ADMIN_BASE}/steps/${stepId}/decide`, { @@ -263,9 +256,9 @@ export const verificationClientApi: VerificationApi = { }), ), - // REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step - // `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and - // `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step). + // Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/ + // Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct + // admin override, not a per-step decision. approveVerification: async (nurseVerificationId: number): Promise => { await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' }); }, diff --git a/client/src/services/verification/apis/mockApi.ts b/client/src/services/verification/apis/mockApi.ts index 6e5f592..3c93930 100644 --- a/client/src/services/verification/apis/mockApi.ts +++ b/client/src/services/verification/apis/mockApi.ts @@ -143,7 +143,8 @@ function mkDoc(id: number, originalFileName: string): VerificationDocument { contentType: 'application/pdf', fileSizeBytes: 482_000, originalFileName, - // A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open. + // A short-lived signed GET URL, embedded on the case detail — re-opening the viewer refetches the + // case (there is no separate per-document re-sign route) to get a fresh one. url: `https://mock.balinyaar.local/docs/${id}`, }; } @@ -414,20 +415,6 @@ export const verificationMockApi: VerificationApi = { return toCaseView(record); }, - getDocumentSignedUrl: async (documentId) => { - await sleep(MOCK_LATENCY_MS); - // Sentinel for the viewer's error/re-request path: this document can never be signed. - if (documentId === 9999) { - throw new ApiError(404, 'Document not found', 'document_not_found'); - } - // A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache. - const sig = Math.random().toString(36).slice(2, 12); - return { - url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`, - expiresInSeconds: 60, - }; - }, - decideStep: async (stepId, input) => { await sleep(MOCK_LATENCY_MS); const found = findCaseByStepId(stepId); diff --git a/client/src/services/verification/constants.ts b/client/src/services/verification/constants.ts index e288208..82fdba9 100644 --- a/client/src/services/verification/constants.ts +++ b/client/src/services/verification/constants.ts @@ -1,12 +1,14 @@ /** * When true, the verification domain is served by the in-memory mock (apis/mockApi.ts) behind the - * VerificationApi seam. The b6 routes exist server-side, but — like `catalog` — the mock lets the full - * nurse flow (checklist → identity run → credential upload → under-review → admin-approval → verified - * badge + publish gate) demo standalone before the backend is reachable in this environment. Flip to - * false to hit the live endpoints — no hook/component changes (see - * dev/shared-working-context/reports/mocks-registry.md). + * VerificationApi seam. + * + * **Real as of phase 09 (blocker-phases/09-nurse-verification-badge.md).** The b6 routes are live and + * shape-matched; the two admin gaps the mock papered over (whole-verification approve/reject, and the + * per-document signed-URL re-sign) shipped in the same change — see `AdminVerificationsController`'s + * `Approve`/`Reject` actions and `DocumentViewer`'s refetch-the-case rewire. The mock stays only as a + * config-selectable fallback for local demo/dev without a backend. */ -export const USE_VERIFICATION_MOCK = true; +export const USE_VERIFICATION_MOCK = false; /** * The checklist is **moderately fresh** — submitting a step changes it, and every mutation invalidates @@ -35,11 +37,3 @@ export const ADMIN_QUEUE_PAGE_SIZE = 20; /** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */ export const ADMIN_CASE_STALE_TIME = 20_000; - -/** - * A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it - * out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the - * stale URL soon after the viewer closes (never retry — a failed/expired sign is surfaced, not re-hammered). - */ -export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000; -export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000; diff --git a/client/src/services/verification/hooks/useVerificationDocumentUrl.ts b/client/src/services/verification/hooks/useVerificationDocumentUrl.ts deleted file mode 100644 index f0d0a9e..0000000 --- a/client/src/services/verification/hooks/useVerificationDocumentUrl.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { verificationApi } from '../apis'; -import { verificationKeys } from '../keys'; -import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants'; - -/** - * A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass - * `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache — a - * reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is - * surfaced to the viewer's error/re-request path, not silently re-hammered. - */ -export function useVerificationDocumentUrl(documentId: number | null) { - return useQuery({ - queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1), - queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number), - enabled: documentId != null, - staleTime: SIGNED_DOCUMENT_URL_STALE_TIME, - gcTime: SIGNED_DOCUMENT_URL_GC_TIME, - retry: false, - }); -} diff --git a/client/src/services/verification/index.ts b/client/src/services/verification/index.ts index 742e500..a18d317 100644 --- a/client/src/services/verification/index.ts +++ b/client/src/services/verification/index.ts @@ -9,7 +9,6 @@ export { useNurseTrustBadge } from './hooks/useNurseTrustBadge'; // Admin review queue (b6 AdminVerificationsController) export { useVerificationQueue } from './hooks/useVerificationQueue'; export { useVerificationCase } from './hooks/useVerificationCase'; -export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl'; export { useDecideStep } from './hooks/useDecideStep'; export { useApproveVerification } from './hooks/useApproveVerification'; export { useRejectVerification } from './hooks/useRejectVerification'; diff --git a/client/src/services/verification/keys.ts b/client/src/services/verification/keys.ts index d88bd31..9f100b0 100644 --- a/client/src/services/verification/keys.ts +++ b/client/src/services/verification/keys.ts @@ -7,9 +7,9 @@ import type { PageParams } from '@/lib/api/types'; * submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no * manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6. * - * The admin subtree (`admin()` → queue / case / document-url) mirrors the same hierarchy: each queue - * variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` - * prefixes let a decision invalidate every queue page and a single case in one call. + * The admin subtree (`admin()` → queue / case) mirrors the same hierarchy: each queue variant + * (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` prefixes let + * a decision invalidate every queue page and a single case in one call. */ export const verificationKeys = { all: ['verification'] as const, @@ -35,8 +35,4 @@ export const verificationKeys = { // A single nurse's full case — invalidated on every decide / approve / reject. adminCases: () => [...verificationKeys.admin(), 'case'] as const, adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const, - - // A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand). - adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const, - adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const, }; diff --git a/client/src/services/verification/types.ts b/client/src/services/verification/types.ts index 74ec371..cd0854c 100644 --- a/client/src/services/verification/types.ts +++ b/client/src/services/verification/types.ts @@ -260,12 +260,6 @@ export interface DecideStepResult { credentialId: number | null; } -/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */ -export interface SignedDocumentUrl { - url: string; - expiresInSeconds: number; -} - /** * The verification domain's API seam — the real HTTP client and the in-memory mock both implement * this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks. @@ -294,10 +288,12 @@ export interface VerificationApi { // --- Admin review queue (b6 AdminVerificationsController) --- /** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */ listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise; - /** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */ + /** + * The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. + * Each document's `url` is already a short-lived signed GET URL; re-opening the viewer refetches this + * case (there is no separate per-document re-sign route) to get a fresh one. + */ getVerificationCase(nurseVerificationId: number): Promise; - /** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */ - getDocumentSignedUrl(documentId: number): Promise; /** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */ decideStep(stepId: number, input: DecideStepInput): Promise; /** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */ diff --git a/mvp/blockers.md b/mvp/blockers.md index ba52b6e..38de0fa 100644 --- a/mvp/blockers.md +++ b/mvp/blockers.md @@ -25,16 +25,15 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real make the first screen show an error immediately instead of the outdated-mock failure it shows now. Those three endpoints need to be built before this can be turned on for real. *(Effort: S–M, one part fixed, one part bigger than first scoped)* -- **Refunds are demo-only today.** The refund screens read fake, disconnected sample data; turning that off - today would show a refund of the wrong amount (off by a factor of 100) for any real cancellation. - *(Effort: M)* +- ~~**Refunds are demo-only today.**~~ **Fixed (phase 08)** for the customer-facing flow: the cancellation + preview, the cancel confirmation, and the refund-status screen now read the real backend, and the ×100 + percent-display bug is gone. The **admin refund console** (preview / retry / reject) is still mocked — + those three endpoints don't exist on the real server yet (see `mvp/fix-plan.md` follow-ups). ### Trust — nurse verification -- **A nurse's verification badge does not reflect reality.** The verified/unverified status shown to - customers and to the nurse herself comes from a fake demo layer, not the real, already-working - verification data underneath. A genuinely verified nurse can show as unverified everywhere in the app, and - vice versa — silently hiding the real "you're not searchable yet" warning a nurse needs to see. - *(Effort: L)* +- ~~**A nurse's verification badge does not reflect reality.**~~ **Fixed (phase 09).** The badge everywhere + (search, nurse's own profile, admin case review) now reads the real verification data; the admin + whole-verification approve/reject actions got real endpoints (they previously 404'd). ### Patient records & visit notes - **Everything a nurse writes about a visit, and everything a family sees about a patient's care plan, is @@ -56,12 +55,6 @@ Effort is a rough size, not a schedule: **S** = small/contained, **M** = a real more than it sounds — see [forgotten-features.md](forgotten-features.md) for why the business plan leans on this feature specifically. *(Effort: L)* -### Search -- **Search results aren't de-duplicated nurses — they're raw pricing-option rows.** One nurse with 3 services - in 3 areas shows up as "9 nurses." The trust information on a result card is also fake, and a nurse who - isn't verified yet can still be opened directly and shown as "verified" if you know her profile link. - *(Effort: M)* - ### Booking lifecycle - **A booking whose remaining visits get automatically marked "missed" can get stuck forever** and never reach a state where the nurse can actually be paid for the visits she did complete. The "today's visits" diff --git a/mvp/fix-plan.md b/mvp/fix-plan.md index 88b8045..f02641c 100644 --- a/mvp/fix-plan.md +++ b/mvp/fix-plan.md @@ -23,9 +23,9 @@ whatever order you prefer. | 05 | [bnpl-setup](blocker-phases/05-bnpl-setup.md) | Installments (BNPL) don't work at all | — | — | | 06 | [catalog-admin-page](blocker-phases/06-catalog-admin-page.md) | No admin page for service categories/pricing | — | — | | 07 | [card-payment-redirect](blocker-phases/07-card-payment-redirect.md) | Card payment can never complete | — | ✅ Done (follow-up filed below) | -| 08 | [refunds-demock](blocker-phases/08-refunds-demock.md) | Refunds are demo-only, off by 100× | — | — | -| 09 | [nurse-verification-badge](blocker-phases/09-nurse-verification-badge.md) | Verification badge doesn't reflect reality | pairs with 10 | — | -| 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | — | +| 08 | [refunds-demock](blocker-phases/08-refunds-demock.md) | Refunds are demo-only, off by 100× | — | ✅ Done (customer surface only, follow-up filed below) | +| 09 | [nurse-verification-badge](blocker-phases/09-nurse-verification-badge.md) | Verification badge doesn't reflect reality | pairs with 10 | ✅ Done | +| 10 | [search-dedup-and-trust](blocker-phases/10-search-dedup-and-trust.md) | Search isn't de-duplicated; trust info hardcoded | pairs with 09 | ✅ Done | | 11 | [nurse-payouts](blocker-phases/11-nurse-payouts.md) | Nurse pay/payouts are fake, no "process" action | benefits from 01 | — | | 12 | [patient-records](blocker-phases/12-patient-records.md) | Patient records & visit notes are fake demo data | needs a product decision first | — | | 13 | [booking-lifecycle](blocker-phases/13-booking-lifecycle.md) | Stuck bookings; "today's visits" unfiltered | pairs with 04 | — | @@ -34,6 +34,19 @@ whatever order you prefer. ## Follow-ups filed (not yet phases of their own) +- **Phase 08 closed the customer-facing refund surface only — the admin refund console stays mocked.** + `AdminRefundsController` only implements create-and-execute (`POST admin_refunds`, matching + `initiateRefund`); there is no real read-only preview, no retry/approve, and no reject route. The phase + doc's own read ("real server side is live and correct") only checked the customer half + (`RefundsController`, `CreateRefundCommand`, `GetCancellationPolicyPreviewQuery`) — the admin gaps weren't + called out and would have 404'd the console had the single mock flag been flipped wholesale. Split into + two independently-selected flags instead (`client/src/services/refunds/constants.ts`): + `USE_CUSTOMER_REFUNDS_MOCK = false` (real — closes the actual ×100 money-safety bug) and + `USE_ADMIN_REFUNDS_MOCK = true` (stays mocked; the four admin methods are kept together rather than mixed, + since a real `initiateRefund` executing against a mocked preview's numbers would be actively dangerous). + Building the missing preview/retry/reject endpoints needs real design (retry semantics re-executing a + channel call, what "reject" reverses) that isn't specified anywhere — filed as its own future phase, not + guessed here. - **Same timezone bug as 04, lower severity, not fixed.** Phase 04 fixed `BookingRequest.PaymentDeadlineAt`/ `NurseResponseDeadlineAt` — a `DateTime` (not `DateTimeOffset`) read back from SQL Server's `datetime2` loses its `Kind` tag (comes back `Unspecified`), so JSON serialization drops the trailing `Z` and a client diff --git a/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs index d67f58e..e8ee140 100644 --- a/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs +++ b/server/src/API/Baya.Web.Api/Controllers/V1/AdminVerificationsController.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; using Asp.Versioning; +using Baya.Application.Features.Verification.Commands.ApproveVerification; +using Baya.Application.Features.Verification.Commands.RejectVerification; using Baya.Application.Features.Verification.Commands.ReviewStep; using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials; using Baya.Application.Features.Verification.Commands.SuspendVerification; @@ -46,8 +48,26 @@ public sealed class AdminVerificationsController(ISender sender) : BaseControlle public async Task Suspend(long nurseVerificationId, AdminSuspendVerificationCommand command, CancellationToken cancellationToken) => OperationResult(await sender.Send(command with { NurseVerificationId = nurseVerificationId }, cancellationToken)); + // Explicit confirmation once every required step has passed — Finalize already flipped the aggregate + // to approved as a side effect of whichever step completed last; this just re-confirms it (409 if not + // actually fully passed) so the admin UI's whole-verification "Approve" action has a real endpoint. + [HttpPost("{nurseVerificationId}/[action]")] + [ProducesOkApiResponseType] + public async Task Approve(long nurseVerificationId, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AdminApproveVerificationCommand(nurseVerificationId), cancellationToken)); + + // Rejects the whole verification regardless of individual step outcomes (an admin override, not a + // per-step decision). + [HttpPost("{nurseVerificationId}/[action]")] + [ProducesOkApiResponseType] + public async Task Reject(long nurseVerificationId, AdminRejectVerificationBody body, CancellationToken cancellationToken) + => OperationResult(await sender.Send(new AdminRejectVerificationCommand(nurseVerificationId, body.Reason), cancellationToken)); + [HttpPost("scan_expiring")] [ProducesOkApiResponseType] public async Task ScanExpiring(ScanExpiringCredentialsCommand command, CancellationToken cancellationToken) => OperationResult(await sender.Send(command, cancellationToken)); } + +/// The whole-verification reject body (the id comes from the route). +public record AdminRejectVerificationBody(string Reason); diff --git a/server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs index 5ac03a9..5c48d56 100644 --- a/server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs +++ b/server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs @@ -66,6 +66,10 @@ internal sealed class GetCancellationPolicyPreviewQueryHandler( var channelContext = await unitOfWork.RefundRepository.GetRefundContextAsync(request.BookingId, cancellationToken); var channel = channelContext?.GatewayType == PaymentGatewayType.Bnpl ? RefundChannel.BnplRevert : RefundChannel.PspCard; + // Whole booking vs remaining-sessions scope: whole when every session is still un-started. + var appliesTo = sessions.Count > 0 && sessions.All(s => s.Refundable) ? "whole_booking" : "remaining_sessions"; + var leadTimeLabel = hoursBefore >= 24 ? "gt_24h" : hoursBefore >= 0 ? "lt_24h" : "started"; + var dto = new CancellationPolicyPreviewDto( booking.Id, cancellable, @@ -77,8 +81,8 @@ internal sealed class GetCancellationPolicyPreviewQueryHandler( Str(refundableBase), Str(platformFeeRefunded), Str(nursePayoutRefunded), - CancellationActor.Customer, - hoursBefore >= 24 ? "at_least_24h" : "less_than_24h", + appliesTo, + leadTimeLabel, channel, // The BNPL ~7–10-business-day customer ETA is stamped on the actual refund; the preview leaves it null. null, diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.Handler.cs new file mode 100644 index 0000000..54c3c12 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.Handler.cs @@ -0,0 +1,63 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ApproveVerification; + +internal sealed class AdminApproveVerificationCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IAuditLogger auditLogger, + ICacheService cache, + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) + : IRequestHandler> +{ + public async ValueTask> Handle(AdminApproveVerificationCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken); + if (verification is null) + return OperationResult.NotFoundResult("Verification not found."); + + var hasSteps = verification.Steps.Count > 0; + var allPassed = hasSteps && verification.Steps.All(s => s.Status == VerificationStepStatus.Passed); + if (!allPassed) + return OperationResult.ConflictResult("Not every required step has passed yet."); + + var now = dateTimeProvider.UtcNow; + var nurseId = verification.NurseId; + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + verification.ReviewedByAdminId = adminId; + + // Idempotent: re-derives `approved` from the already-passed steps (Finalize already flipped this on + // whichever step completed last); this just records the admin's explicit confirmation. + VerificationAggregator.Finalize(verification, profile, now); + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); + + await unitOfWork.CommitAsync(); + + await auditLogger.WriteAsync( + "nurse_verification", + verification.Id.ToString(), + "approve", + new Dictionary { ["admin_id"] = adminId }, + cancellationToken); + + await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.cs new file mode 100644 index 0000000..6db6af6 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/ApproveVerification/AdminApproveVerificationCommand.cs @@ -0,0 +1,14 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.ApproveVerification; + +/// +/// Explicit admin confirmation that a verification is approved. By the time every required step has +/// passed, has already flipped +/// nurse_verifications.status to approved as a side effect of whichever step completed last +/// (an admin decide or an automated run) — this re-runs that same aggregation (idempotent) and returns a +/// clean conflict if the case isn't actually fully passed yet (a stale client). NurseVerificationId +/// is route-supplied. +/// +public record AdminApproveVerificationCommand(long NurseVerificationId) : IRequest>; diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Handler.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Handler.cs new file mode 100644 index 0000000..d41b372 --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Handler.cs @@ -0,0 +1,63 @@ +#nullable enable +using Baya.Application.Common; +using Baya.Application.Contracts.Audit; +using Baya.Application.Contracts.Common; +using Baya.Application.Contracts.Persistence; +using Baya.Application.Contracts.Search; +using Baya.Application.Models.Common; +using Baya.Domain.Entities.Verification; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RejectVerification; + +internal sealed class AdminRejectVerificationCommandHandler( + ICurrentUser currentUser, + IUnitOfWork unitOfWork, + IAuditLogger auditLogger, + ICacheService cache, + IDateTimeProvider dateTimeProvider, + ISearchIndexMaintainer searchIndex) + : IRequestHandler> +{ + public async ValueTask> Handle(AdminRejectVerificationCommand request, CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } adminId) + return OperationResult.UnauthorizedResult("Not authenticated."); + + var verification = await unitOfWork.VerificationRepository.GetTrackedByIdAsync(request.NurseVerificationId, cancellationToken); + if (verification is null) + return OperationResult.NotFoundResult("Verification not found."); + + var now = dateTimeProvider.UtcNow; + var nurseId = verification.NurseId; + + var profile = await unitOfWork.NurseProfileRepository.GetTrackedByIdAsync(nurseId, cancellationToken); + if (profile is null) + return OperationResult.NotFoundResult("Nurse profile not found."); + + // An explicit admin override, not a per-step derivation — deliberately not routed through + // `VerificationAggregator.Finalize` (it re-derives status purely from step outcomes, which would + // clobber this back to in_review/pending unless a step happens to already be failed). + verification.Status = VerificationStatus.Rejected; + verification.RejectedAt = now; + verification.RejectionReason = request.Reason; + verification.ApprovedAt = null; + verification.ReviewedByAdminId = adminId; + profile.MarkUnverified(); + + await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken); + + await unitOfWork.CommitAsync(); + + await auditLogger.WriteAsync( + "nurse_verification", + verification.Id.ToString(), + "reject", + new Dictionary { ["admin_id"] = adminId, ["reason"] = request.Reason }, + cancellationToken); + + await VerificationCache.InvalidateBadgeAsync(cache, nurseId, cancellationToken); + + return OperationResult.SuccessResult(true); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Validator.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Validator.cs new file mode 100644 index 0000000..7e774cf --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.Validator.cs @@ -0,0 +1,12 @@ +using FluentValidation; + +namespace Baya.Application.Features.Verification.Commands.RejectVerification; + +// NurseVerificationId is route-supplied (set via `command with { ... }`), so it is not validated here. +public sealed class AdminRejectVerificationCommandValidator : AbstractValidator +{ + public AdminRejectVerificationCommandValidator() + { + RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000); + } +} diff --git a/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.cs b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.cs new file mode 100644 index 0000000..00d617d --- /dev/null +++ b/server/src/Core/Baya.Application/Features/Verification/Commands/RejectVerification/AdminRejectVerificationCommand.cs @@ -0,0 +1,12 @@ +using Baya.Application.Models.Common; +using Mediator; + +namespace Baya.Application.Features.Verification.Commands.RejectVerification; + +/// +/// Rejects the whole verification regardless of individual step outcomes (e.g. a fraud red flag an admin +/// wants to act on immediately, not one step at a time) — a distinct action from deciding a single step, +/// mirroring the same terminal rejected path VerificationAggregator already takes when a step +/// fails. NurseVerificationId is route-supplied. +/// +public record AdminRejectVerificationCommand(long NurseVerificationId, string Reason) : IRequest>; diff --git a/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs b/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs index c013bdd..d1edad5 100644 --- a/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs +++ b/server/src/Core/Baya.Application/Models/Search/NurseSearchResultDto.cs @@ -1,10 +1,14 @@ namespace Baya.Application.Models.Search; /// -/// One family-facing search hit — a bookable variant matched in a covered area. Price is IRR Rials -/// as a digit string (BIGINT on the wire, never a float); DistrictId == null means the nurse covers -/// the whole city. NurseName/AvatarUrl are the card's identity (denormalized on the index); -/// DistanceKm is optional — null until the index carries the searched coordinate. +/// One family-facing search hit — **one card per nurse**, not one row per matching variant (a nurse +/// matching several variants/areas for the same query previously surfaced as several hits — see phase 10 +/// of mvp/blocker-phases). VariantId/Price/PriceUnit describe the nurse's +/// cheapest matching variant (the card's "from X" price); MatchingServiceCount is how many distinct +/// variants of hers matched, so the UI can disclose "+N more" rather than implying she has only one. Price +/// is IRR Rials as a digit string (BIGINT on the wire, never a float); DistrictId == null means the +/// nurse covers the whole city. NurseName/AvatarUrl are the card's identity (denormalized on +/// the index); DistanceKm is optional — null until the index carries the searched coordinate. /// public record NurseSearchResultDto( long VariantId, @@ -20,4 +24,5 @@ public record NurseSearchResultDto( long? DistrictId, string NurseName, string AvatarUrl, - double? DistanceKm); + double? DistanceKm, + int MatchingServiceCount); diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs index d5e10d6..da745f6 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs @@ -12,8 +12,10 @@ namespace Baya.Infrastructure.Persistence.Services.Search; /// The MVP backend — the real, production search over the maintained /// nurse_search_index. It reads only is_searchable = 1 rows (an unverified, suspended, /// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price -/// filters and the rating sort, and paginates. Served from the covering search index; a later -/// ElasticNurseSearch replaces this class behind the same interface with no caller changes. +/// filters, groups the matches down to one card per nurse (the index is one row per bookable +/// variant × covered area, so a nurse with several matching variants/areas would otherwise surface as +/// several hits — phase 10), and paginates over that grouped set. Served from the covering search index; a +/// later ElasticNurseSearch replaces this class behind the same interface with no caller changes. /// internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch { @@ -42,29 +44,68 @@ internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch if (!string.IsNullOrWhiteSpace(criteria.PriceUnit)) query = query.Where(r => r.PriceUnit == criteria.PriceUnit); - var total = await query.CountAsync(cancellationToken); + // Group the filtered rows down to one entry per nurse — page size must be nurse-counted, not + // row-counted, so both the count and the Skip/Take run over this grouped set, not the raw rows. + // Plain aggregates only, projected to an anonymous type (a named record constructor here doesn't + // translate reliably) — the per-nurse distinct-variant count is derived from `candidates` below + // instead, which already carries every matching row for the paged nurses. + var grouped = query.GroupBy(r => r.NurseId).Select(g => new + { + NurseId = g.Key, + MinPrice = g.Min(r => r.Price), + AverageRating = g.Max(r => r.AverageRating), + TotalReviews = g.Max(r => r.TotalReviews), + }); - var rows = await query - // Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic. - .OrderByDescending(r => r.AverageRating) - .ThenByDescending(r => r.TotalReviews) - .ThenBy(r => r.NurseId) - .ThenBy(r => r.VariantId) + var total = await grouped.CountAsync(cancellationToken); + + // Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic. + var page = await grouped + .OrderByDescending(g => g.AverageRating) + .ThenByDescending(g => g.TotalReviews) + .ThenBy(g => g.NurseId) .Skip((criteria.Page - 1) * criteria.PageSize) .Take(criteria.PageSize) + .ToListAsync(cancellationToken); + + if (page.Count == 0) + return new PagedResult([], total, criteria.Page, criteria.PageSize); + + // One query for the card's representative row per paged nurse — her cheapest matching variant + // (VariantId tiebreaks a price tie so the pick is deterministic). Fetches every matching row for + // just this page's nurses (a handful of variants/areas each at MVP scale), then picks in memory — + // avoids relying on "OrderBy().First() inside a GroupBy projection", which EF/SQL Server doesn't + // translate as reliably as a plain aggregate GroupBy. + var pageNurseIds = page.Select(g => g.NurseId).ToList(); + var minPriceByNurse = page.ToDictionary(g => g.NurseId, g => g.MinPrice); + + var candidates = await query + .Where(r => pageNurseIds.Contains(r.NurseId)) .Select(r => new Row( r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender, r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId, r.NurseName, r.AvatarUrl)) .ToListAsync(cancellationToken); + var byNurse = candidates.GroupBy(r => r.NurseId).ToDictionary(g => g.Key, g => g.ToList()); + var representativeByNurse = byNurse.ToDictionary( + kv => kv.Key, + kv => kv.Value.Where(r => r.Price == minPriceByNurse[kv.Key]).OrderBy(r => r.VariantId).First()); + // Distinct variants, not rows — the same variant can carry more than one matched area row (e.g. a + // district-specific row plus a whole-city row), which must not inflate "N matching services". + var matchCountByNurse = byNurse.ToDictionary(kv => kv.Key, kv => kv.Value.Select(r => r.VariantId).Distinct().Count()); + // Format price to a digit string in memory (no long.ToString translation required in SQL). // DistanceKm is null: the covering index carries no coordinate, so distance is not derivable here. - var items = rows.Select(r => new NurseSearchResultDto( - r.VariantId, r.NurseId, r.ServiceCategoryId, - r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender, - r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId, - r.NurseName, r.AvatarUrl, null)).ToList(); + var items = pageNurseIds.Select(nurseId => + { + var r = representativeByNurse[nurseId]; + return new NurseSearchResultDto( + r.VariantId, r.NurseId, r.ServiceCategoryId, + r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender, + r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId, + r.NurseName, r.AvatarUrl, null, matchCountByNurse[nurseId]); + }).ToList(); return new PagedResult(items, total, criteria.Page, criteria.PageSize); } diff --git a/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs index a244e41..d4320c4 100644 --- a/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs +++ b/server/src/Tests/Baya.Test.Foundation/Search/SearchIndexTests.cs @@ -1,4 +1,5 @@ using Baya.Application.Models.Search; +using Baya.Domain.Entities.Catalog; using Baya.Domain.Entities.Verification; namespace Baya.Test.Foundation.Search; @@ -118,6 +119,43 @@ public sealed class SearchIndexTests Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray()); } + [Fact] + public void SameNurseMultipleMatchingVariants_CollapsesToOneCardWithCheapestPriceAndMatchCount() + { + using var host = new SearchIndexTestHost(); + var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 2000, host.District3Id); + Project(host, nurse); + + // A second, cheaper variant in the same category — the maintainer fans it out across the nurse's + // existing areas, so it also becomes a match for the same search. + var cheaper = new NurseServiceVariant + { + NurseId = nurse.NurseId, + ServiceCategoryId = host.CategoryId, + Price = 1000, + PriceUnit = "per_day", + SessionCount = null, + DisplayName = "cheaper", + OptionSetHash = $"hash-{nurse.NurseId}-2", + IsActive = true + }; + host.Db.Set().Add(cheaper); + host.Db.SaveChanges(); + host.Maintainer.ReindexVariantAsync(cheaper, default).GetAwaiter().GetResult(); + host.Db.SaveChanges(); + + // Two searchable rows for the one nurse, but the search must collapse them to one card. + Assert.Equal(2, host.SearchableRowCount()); + + var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result; + + Assert.Single(page.Items); + Assert.Equal(1, page.Total); + Assert.Equal(nurse.NurseId, page.Items[0].NurseId); + Assert.Equal("1000", page.Items[0].Price); // the cheaper matching variant is the representative + Assert.Equal(2, page.Items[0].MatchingServiceCount); + } + [Fact] public void SuspendingANurseRemovesThemFromSearch() {