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()
{