3 blocker phases
This commit is contained in:
@@ -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 (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TrustBadge state="verified" nurseId={profile.nurseId} />
|
||||
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} nurseId={profile.nurseId} />
|
||||
{profile.inoMembership ? (
|
||||
<Chip
|
||||
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -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 ? (
|
||||
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
|
||||
{step.documents.map((doc) => (
|
||||
<DocumentViewer key={doc.id} document={doc} />
|
||||
<DocumentViewer
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onReload={onReloadDocuments}
|
||||
reloading={reloadingDocuments}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : isManual ? (
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
|
||||
bookingId: 5003,
|
||||
cancellable: true,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
refundPercentageApplied: 1,
|
||||
refundPercentageApplied: 100,
|
||||
feePercentage: 0,
|
||||
refundAmountIrr: '17600000',
|
||||
feeAmountIrr: '0',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('<NurseResultCard/> 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();
|
||||
|
||||
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<TrustBadge state="verified" nurseId={nurse.nurseId} />
|
||||
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{serviceLabel}
|
||||
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
|
||||
|
||||
@@ -24,7 +24,7 @@ const base: RefundSummary = {
|
||||
totalRefundedIrr: '45000000',
|
||||
expectedCustomerRefundEta: null,
|
||||
externalRevertReference: null,
|
||||
refundPercentageApplied: 1,
|
||||
refundPercentageApplied: 100,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
platformFeeRefundedIrr: '5400000',
|
||||
nursePayoutRefundedIrr: '39600000',
|
||||
|
||||
@@ -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('<DocumentViewer/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
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(<DocumentViewer document={DOC} />);
|
||||
it('renders the image from the case-embedded signed url', () => {
|
||||
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading={false} />);
|
||||
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(<DocumentViewer document={DOC} />);
|
||||
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(<DocumentViewer document={DOC} onReload={onReload} reloading={false} />);
|
||||
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(<DocumentViewer document={DOC} />);
|
||||
it('shows a skeleton while the parent case is (re)loading', () => {
|
||||
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading />);
|
||||
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DocumentViewerProps> = ({ document }) => {
|
||||
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ 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<DocumentViewerProps> = ({ document }) =>
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="refresh"
|
||||
onClick={() => signed.refetch()}
|
||||
disabled={signed.isFetching}
|
||||
onClick={onReload}
|
||||
disabled={reloading}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('doc_reload')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{signed.isLoading || signed.isFetching ? (
|
||||
{reloading ? (
|
||||
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
|
||||
) : signed.isError ? (
|
||||
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
||||
{t('doc_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()}>
|
||||
{t('doc_reload')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : signed.data?.url ? (
|
||||
) : document.url ? (
|
||||
isImage ? (
|
||||
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
|
||||
<Box
|
||||
component="img"
|
||||
src={signed.data.url}
|
||||
src={document.url}
|
||||
alt={document.originalFileName ?? String(document.id)}
|
||||
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab>
|
||||
<AppButton variant="outlined" color="primary" endIcon="external" href={document.url} openInNewTab>
|
||||
{t('doc_open_new')}
|
||||
</AppButton>
|
||||
)
|
||||
|
||||
@@ -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<CancellationPolicyPreview, 'refundableSessionIds'>;
|
||||
|
||||
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<ApiEnvelope<CancellationPolicyPreview>>(
|
||||
`${BOOKINGS}/${bookingId}/cancellation_policy`,
|
||||
toPreview(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CancellationPolicyPreviewWire>>(
|
||||
`${BOOKINGS}/${bookingId}/cancellation_policy`,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -99,10 +109,16 @@ export const refundsClientApi: RefundsApi = {
|
||||
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${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<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
|
||||
return wire.map(toSummary);
|
||||
try {
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<number, AdminRefundResult> = {};
|
||||
const adminInitiateAttempts: Record<number, number> = {};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<number, { nurse: SeedNurse; cheapest: SeedVariant; matchCount: number }>();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SignedDocumentUrl> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<SignedDocumentUrl>>(`${ADMIN_BASE}/documents/${documentId}/url`)),
|
||||
|
||||
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<DecideStepResult>>(`${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<void> => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<AdminVerificationQueuePage>;
|
||||
/** 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<AdminVerificationCase>;
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
|
||||
getDocumentSignedUrl(documentId: number): Promise<SignedDocumentUrl>;
|
||||
/** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
|
||||
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
|
||||
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */
|
||||
|
||||
Reference in New Issue
Block a user