frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -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 }),
});
},
};