frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -1,20 +1,112 @@
import { clientFetch } from '@/lib/api/client';
import { ApiError } from '@/lib/api/errors';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types';
import { ADMIN_QUEUE_PAGE_SIZE } from '../constants';
import type {
AdminVerificationCase,
AdminVerificationQueueFilters,
AdminVerificationQueueItem,
AdminVerificationStepDetail,
CredentialDetailsInput,
DecideStepInput,
DecideStepResult,
DocumentConfirmedResult,
IdentityKycInput,
NurseCredential,
RunStepResult,
SignedDocumentUrl,
TrustBadge,
UploadUrlResult,
VerificationAggregateStatus,
VerificationApi,
VerificationDocument,
VerificationStatus,
VerificationStepStatus,
} from '../types';
const BASE = '/api/v1/nurse_verification';
const NURSES_BASE = '/api/v1/nurses';
const ADMIN_BASE = '/api/v1/admin_verifications';
/** `AdminPendingStepDto` — one **row per step** awaiting attention (not per nurse); documents carry signed GET URLs. */
interface AdminPendingStepWire {
nurseVerificationId: number;
nurseId: number;
nurseName: string;
stepId: number;
stepCode: string;
stepDisplayName: string;
status: VerificationStepStatus;
submittedAt: string | null;
documents: VerificationDocument[];
}
/** `AdminStepDetailDto` — note the id lives under `stepId` on the wire (mapped to `id`). */
interface AdminStepDetailWire {
stepId: number;
code: string;
displayName: string;
status: VerificationStepStatus;
isAutomated: boolean;
expiresAt: string | null;
failureReason: string | null;
documents: VerificationDocument[];
}
/** `AdminVerificationDetailDto`. */
interface AdminVerificationDetailWire {
nurseVerificationId: number;
nurseId: number;
identityName: string;
status: VerificationAggregateStatus;
steps: AdminStepDetailWire[];
credentials: NurseCredential[];
}
/**
* REQ-034: the queue DTO is **per step**, so we fold rows to one item per nurse for the queue UI. This is
* lossy — the per-step page carries no whole-nurse aggregate (`stepsPassed`/`stepsTotal`/expiry), and a
* nurse's steps can straddle page boundaries — which is why a nurse-level queue endpoint is filed. We map
* what the row gives (nurse identity, the step as `nextPendingStepCode`, `submittedAt`) and leave the
* unavailable aggregate fields at neutral defaults.
*/
function foldQueueRows(rows: AdminPendingStepWire[]): AdminVerificationQueueItem[] {
const byNurse = new Map<number, AdminVerificationQueueItem>();
for (const row of rows) {
const existing = byNurse.get(row.nurseVerificationId);
if (existing) {
if (row.submittedAt && (existing.submittedAt == null || row.submittedAt < existing.submittedAt)) {
existing.submittedAt = row.submittedAt;
}
continue;
}
byNurse.set(row.nurseVerificationId, {
nurseVerificationId: row.nurseVerificationId,
nurseId: row.nurseId,
nurseName: row.nurseName,
status: 'in_review',
stepsPassed: 0,
stepsTotal: 0,
nextPendingStepCode: row.stepCode,
submittedAt: row.submittedAt,
hasExpiringCredential: false,
});
}
return Array.from(byNurse.values());
}
function toStepDetail(wire: AdminStepDetailWire): AdminVerificationStepDetail {
return {
id: wire.stepId,
code: wire.code,
displayName: wire.displayName,
status: wire.status,
isAutomated: wire.isAutomated,
expiresAt: wire.expiresAt,
failureReason: wire.failureReason,
documents: wire.documents,
};
}
/**
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
@@ -120,4 +212,62 @@ export const verificationClientApi: VerificationApi = {
getTrustBadge: async (nurseId) =>
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
listVerificationQueue: async (
filters: AdminVerificationQueueFilters,
params: PageParams,
): Promise<Paginated<AdminVerificationQueueItem>> => {
const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status);
query.set('page', String(params.page ?? 1));
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<AdminPendingStepWire>>>(`${ADMIN_BASE}?${query.toString()}`),
);
// REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint
// exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact.
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
},
getVerificationCase: async (nurseVerificationId: number): Promise<AdminVerificationCase> => {
const wire = unwrap(
await clientFetch<ApiEnvelope<AdminVerificationDetailWire>>(`${ADMIN_BASE}/${nurseVerificationId}`),
);
return {
nurseVerificationId: wire.nurseVerificationId,
nurseId: wire.nurseId,
identityName: wire.identityName,
status: wire.status,
steps: wire.steps.map(toStepDetail),
credentials: wire.credentials,
};
},
// 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`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
// 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).
approveVerification: async (nurseVerificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
},
rejectVerification: async (nurseVerificationId: number, reason: string): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
});
},
};
@@ -1,7 +1,12 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type {
AdminVerificationCase,
AdminVerificationQueueItem,
AdminVerificationStepDetail,
CredentialType,
IdentityKycInput,
NurseCredential,
StepTypeCode,
TrustBadge,
VerificationApi,
@@ -10,7 +15,7 @@ import type {
VerificationStep,
VerificationStepStatus,
} from '../types';
import { NATIONAL_ID_LENGTH } from '../constants';
import { ADMIN_QUEUE_PAGE_SIZE, NATIONAL_ID_LENGTH } from '../constants';
const MOCK_LATENCY_MS = 300;
@@ -73,6 +78,194 @@ function seedSteps(): void {
}));
}
/* --- Admin review queue fixtures + state ---------------------------------------------------------
* A small in-memory review desk: three nurses spanning `pending` / `in_review`, each a full case with
* ordered steps (automated steps `passed`, a manual credential-bearing step `in_review` with a document),
* one nurse carrying an expiring credential. Decisions mutate this state so a human can watch a case move
* through decide → approve/reject and drop off the queue. Timestamps are relative to module-load `Date.now()`.
*/
const CREDENTIAL_BEARING: ReadonlySet<string> = new Set([
'moh_competency_license',
'ino_membership',
'criminal_record',
]);
/** Internal record: the admin case + the queue-only metadata (`nurseName`, `submittedAt`, expiry flag). */
interface AdminCaseRecord extends AdminVerificationCase {
nurseName: string;
submittedAt: string | null;
hasExpiringCredential: boolean;
}
let nextAdminStepId = 1;
let nextCredentialId = 8001;
const adminNowMs = Date.now();
const daysAgo = (n: number): string => new Date(adminNowMs - n * 24 * 60 * 60 * 1000).toISOString();
const daysFromNow = (n: number): string => new Date(adminNowMs + n * 24 * 60 * 60 * 1000).toISOString();
function mkStep(
code: StepTypeCode,
status: VerificationStepStatus,
isAutomated: boolean,
extra: Partial<Pick<AdminVerificationStepDetail, 'expiresAt' | 'failureReason' | 'documents'>> = {},
): AdminVerificationStepDetail {
return {
id: nextAdminStepId++,
code,
displayName: code,
status,
isAutomated,
expiresAt: extra.expiresAt ?? null,
failureReason: extra.failureReason ?? null,
documents: extra.documents ?? [],
};
}
function mkDoc(id: number, originalFileName: string): VerificationDocument {
return {
id,
contentType: 'application/pdf',
fileSizeBytes: 482_000,
originalFileName,
// A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open.
url: `https://mock.balinyaar.local/docs/${id}`,
};
}
function mkCredential(
credentialType: CredentialType,
holderNameSnapshot: string,
issuingAuthority: string,
opts: { issuedAt?: string | null; expiresAt?: string | null } = {},
): NurseCredential {
return {
id: nextCredentialId++,
credentialType,
holderNameSnapshot,
issuingAuthority,
issuedAt: opts.issuedAt ?? null,
expiresAt: opts.expiresAt ?? null,
verificationMethod: 'manual',
};
}
const adminCases: AdminCaseRecord[] = [
{
nurseVerificationId: 501,
nurseId: 101,
identityName: 'مریم رضایی',
nurseName: 'مریم رضایی',
status: 'in_review',
submittedAt: daysAgo(2),
hasExpiringCredential: false,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'in_review', false, { documents: [mkDoc(9001, 'moh-license.pdf')] }),
mkStep('ino_membership', 'pending', false),
mkStep('criminal_record', 'pending', false),
mkStep('bank_account_verification', 'passed', true),
],
credentials: [],
},
{
nurseVerificationId: 502,
nurseId: 102,
identityName: 'زهرا محمدی',
nurseName: 'زهرا محمدی',
status: 'pending',
submittedAt: daysAgo(5),
hasExpiringCredential: true,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'pending', false),
mkStep('ino_membership', 'pending', false),
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(18) }),
mkStep('bank_account_verification', 'passed', true),
],
// A recorded criminal-record credential lapsing soon — drives the `hasExpiringCredential` queue chip.
credentials: [mkCredential('criminal_record', 'زهرا محمدی', 'ناجا', { issuedAt: daysAgo(347), expiresAt: daysFromNow(18) })],
},
{
nurseVerificationId: 503,
nurseId: 103,
identityName: 'علی کریمی',
nurseName: 'علی کریمی',
status: 'in_review',
submittedAt: daysAgo(1),
hasExpiringCredential: false,
steps: [
mkStep('identity_kyc', 'passed', true),
mkStep('shahkar_match', 'passed', true),
mkStep('moh_competency_license', 'passed', false),
mkStep('ino_membership', 'in_review', false, { documents: [mkDoc(9002, 'ino-membership.pdf')] }),
mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(300) }),
mkStep('bank_account_verification', 'passed', true),
],
credentials: [
mkCredential('moh_competency_license', 'علی کریمی', 'وزارت بهداشت', { issuedAt: daysAgo(120) }),
mkCredential('criminal_record', 'علی کریمی', 'ناجا', { issuedAt: daysAgo(65), expiresAt: daysFromNow(300) }),
],
},
];
function findCaseById(nurseVerificationId: number): AdminCaseRecord | undefined {
return adminCases.find((record) => record.nurseVerificationId === nurseVerificationId);
}
function findCaseByStepId(stepId: number): { record: AdminCaseRecord; step: AdminVerificationStepDetail } | undefined {
for (const record of adminCases) {
const step = record.steps.find((candidate) => candidate.id === stepId);
if (step) return { record, step };
}
return undefined;
}
/** The next step needing an admin's eyes: an `in_review` (uploaded, awaiting decision) step first, else a `pending` one. */
function nextPendingCode(steps: AdminVerificationStepDetail[]): string | null {
return (
steps.find((step) => step.status === 'in_review')?.code ??
steps.find((step) => step.status === 'pending')?.code ??
null
);
}
/** Re-aggregate the case status exactly as the server would after a step decision. */
function reaggregateCase(record: AdminCaseRecord): void {
const allPassed = record.steps.every((step) => step.status === 'passed');
const anyInReview = record.steps.some((step) => step.status === 'in_review');
record.status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
}
function toQueueItem(record: AdminCaseRecord): AdminVerificationQueueItem {
return {
nurseVerificationId: record.nurseVerificationId,
nurseId: record.nurseId,
nurseName: record.nurseName,
status: record.status,
stepsPassed: record.steps.filter((step) => step.status === 'passed').length,
stepsTotal: record.steps.length,
nextPendingStepCode: nextPendingCode(record.steps),
submittedAt: record.submittedAt,
hasExpiringCredential: record.hasExpiringCredential,
};
}
/** Return the admin-case view (drops the queue-only metadata; deep-copies so callers can't mutate the store). */
function toCaseView(record: AdminCaseRecord): AdminVerificationCase {
return {
nurseVerificationId: record.nurseVerificationId,
nurseId: record.nurseId,
identityName: record.identityName,
status: record.status,
steps: record.steps.map((step) => ({ ...step, documents: step.documents.map((doc) => ({ ...doc })) })),
credentials: record.credentials.map((credential) => ({ ...credential })),
};
}
/**
* In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end — the
* automated runs (identity/shahkar/bank), the manual document uploads (→ in_review), the structured
@@ -170,6 +363,88 @@ export const verificationMockApi: VerificationApi = {
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
} satisfies TrustBadge;
},
listVerificationQueue: async (filters, params) => {
await sleep(MOCK_LATENCY_MS);
// Default (no status filter) shows the whole desk — both `pending` and `in_review`.
const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem);
const page = params.page ?? 1;
const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE;
const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize };
},
getVerificationCase: async (nurseVerificationId) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
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);
if (!found) throw new ApiError(404, 'Step not found', 'not_found');
const { record, step } = found;
if (input.approve) {
step.status = 'passed';
step.failureReason = null;
let credentialId: number | null = null;
// On approving a credential-bearing step with a credential number, record the (encrypted-at-rest,
// never re-serialized) credential — mirroring the server's `nurse_credentials` write.
if (CREDENTIAL_BEARING.has(step.code) && input.credentialNumber) {
const credential = mkCredential(
step.code as CredentialType,
input.holderName ?? record.identityName,
input.issuingAuthority ?? '',
{ issuedAt: input.issuedAt ?? null, expiresAt: input.expiresAt ?? null },
);
record.credentials.push(credential);
credentialId = credential.id;
}
reaggregateCase(record);
return { stepId, stepStatus: step.status, credentialId };
}
const reason = input.rejectionReason?.trim();
if (!reason) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
step.status = 'failed';
step.failureReason = reason;
reaggregateCase(record);
return { stepId, stepStatus: step.status, credentialId: null };
},
approveVerification: async (nurseVerificationId) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
record.steps = record.steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
// Aggregate → `approved`, which drops it out of the queue's `pending`/`in_review` filter.
record.status = 'approved';
},
rejectVerification: async (nurseVerificationId, reason) => {
await sleep(MOCK_LATENCY_MS);
const record = findCaseById(nurseVerificationId);
if (!record) throw new ApiError(404, 'Verification not found', 'not_found');
if (reason.trim().length === 0) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required');
// Aggregate → `rejected`, dropping it out of the queue.
record.status = 'rejected';
},
};
/**
@@ -25,3 +25,21 @@ export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
/** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */
export const NATIONAL_ID_LENGTH = 10;
/**
* Admin review queue — moderately fresh; a decision invalidates it. `keepPreviousData` + a per-page key
* make paging flicker-free, so a short `staleTime` is enough.
*/
export const ADMIN_QUEUE_STALE_TIME = 20_000;
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;
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
/**
* Approve the whole verification (all required steps pass → aggregate `approved`), removing it from the
* queue. Takes the `nurseVerificationId`; invalidates the queue and that case so both reflect the flip.
*/
export function useApproveVerification() {
const queryClient = useQueryClient();
return useMutation<void, unknown, number>({
mutationFn: (nurseVerificationId) => verificationApi.approveVerification(nurseVerificationId),
onSuccess: (_void, nurseVerificationId) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
},
});
}
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import type { DecideStepInput, DecideStepResult } from '../types';
export interface DecideStepVars {
stepId: number;
/** The case this step belongs to — used to invalidate exactly that case on success. */
nurseVerificationId: number;
input: DecideStepInput;
}
/**
* Approve or reject a manual step. On approving a credential-bearing step the (encrypted) credential is
* recorded server-side and its `credentialId` comes back; a reject requires `input.rejectionReason`. On
* success we invalidate the case (its steps re-render) and the queue (the aggregate/counts may have moved,
* or the case may have dropped off). A domain 4xx (missing reason, holder-name mismatch) surfaces to the
* caller's `onError`.
*/
export function useDecideStep() {
const queryClient = useQueryClient();
return useMutation<DecideStepResult, unknown, DecideStepVars>({
mutationFn: ({ stepId, input }) => verificationApi.decideStep(stepId, input),
onSuccess: (_result, { nurseVerificationId }) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
},
});
}
@@ -0,0 +1,23 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
export interface RejectVerificationVars {
nurseVerificationId: number;
reason: string;
}
/**
* Reject the whole verification (aggregate `rejected`), removing it from the queue. Takes the
* `nurseVerificationId` + a `reason`; invalidates the queue and that case so both reflect the change.
*/
export function useRejectVerification() {
const queryClient = useQueryClient();
return useMutation<void, unknown, RejectVerificationVars>({
mutationFn: ({ nurseVerificationId, reason }) => verificationApi.rejectVerification(nurseVerificationId, reason),
onSuccess: (_void, { nurseVerificationId }) => {
queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() });
queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) });
},
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { ADMIN_CASE_STALE_TIME } from '../constants';
/**
* The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check.
* Keyed per `nurseVerificationId` and only enabled once one is selected (pass `null` from the queue until a
* row is opened). Invalidated on every decide / approve / reject so the case reflects the new step states.
*/
export function useVerificationCase(nurseVerificationId: number | null) {
return useQuery({
queryKey: verificationKeys.adminCase(nurseVerificationId ?? -1),
queryFn: () => verificationApi.getVerificationCase(nurseVerificationId as number),
enabled: nurseVerificationId != null,
staleTime: ADMIN_CASE_STALE_TIME,
});
}
@@ -0,0 +1,21 @@
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,
});
}
@@ -0,0 +1,21 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { ADMIN_QUEUE_PAGE_SIZE, ADMIN_QUEUE_STALE_TIME } from '../constants';
import type { AdminVerificationQueueFilters } from '../types';
/**
* The admin review queue (one item per nurse), filtered by `status` and paginated. `filters` + `params`
* are part of the query key, so switching the status filter or paging reuses cached pages; `keepPreviousData`
* avoids an empty flash while the next page loads. A decision (`useDecideStep` / approve / reject) invalidates
* the queue so the desk re-renders without a manual refetch.
*/
export function useVerificationQueue(filters: AdminVerificationQueueFilters, page: number) {
const params = { page, pageSize: ADMIN_QUEUE_PAGE_SIZE };
return useQuery({
queryKey: verificationKeys.adminQueue(filters, params),
queryFn: () => verificationApi.listVerificationQueue(filters, params),
staleTime: ADMIN_QUEUE_STALE_TIME,
placeholderData: keepPreviousData,
});
}
@@ -5,3 +5,11 @@ export { useRunBankVerification } from './hooks/useRunBankVerification';
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
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';
+24
View File
@@ -1,8 +1,15 @@
import type { AdminVerificationQueueFilters } from './types';
import type { PageParams } from '@/lib/api/types';
/**
* React Query key factory for the verification domain. The nurse's own `status()` is the **single
* cached source** that both B3 (checklist) and B6 (under-review) read — one query, two views. Every
* 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.
*/
export const verificationKeys = {
all: ['verification'] as const,
@@ -15,4 +22,21 @@ export const verificationKeys = {
// The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile.
badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const,
// --- Admin review queue (b6 AdminVerificationsController) ---
admin: () => [...verificationKeys.all, 'admin'] as const,
// The review queue — `filters` + `params` are part of the key, so paging / changing the status filter
// never refetches a page already in cache (React Query hashes keys deterministically).
adminQueues: () => [...verificationKeys.admin(), 'queue'] as const,
adminQueue: (filters: AdminVerificationQueueFilters, params: PageParams) =>
[...verificationKeys.adminQueues(), filters, params] as const,
// 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,
};
+97
View File
@@ -15,6 +15,8 @@
* credential **types** only.
*/
import type { PageParams, Paginated } from '@/lib/api/types';
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
export type VerificationAggregateStatus =
| 'not_started'
@@ -146,6 +148,87 @@ export interface CredentialDetailsInput {
expiresAt?: string | null;
}
/* --- Admin review queue (b6 `AdminVerificationsController`) ---------------------------------------
* The admin-side surface of the same trust engine. The nurse builds the checklist above; an admin
* reviews it here — works the queue, opens a case, decides each manual step, and (via a re-aggregate)
* flips `is_verified`. `credentialNumber` is accepted only as **input** on a decide; it is NEVER on any
* response DTO (encrypted at rest). Signed document URLs are short-lived — fetched on demand, not cached.
*/
/**
* `AdminPendingStepDto`, folded to **one row per nurse** — the review queue item. The b6 endpoint returns
* one row per *step* awaiting attention; the nurse-level aggregate (`stepsPassed`/`stepsTotal`/
* `nextPendingStepCode`/`hasExpiringCredential`) is the shape the queue UI needs (a nurse-level queue
* endpoint is filed as REQ-034; the client maps what it can).
*/
export interface AdminVerificationQueueItem {
nurseVerificationId: number;
nurseId: number;
nurseName: string;
status: VerificationAggregateStatus;
stepsPassed: number;
stepsTotal: number;
nextPendingStepCode: string | null;
submittedAt: string | null;
hasExpiringCredential: boolean;
}
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */
export interface AdminVerificationQueueFilters {
status?: 'pending' | 'in_review';
}
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
export interface AdminVerificationStepDetail {
id: number;
code: string;
displayName: string;
status: VerificationStepStatus;
isAutomated: boolean;
expiresAt: string | null;
failureReason: string | null;
documents: VerificationDocument[];
}
/** `AdminVerificationDetailDto` — the full case: ordered steps, recorded credentials, + the identity name for cross-check. */
export interface AdminVerificationCase {
nurseVerificationId: number;
nurseId: number;
identityName: string;
status: VerificationAggregateStatus;
steps: AdminVerificationStepDetail[];
credentials: NurseCredential[];
}
/**
* Body for `POST admin_verifications/steps/{stepId}/decide`. `rejectionReason` is required when
* `approve=false`; the credential fields (recorded only on approving a credential-bearing step) include
* `credentialNumber` — accepted as **input** here, but never echoed back on any response.
*/
export interface DecideStepInput {
approve: boolean;
rejectionReason?: string;
credentialNumber?: string;
holderName?: string;
issuingAuthority?: string;
issuedAt?: string | null;
expiresAt?: string | null;
verificationSource?: string;
}
/** `ReviewStepResult` — the step's new status after a decision; `credentialId` is set only when one was recorded. */
export interface DecideStepResult {
stepId: number;
stepStatus: VerificationStepStatus;
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.
@@ -170,6 +253,20 @@ export interface VerificationApi {
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
/** The public trust badge for a nurse (types only). */
getTrustBadge(nurseId: number): Promise<TrustBadge>;
// --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<Paginated<AdminVerificationQueueItem>>;
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
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. */
approveVerification(nurseVerificationId: number): Promise<void>;
/** Reject the whole verification (`rejected`), removing it from the queue. */
rejectVerification(nurseVerificationId: number, reason: string): Promise<void>;
}
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */