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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user