273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { clientFetch } from '@/lib/api/client';
|
|
import { ApiError } from '@/lib/api/errors';
|
|
import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types';
|
|
import { ADMIN_QUEUE_PAGE_SIZE } from '../constants';
|
|
import type {
|
|
AdminVerificationCase,
|
|
AdminVerificationQueueFilters,
|
|
AdminVerificationQueueItem,
|
|
AdminVerificationQueuePage,
|
|
AdminVerificationStepDetail,
|
|
CredentialDetailsInput,
|
|
DecideStepInput,
|
|
DecideStepResult,
|
|
DocumentConfirmedResult,
|
|
IdentityKycInput,
|
|
NurseCredential,
|
|
RunStepResult,
|
|
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
|
|
* (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`)),
|
|
|
|
listVerificationQueue: async (
|
|
filters: AdminVerificationQueueFilters,
|
|
params: PageParams,
|
|
): Promise<AdminVerificationQueuePage> => {
|
|
const query = new URLSearchParams();
|
|
if (filters.status) query.set('status', filters.status);
|
|
// REQ-062: proposed `q` search param — the server ignores it today (no-op, never a 400) until the
|
|
// endpoint gains the filter; the client sends it so the swap is a no-op once it lands.
|
|
if (filters.search) query.set('q', filters.search);
|
|
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.
|
|
// REQ-062: `counts` stays undefined on the real path — the queue screen renders the status tabs
|
|
// without badge counts until the endpoint serves the whole-desk totals.
|
|
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,
|
|
};
|
|
},
|
|
|
|
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
|
|
unwrap(
|
|
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(input),
|
|
}),
|
|
),
|
|
|
|
// Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/
|
|
// Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct
|
|
// admin override, not a per-step decision.
|
|
approveVerification: async (nurseVerificationId: number): Promise<void> => {
|
|
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
|
|
},
|
|
|
|
rejectVerification: async (nurseVerificationId: number, reason: string): Promise<void> => {
|
|
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
},
|
|
};
|