frontend phase 15
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user