frontend phase 5 & backend phase 12
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type {
|
||||
CredentialDetailsInput,
|
||||
DocumentConfirmedResult,
|
||||
IdentityKycInput,
|
||||
RunStepResult,
|
||||
TrustBadge,
|
||||
UploadUrlResult,
|
||||
VerificationApi,
|
||||
VerificationDocument,
|
||||
VerificationStatus,
|
||||
} from '../types';
|
||||
|
||||
const BASE = '/api/v1/nurse_verification';
|
||||
const NURSES_BASE = '/api/v1/nurses';
|
||||
|
||||
/**
|
||||
* Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes
|
||||
* (SHA-256 hex). Runs in the browser only (Web Crypto); the mock skips it.
|
||||
*/
|
||||
async function sha256Hex(file: File): Promise<string> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs the file bytes to the signed object-storage URL with upload **progress**. This is a direct PUT
|
||||
* to `IObjectStorage` (not our API), so it uses XHR — `fetch` can't report upload progress and the
|
||||
* signed URL needs no bearer. The bearer-carrying JSON calls still go through `clientFetch`.
|
||||
*/
|
||||
function putSignedUrl(uploadUrl: string, file: File, onProgress?: (percent: number) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new ApiError(xhr.status, 'Object storage upload failed', 'upload_failed'));
|
||||
xhr.onerror = () => reject(new ApiError(0, 'Network error during upload', 'network_error'));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the VerificationApi seam (b6 contract). Routes are action-style +
|
||||
* snake_case; JSON bodies/fields are camelCase; step ids come from the route. Automated-step failures
|
||||
* come back as `200` with `stepStatus:"failed"` — surfaced, not thrown. Selected once
|
||||
* USE_VERIFICATION_MOCK is false.
|
||||
*
|
||||
* Gap: `submitCredentialDetails` has no nurse-facing b6 endpoint (admin enters the structured fields on
|
||||
* review) — it is filed in `for-backend.md` and no-ops here; the document uploads it accompanies ARE
|
||||
* contract-backed (upload_url → PUT → documents). The mock persists the details for the standalone demo.
|
||||
*/
|
||||
export const verificationClientApi: VerificationApi = {
|
||||
getStatus: async () => unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(BASE)),
|
||||
|
||||
start: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<VerificationStatus>>(`${BASE}/submit`, { method: 'POST' })),
|
||||
|
||||
runIdentityKyc: async ({ nationalId, livenessCaptured }: IdentityKycInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/identity_kyc/run`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nationalId, livenessPayload: livenessCaptured ? 'captured' : null }),
|
||||
}),
|
||||
),
|
||||
|
||||
runShahkarMatch: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/shahkar_match/run`, { method: 'POST' })),
|
||||
|
||||
runBankVerification: async () =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RunStepResult>>(`${BASE}/steps/bank_account_verification/run`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
),
|
||||
|
||||
uploadStepDocument: async (stepId, file, onProgress): Promise<VerificationDocument> => {
|
||||
const { objectStorageKey, uploadUrl } = unwrap(
|
||||
await clientFetch<ApiEnvelope<UploadUrlResult>>(`${BASE}/steps/${stepId}/upload_url`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: file.type, fileName: file.name }),
|
||||
}),
|
||||
);
|
||||
await putSignedUrl(uploadUrl, file, onProgress);
|
||||
const integrityHash = await sha256Hex(file);
|
||||
const confirmed = unwrap(
|
||||
await clientFetch<ApiEnvelope<DocumentConfirmedResult>>(`${BASE}/steps/${stepId}/documents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
objectStorageKey,
|
||||
integrityHash,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: confirmed.documentId,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
url: '',
|
||||
};
|
||||
},
|
||||
|
||||
// No nurse-facing endpoint yet (see gap note above / for-backend.md); the accompanying document
|
||||
// uploads carry the real signal. Kept as a seam method so the mock can persist details unchanged.
|
||||
submitCredentialDetails: async (_input: CredentialDetailsInput) => {},
|
||||
|
||||
getTrustBadge: async (nurseId) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`)),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_VERIFICATION_MOCK } from '../constants';
|
||||
import type { VerificationApi } from '../types';
|
||||
import { verificationClientApi } from './clientApi';
|
||||
import { verificationMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected VerificationApi implementation — the single seam the hooks import. Selection is by
|
||||
* config (USE_VERIFICATION_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const verificationApi: VerificationApi = USE_VERIFICATION_MOCK ? verificationMockApi : verificationClientApi;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type {
|
||||
IdentityKycInput,
|
||||
StepTypeCode,
|
||||
TrustBadge,
|
||||
VerificationApi,
|
||||
VerificationDocument,
|
||||
VerificationStatus,
|
||||
VerificationStep,
|
||||
VerificationStepStatus,
|
||||
} from '../types';
|
||||
import { NATIONAL_ID_LENGTH } from '../constants';
|
||||
|
||||
const MOCK_LATENCY_MS = 300;
|
||||
|
||||
/**
|
||||
* The seeded step-types, in checklist order (mirrors the b6 required step-type seed). Every step is
|
||||
* required, so `steps.length` is the "Y" of the "X از Y" meter. `automated` drives the honest copy and
|
||||
* whether the step runs (`/run`) or waits on a manual document + admin decision.
|
||||
*/
|
||||
const SEED: ReadonlyArray<{ code: StepTypeCode; automated: boolean }> = [
|
||||
{ code: 'identity_kyc', automated: true },
|
||||
{ code: 'shahkar_match', automated: true },
|
||||
{ code: 'moh_competency_license', automated: false },
|
||||
{ code: 'ino_membership', automated: false },
|
||||
{ code: 'criminal_record', automated: false },
|
||||
{ code: 'bank_account_verification', automated: true },
|
||||
];
|
||||
|
||||
// Deterministic test triggers matching the backend b6 mock seams (documented in the mock registry).
|
||||
const KYC_FAIL_NATIONAL_ID = '0000000000'; // MockIdentityKycProvider fail id
|
||||
const SHAHKAR_SHARED_SIM_NATIONAL_ID = '1111111111'; // stands in for the shared-SIM handled failure
|
||||
|
||||
let steps: VerificationStep[] = [];
|
||||
let nextStepId = 1;
|
||||
let nextDocId = 1;
|
||||
let boundNationalId = '';
|
||||
let approvedAt: string | null = null;
|
||||
|
||||
const nationalIdShape = new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`);
|
||||
|
||||
function findStep(code: string): VerificationStep | undefined {
|
||||
return steps.find((step) => step.code === code);
|
||||
}
|
||||
|
||||
function setStepStatus(code: string, status: VerificationStepStatus, failureReason: string | null = null): void {
|
||||
steps = steps.map((step) => (step.code === code ? { ...step, status, failureReason } : step));
|
||||
}
|
||||
|
||||
/** Re-aggregate exactly as the server would: approved only when every step passes; blockers are the rest. */
|
||||
function aggregate(): VerificationStatus {
|
||||
if (steps.length === 0) {
|
||||
return { status: 'not_started', isBookable: false, blockingSteps: [], steps: [] };
|
||||
}
|
||||
const blockingSteps = steps.filter((step) => step.status !== 'passed').map((step) => step.code);
|
||||
const allPassed = blockingSteps.length === 0;
|
||||
const anyInReview = steps.some((step) => step.status === 'in_review');
|
||||
const status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending';
|
||||
return { status, isBookable: allPassed, blockingSteps, steps: steps.map((step) => ({ ...step })) };
|
||||
}
|
||||
|
||||
function seedSteps(): void {
|
||||
if (steps.length > 0) return; // idempotent — never duplicates a step (contract submit semantics)
|
||||
steps = SEED.map(({ code, automated }) => ({
|
||||
id: nextStepId++,
|
||||
code,
|
||||
displayName: code,
|
||||
status: 'not_started',
|
||||
isAutomated: automated,
|
||||
expiresAt: null,
|
||||
failureReason: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* credential details, and the admin-approval simulation (`__mockApproveAll`) that flips the aggregate
|
||||
* to `approved` so a human can watch the trust badge + publish gate unlock. Mirrors the real shapes for
|
||||
* a one-line swap.
|
||||
*/
|
||||
export const verificationMockApi: VerificationApi = {
|
||||
getStatus: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return aggregate();
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
seedSteps();
|
||||
return aggregate();
|
||||
},
|
||||
|
||||
runIdentityKyc: async ({ nationalId }: IdentityKycInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!nationalIdShape.test(nationalId)) {
|
||||
throw new ApiError(400, 'Malformed national id', 'invalid_national_id');
|
||||
}
|
||||
boundNationalId = nationalId;
|
||||
const step = findStep('identity_kyc');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (nationalId === KYC_FAIL_NATIONAL_ID) {
|
||||
setStepStatus('identity_kyc', 'failed', 'kyc_no_match');
|
||||
return { stepId: step.id, stepStatus: 'failed', failureReason: 'kyc_no_match' };
|
||||
}
|
||||
setStepStatus('identity_kyc', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
runShahkarMatch: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const step = findStep('shahkar_match');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (findStep('identity_kyc')?.status !== 'passed') {
|
||||
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
|
||||
}
|
||||
if (boundNationalId === SHAHKAR_SHARED_SIM_NATIONAL_ID) {
|
||||
setStepStatus('shahkar_match', 'failed', 'shared_sim');
|
||||
return { stepId: step.id, stepStatus: 'failed', failureReason: 'shared_sim' };
|
||||
}
|
||||
setStepStatus('shahkar_match', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
runBankVerification: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const step = findStep('bank_account_verification');
|
||||
if (!step) throw new ApiError(400, 'Verification not started', 'not_started');
|
||||
if (findStep('identity_kyc')?.status !== 'passed') {
|
||||
throw new ApiError(400, 'Identity KYC required first', 'kyc_required');
|
||||
}
|
||||
setStepStatus('bank_account_verification', 'passed');
|
||||
return { stepId: step.id, stepStatus: 'passed', failureReason: null };
|
||||
},
|
||||
|
||||
uploadStepDocument: async (stepId, file, onProgress) => {
|
||||
// Simulate the signed-URL PUT progress, then confirm (→ in_review).
|
||||
for (let percent = 0; percent <= 100; percent += 25) {
|
||||
onProgress?.(percent);
|
||||
await sleep(MOCK_LATENCY_MS / 5);
|
||||
}
|
||||
const step = steps.find((candidate) => candidate.id === stepId);
|
||||
if (!step) throw new ApiError(404, 'Step not found', 'not_found');
|
||||
setStepStatus(step.code, 'in_review');
|
||||
return {
|
||||
id: nextDocId++,
|
||||
contentType: file.type,
|
||||
fileSizeBytes: file.size,
|
||||
originalFileName: file.name,
|
||||
url: '',
|
||||
} satisfies VerificationDocument;
|
||||
},
|
||||
|
||||
submitCredentialDetails: async (input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// The server validates the structured registry fields; the mock enforces the one the UI collects.
|
||||
if (input.inoNumber.trim().length === 0) {
|
||||
throw new ApiError(400, 'INO number is required', 'ino_number_required');
|
||||
}
|
||||
},
|
||||
|
||||
getTrustBadge: async (nurseId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const agg = aggregate();
|
||||
return {
|
||||
nurseId,
|
||||
isVerified: agg.status === 'approved' && agg.isBookable,
|
||||
approvedAt,
|
||||
credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [],
|
||||
} satisfies TrustBadge;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Dev-only admin-decision simulation (reachable from B3/B6 while `USE_VERIFICATION_MOCK` is true) — the
|
||||
* b6 admin review queue is deferred to f15, so this stands in to let a human **observe** the state
|
||||
* change: passes every step and flips the aggregate to `approved`. Never shipped against the real
|
||||
* backend (the caller gates it on the mock flag).
|
||||
*/
|
||||
export function __mockApproveAll(): void {
|
||||
approvedAt = '2026-07-09T00:00:00.000Z';
|
||||
steps = steps.map((step) => ({ ...step, status: 'passed', failureReason: null }));
|
||||
}
|
||||
|
||||
/** Dev-only: reject a manual step with a reason, to exercise the rejected-with-reason re-submit path. */
|
||||
export function __mockRejectStep(code: string, reason: string): void {
|
||||
setStepStatus(code, 'failed', reason);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export const USE_VERIFICATION_MOCK = true;
|
||||
|
||||
/**
|
||||
* The checklist is **moderately fresh** — submitting a step changes it, and every mutation invalidates
|
||||
* it, so a short staleTime avoids a refetch when B3 and B6 mount from the same cached query.
|
||||
*/
|
||||
export const VERIFICATION_STATUS_STALE_TIME = 30_000;
|
||||
|
||||
/** The public trust badge changes only on a step decision / suspension / expiry — keep it warm longer. */
|
||||
export const TRUST_BADGE_STALE_TIME = 5 * 60_000; // 5 min
|
||||
export const TRUST_BADGE_GC_TIME = 30 * 60_000; // 30 min
|
||||
|
||||
/** Client-side document guardrails (mirrors the b6 `IObjectStorage` limits). jpg/png/pdf, 5 MB cap. */
|
||||
export const ACCEPTED_DOCUMENT_TYPES: readonly string[] = ['image/jpeg', 'image/png', 'application/pdf'] as const;
|
||||
export const ACCEPTED_IMAGE_TYPES: readonly string[] = ['image/jpeg', 'image/png'] as const;
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { TRUST_BADGE_GC_TIME, TRUST_BADGE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The public trust badge for a nurse (verified state + credential **types**, never numbers). Public
|
||||
* (`[AllowAnonymous]`) and long-lived — it changes only on a step decision / suspension / expiry — so a
|
||||
* generous `staleTime` keeps it warm across the nurse's own profile and (in f6) search + public profile,
|
||||
* which reuse this same query key. Pass `nurseId = undefined` to disable until the id is known.
|
||||
*/
|
||||
export function useNurseTrustBadge(nurseId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.badge(nurseId ?? -1),
|
||||
queryFn: () => verificationApi.getTrustBadge(nurseId as number),
|
||||
enabled: nurseId != null,
|
||||
staleTime: TRUST_BADGE_STALE_TIME,
|
||||
gcTime: TRUST_BADGE_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Runs the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard) for the
|
||||
* `bank_account_verification` step. Requires a verified identity + a primary bank account (added on the
|
||||
* f2 bank screen this checklist deep-links to) — a `400` otherwise, surfaced inline. Invalidates the
|
||||
* status so the checklist reflects the step's new state.
|
||||
*/
|
||||
export function useRunBankVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => verificationApi.runBankVerification(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Opens (or re-opens) verification and seeds the checklist — called from the B3 "start / continue" CTA
|
||||
* when the aggregate is `not_started`. Idempotent server-side. Writes the fresh status straight into
|
||||
* the cache so the checklist renders the seeded steps without a second fetch.
|
||||
*/
|
||||
export function useStartVerification() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => verificationApi.start(),
|
||||
onSuccess: (status) => {
|
||||
queryClient.setQueryData(verificationKeys.status(), status);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { CredentialDetailsInput } from '../types';
|
||||
|
||||
/**
|
||||
* Persists the structured professional-credential details (INO number, specialties, license fields) the
|
||||
* registry needs. The credential **documents** themselves move their steps to `in_review` as they
|
||||
* upload (via `useUploadVerificationDocument`); this finalises B5 by saving the structured metadata,
|
||||
* then invalidates the status so the checklist / B6 reflect the in-review credential steps. A missing
|
||||
* INO number surfaces as `400` inline. (No nurse-facing b6 endpoint accepts these yet — gap filed;
|
||||
* mock-persisted meanwhile.)
|
||||
*/
|
||||
export function useSubmitCredentials() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CredentialDetailsInput) => verificationApi.submitCredentialDetails(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { IdentityKycInput, RunStepResult } from '../types';
|
||||
|
||||
export interface SubmitIdentityResult {
|
||||
identity: RunStepResult;
|
||||
/** Null when identity KYC itself failed — Shahkar requires a verified national id first. */
|
||||
shahkar: RunStepResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the automated identity flow: national-ID + liveness KYC, then — only if that **passes** — the
|
||||
* phone↔national-id Shahkar match the server chains off the bound national id. Both are surfaced so B4
|
||||
* can show each step's outcome (incl. the handled shared-SIM Shahkar failure). Invalidates the status
|
||||
* so B3 reflects the new step states from cache. A malformed national id throws `400`; a vendor
|
||||
* mismatch is a `failed` step in the result (not a throw).
|
||||
*/
|
||||
export function useSubmitIdentity() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: IdentityKycInput): Promise<SubmitIdentityResult> => {
|
||||
const identity = await verificationApi.runIdentityKyc(input);
|
||||
const shahkar = identity.stepStatus === 'passed' ? await verificationApi.runShahkarMatch() : null;
|
||||
return { identity, shahkar };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import type { VerificationDocument } from '../types';
|
||||
|
||||
export interface UploadDocumentVars {
|
||||
stepId: number;
|
||||
file: File;
|
||||
/** Progress callback (0–100) the uploader wires to its progress bar. */
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a manual step's document (url → PUT bytes → confirm) and moves it to `in_review`. Progress
|
||||
* is reported through `vars.onProgress` (React Query can't stream it), so the `<DocumentUpload>`
|
||||
* component owns the bar while the mutation owns the request + cache invalidation — the checklist then
|
||||
* shows the step `in_review` from cache with no manual refetch.
|
||||
*/
|
||||
export function useUploadVerificationDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ stepId, file, onProgress }: UploadDocumentVars): Promise<VerificationDocument> =>
|
||||
verificationApi.uploadStepDocument(stepId, file, onProgress),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: verificationKeys.status() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { VERIFICATION_STATUS_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's own verification checklist + aggregate status. **The single cached source B3 and B6 both
|
||||
* read** — one query, two views (checklist hub / under-review). Every submit/upload/run mutation
|
||||
* invalidates `verificationKeys.status()`, so the checklist re-renders from cache with no manual
|
||||
* refetch. A moderate `staleTime` avoids a refetch when the two screens mount in sequence.
|
||||
*/
|
||||
export function useVerificationStatus() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.status(),
|
||||
queryFn: () => verificationApi.getStatus(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: VERIFICATION_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { useVerificationStatus } from './hooks/useVerificationStatus';
|
||||
export { useStartVerification } from './hooks/useStartVerification';
|
||||
export { useSubmitIdentity } from './hooks/useSubmitIdentity';
|
||||
export { useRunBankVerification } from './hooks/useRunBankVerification';
|
||||
export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument';
|
||||
export { useSubmitCredentials } from './hooks/useSubmitCredentials';
|
||||
export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const verificationKeys = {
|
||||
all: ['verification'] as const,
|
||||
|
||||
// The signed-in nurse's checklist — moderately fresh; every mutation invalidates it.
|
||||
status: () => [...verificationKeys.all, 'status'] as const,
|
||||
|
||||
// A manual step's uploaded documents (metadata only), if a screen ever lists them separately.
|
||||
documents: (stepCode: string) => [...verificationKeys.all, 'documents', stepCode] as const,
|
||||
|
||||
// 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,
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Verification domain — the trust engine's front-end data layer. Shapes mirror the b6 contract
|
||||
* (`dev/contracts/domains/verification.md`) exactly; the wire is **camelCase** and `clientFetch`
|
||||
* unwraps the `ApiResult<T>` envelope, so these are the post-`unwrap()` payloads.
|
||||
*
|
||||
* Load-bearing semantics (see the contract "Key semantics"):
|
||||
* - `VerificationStatus.status` is the **single source of verification truth**; `isBookable` is the
|
||||
* only flag the UI gates on. The client never infers `is_verified`.
|
||||
* - Steps are **data-driven**: render the ordered `steps[]`, mapping each `code`/`status` to a label
|
||||
* + chip. A new step type appearing in the response must render without a code change.
|
||||
* - Automated steps (`isAutomated:true`) run via a `/run` endpoint; manual steps take a document upload
|
||||
* and wait for an admin decision. **Honest copy** keys off `isAutomated` — a manual step is never
|
||||
* presented as an automated authority check.
|
||||
* - Credential **numbers never cross the wire** (encrypted, never serialized); the badge exposes
|
||||
* credential **types** only.
|
||||
*/
|
||||
|
||||
/** The aggregate `nurse_verifications.status` — the single source of verification truth. */
|
||||
export type VerificationAggregateStatus =
|
||||
| 'not_started'
|
||||
| 'pending'
|
||||
| 'in_review'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'suspended';
|
||||
|
||||
/** Per-step `verification_steps.status`. `failed` renders as the rejected (red) chip; `expired` re-gates. */
|
||||
export type VerificationStepStatus =
|
||||
| 'not_started'
|
||||
| 'pending'
|
||||
| 'in_review'
|
||||
| 'passed'
|
||||
| 'failed'
|
||||
| 'expired';
|
||||
|
||||
/** The six seeded, **stable** step-type codes. Labels are i18n keys off the code — never derived from it. */
|
||||
export type StepTypeCode =
|
||||
| 'identity_kyc'
|
||||
| 'shahkar_match'
|
||||
| 'moh_competency_license'
|
||||
| 'ino_membership'
|
||||
| 'criminal_record'
|
||||
| 'bank_account_verification';
|
||||
|
||||
/** The three credential-bearing step types recorded on admin approval. */
|
||||
export type CredentialType = 'moh_competency_license' | 'ino_membership' | 'criminal_record';
|
||||
|
||||
/** How a credential was verified. Today every real credential resolves `manual` (admin review). */
|
||||
export type VerificationMethod = 'manual' | 'portal' | 'api';
|
||||
|
||||
/**
|
||||
* Trust-badge display state — derived client-side, not a wire enum. `verified` when the badge/aggregate
|
||||
* is approved; `expired` when a required credential lapsed (distinct from never-verified); else
|
||||
* `unverified`. The public badge endpoint only carries `isVerified`, so `expired` is computed by the
|
||||
* nurse's own-profile view from its `VerificationStatus`; public consumers (search/f6) see verified/unverified.
|
||||
*/
|
||||
export type BadgeState = 'verified' | 'unverified' | 'expired';
|
||||
|
||||
/** `VerificationStepDto` — one row of the checklist. Every seeded step is required (Y of the "X از Y" meter). */
|
||||
export interface VerificationStep {
|
||||
id: number;
|
||||
code: string;
|
||||
/** Server-provided fallback label; the UI prefers the i18n label keyed off `code`. */
|
||||
displayName: string;
|
||||
status: VerificationStepStatus;
|
||||
isAutomated: boolean;
|
||||
expiresAt: string | null;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** `VerificationStatusDto` — the aggregate + ordered per-step list driving B3/B6. */
|
||||
export interface VerificationStatus {
|
||||
status: VerificationAggregateStatus;
|
||||
isBookable: boolean;
|
||||
/** Step codes still blocking go-live. */
|
||||
blockingSteps: string[];
|
||||
steps: VerificationStep[];
|
||||
}
|
||||
|
||||
/** `UploadUrlResult` — a signed PUT target for a manual step's document. */
|
||||
export interface UploadUrlResult {
|
||||
objectStorageKey: string;
|
||||
uploadUrl: string;
|
||||
}
|
||||
|
||||
/** `DocumentConfirmedResult` — the step's new status after a document is confirmed. */
|
||||
export interface DocumentConfirmedResult {
|
||||
documentId: number;
|
||||
stepStatus: VerificationStepStatus;
|
||||
}
|
||||
|
||||
/** `VerificationDocumentDto` — **metadata only**, never bytes. `url` is a short-lived signed GET URL. */
|
||||
export interface VerificationDocument {
|
||||
id: number;
|
||||
contentType: string;
|
||||
fileSizeBytes: number;
|
||||
originalFileName: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** `RunStepResult` — the outcome of an automated step run; a vendor fail is `stepStatus:"failed"` + reason. */
|
||||
export interface RunStepResult {
|
||||
stepId: number;
|
||||
stepStatus: VerificationStepStatus;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** `NurseCredentialDto` — a recorded credential. `credentialNumber` is **never** present. */
|
||||
export interface NurseCredential {
|
||||
id: number;
|
||||
credentialType: CredentialType;
|
||||
holderNameSnapshot: string;
|
||||
issuingAuthority: string;
|
||||
issuedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
verificationMethod: VerificationMethod;
|
||||
}
|
||||
|
||||
/** `TrustBadgeDto` — the public trust signal. Credential **types** only, never numbers. */
|
||||
export interface TrustBadge {
|
||||
nurseId: number;
|
||||
isVerified: boolean;
|
||||
approvedAt: string | null;
|
||||
credentialTypes: string[];
|
||||
}
|
||||
|
||||
/** Body for the automated identity-KYC run. `livenessCaptured` stands in for the vendor liveness payload. */
|
||||
export interface IdentityKycInput {
|
||||
nationalId: string;
|
||||
livenessCaptured: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured professional-credential details the registry needs (INO number, specialties, license
|
||||
* number, issuing authority, holder name, issue/expiry). **No nurse-facing b6 endpoint accepts these
|
||||
* yet** (admin enters them on review) — the mock persists them and the gap is filed for the backend
|
||||
* (`for-backend.md`). The document uploads themselves are contract-backed (upload_url → documents).
|
||||
*/
|
||||
export interface CredentialDetailsInput {
|
||||
inoNumber: string;
|
||||
specialties: string[];
|
||||
licenseNumber?: string;
|
||||
issuingAuthority?: string;
|
||||
holderName?: string;
|
||||
issuedAt?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export interface VerificationApi {
|
||||
/** The nurse's own checklist + aggregate + blocking summary (a `not_started` empty list, never a 404). */
|
||||
getStatus(): Promise<VerificationStatus>;
|
||||
/** Open (or re-open) verification and seed the checklist. Idempotent. */
|
||||
start(): Promise<VerificationStatus>;
|
||||
/** Automated national-ID + liveness check. A vendor fail is a `failed` status, not a thrown error. */
|
||||
runIdentityKyc(input: IdentityKycInput): Promise<RunStepResult>;
|
||||
/** Automated phone↔national-id Shahkar match (requires identity KYC passed). Shared-SIM is a handled fail. */
|
||||
runShahkarMatch(): Promise<RunStepResult>;
|
||||
/** Automated استعلام شبا IBAN-owner ↔ national-id money-mule guard (requires a primary bank account). */
|
||||
runBankVerification(): Promise<RunStepResult>;
|
||||
/**
|
||||
* Upload a document for a manual step (url → PUT bytes → confirm), moving it to `in_review`. Reports
|
||||
* upload progress (0–100) via `onProgress`. Returns the server's stored **metadata** (never bytes).
|
||||
*/
|
||||
uploadStepDocument(stepId: number, file: File, onProgress?: (percent: number) => void): Promise<VerificationDocument>;
|
||||
/** Persist the structured professional-credential details (gap-filed; mock-persisted for now). */
|
||||
submitCredentialDetails(input: CredentialDetailsInput): Promise<void>;
|
||||
/** The public trust badge for a nurse (types only). */
|
||||
getTrustBadge(nurseId: number): Promise<TrustBadge>;
|
||||
}
|
||||
|
||||
/** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */
|
||||
export const SPECIALTY_PRESETS: readonly string[] = ['elderly', 'icu', 'pediatric', 'post_surgery', 'wound_care'] as const;
|
||||
|
||||
/** Aggregate statuses at which the nurse's services may go live and the trust badge shows verified. */
|
||||
export function isApproved(status: VerificationStatus | undefined): boolean {
|
||||
return status?.status === 'approved' && status.isBookable;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trust-badge state for the nurse's **own** profile, computed from the full status: `expired` when a
|
||||
* required step has lapsed (distinct from never-verified), `verified` when approved, else `unverified`.
|
||||
*/
|
||||
export function ownBadgeState(status: VerificationStatus | undefined): BadgeState {
|
||||
if (!status) return 'unverified';
|
||||
if (status.steps.some((step) => step.status === 'expired')) return 'expired';
|
||||
return isApproved(status) ? 'verified' : 'unverified';
|
||||
}
|
||||
|
||||
/** The public-badge state (search/f6 + public profile) — no `expired` signal on the public payload. */
|
||||
export function publicBadgeState(badge: TrustBadge | undefined): BadgeState {
|
||||
return badge?.isVerified ? 'verified' : 'unverified';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NATIONAL_ID_LENGTH } from './constants';
|
||||
|
||||
/**
|
||||
* Validates an Iranian national id (کد ملی): 10 digits with the official mod-11 checksum. The server
|
||||
* re-validates (contract `400` on a malformed id), but a client check gives an instant field error and
|
||||
* spares a round-trip. Rejects the trivial all-same-digit ids the algorithm otherwise accepts.
|
||||
*/
|
||||
export function isValidNationalId(value: string): boolean {
|
||||
if (!new RegExp(`^\\d{${NATIONAL_ID_LENGTH}}$`).test(value)) return false;
|
||||
if (/^(\d)\1{9}$/.test(value)) return false;
|
||||
|
||||
const digits = value.split('').map(Number);
|
||||
const check = digits[9];
|
||||
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (NATIONAL_ID_LENGTH - index), 0);
|
||||
const remainder = sum % 11;
|
||||
return remainder < 2 ? check === remainder : check === 11 - remainder;
|
||||
}
|
||||
Reference in New Issue
Block a user