124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
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`)),
|
|
};
|