Files
baya-monorepo/client/src/services/verification/apis/clientApi.ts
T
2026-08-02 23:12:44 +03:30

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 }),
});
},
};