From 24fb51de23b78bf3d94932c1111364c20912e784 Mon Sep 17 00:00:00 2001 From: mihretu Date: Mon, 7 Sep 2026 08:12:12 +0000 Subject: [PATCH 1/3] fix(exam): reflect authoritative exam state and lock engine-graded results - COC queue status cell shows the server-derived exam state (registered, present, sat, passed, failed) instead of the lagging application status. - Portal examStageFor prefers the server's examState so portal and back office never disagree; NOT_SITTING stage added. - Exam roster shows each candidate's result and lock; Regrade hidden once a result exists. - Record Result modal: only unmarked candidates, empty score boxes (no silent zeros), reason required, backend refusals translated. - Result page: auto-graded marks read-only, per-applicant publish. - Exam page: session window fields, Add Question menu (bank / Excel / scratch), wait metrics panel; PUBLISHED exam status removed. - Portal: exam window shown, early launch and eligibility refusals explained. Co-Authored-By: Claude Fable 5.1 --- .../src/app/features/exam/api/exam-api.ts | 52 +++++ .../ExamCandidatesPanel/columns.tsx | 52 ++++- .../ExamQuestionCreateModal.tsx | 197 ++++++++++++++++++ .../ExamQuestionImportModal.tsx | 174 ++++++++++++++++ .../QuestionBankPickerModal.tsx | 138 ++++++++++++ .../components/ExamQuestionActions/errors.ts | 60 ++++++ .../components/ExamQuestionActions/index.ts | 4 + .../exam/components/ExamWaitMetricsPanel.tsx | 67 ++++++ .../features/exam/pages/ExamDetailPage.tsx | 71 ++++++- .../features/exam/pages/ExamPage/columns.tsx | 14 +- .../features/exam/pages/ExamPage/index.tsx | 53 ++++- .../exam/pages/ExamPage/validation.spec.ts | 32 +++ .../exam/pages/ExamPage/validation.ts | 9 + .../src/app/features/exam/types/exam.ts | 125 ++++++++++- .../pages/LicenseQueuePage/columns.tsx | 30 +++ .../src/app/features/result/api/result-api.ts | 11 + .../components/RecordResultModal/columns.tsx | 17 +- .../components/RecordResultModal/index.tsx | 101 ++++++--- .../result/pages/ResultPage/actions.tsx | 51 +++-- .../result/pages/ResultPage/index.tsx | 59 +++++- .../src/app/features/result/types/result.ts | 7 + apps/backoffice/src/app/i18n/locales/am.ts | 133 +++++++++++- apps/backoffice/src/app/i18n/locales/en.ts | 133 +++++++++++- .../certificates/pages/CertificatesPage.tsx | 1 + .../components/ExamInstructions.tsx | 25 ++- .../exam-attempt/hooks/useExamAttempt.ts | 15 +- .../exam-attempt/types/exam-attempt.ts | 3 + .../app/features/exams/exam-window.spec.ts | 39 ++++ .../src/app/features/exams/exam-window.ts | 84 ++++++++ .../exams/pages/ExamsPage/columns.tsx | 37 +++- .../features/exams/pages/ExamsPage/index.tsx | 66 +++++- .../app/features/licensing/exam-stage.spec.ts | 64 ++++++ .../src/app/features/licensing/exam-stage.ts | 30 +++ apps/portal/src/app/i18n/locales/am.ts | 12 +- apps/portal/src/app/i18n/locales/en.ts | 14 +- .../lib/features/licensing/licensing.types.ts | 7 + 36 files changed, 1888 insertions(+), 99 deletions(-) create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionImportModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/QuestionBankPickerModal.tsx create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/errors.ts create mode 100644 apps/backoffice/src/app/features/exam/components/ExamQuestionActions/index.ts create mode 100644 apps/backoffice/src/app/features/exam/components/ExamWaitMetricsPanel.tsx create mode 100644 apps/portal/src/app/features/exams/exam-window.spec.ts create mode 100644 apps/portal/src/app/features/exams/exam-window.ts diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index 4dbe8f713..5543c5c7d 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -13,7 +13,13 @@ import type { ResolveIncidentPayload, RegradeOutcome, GradingSheet, + AddExamQuestionsPayload, + CreateExamQuestionPayload, + ImportExamQuestionsPayload, + QuestionImportReport, + ExamWaitMetrics, } from '../types/exam'; +import type { Question } from '../../question/types/question'; const examApi = baseApi.injectEndpoints({ endpoints: (builder) => ({ @@ -60,6 +66,48 @@ const examApi = baseApi.injectEndpoints({ }), invalidatesTags: ['Api'], }), + // --- Question management from the exam page --------------------------- + /** Append approved bank items to the paper, keeping what is already on it. */ + addExamQuestions: builder.mutation({ + query: ({ examId, questionIds }) => ({ + url: `/exams/${examId}/questions/add`, + method: 'POST', + body: { questionIds }, + }), + invalidatesTags: ['Api'], + }), + /** Author a question under the exam's subject and put it on the paper in one call. */ + createExamQuestion: builder.mutation({ + query: ({ examId, ...body }) => ({ + url: `/exams/${examId}/questions/new`, + method: 'POST', + body, + }), + invalidatesTags: ['Api'], + }), + /** + * Excel import. `dryRun` validates and previews without writing; the real + * import runs the same validation and is all-or-nothing on the server. + */ + importExamQuestions: builder.mutation({ + query: ({ examId, file, dryRun }) => { + const body = new FormData(); + body.append('file', file); + // No Content-Type header: fetch sets it with the multipart boundary. + return { + url: `/exams/${examId}/questions/import?dryRun=${dryRun ? 'true' : 'false'}`, + method: 'POST', + body, + }; + }, + // A dry run changes nothing, so the paper does not need refetching. + invalidatesTags: (_result, error, { dryRun }) => (error || dryRun ? [] : ['Api']), + }), + /** Read-only analytics over the session's own timestamps. */ + getExamWaitMetrics: builder.query({ + query: (examId) => `/exams/${examId}/wait-metrics`, + providesTags: ['Api'], + }), // --- Candidates and attendance (US-EXAM-007/009) --------------------- getExamRegistrations: builder.query({ query: (examId) => `/exams/${examId}/registrations`, @@ -123,6 +171,10 @@ export const { useDeleteExamMutation, useAssignQuestionsMutation, useSelectRandomQuestionsMutation, + useAddExamQuestionsMutation, + useCreateExamQuestionMutation, + useImportExamQuestionsMutation, + useGetExamWaitMetricsQuery, useGetExamRegistrationsQuery, useRecordAttendanceMutation, useGetExamIncidentsQuery, diff --git a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx index 0884a3db6..1b7aa125c 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamCandidatesPanel/columns.tsx @@ -1,5 +1,5 @@ -import { ActionIcon, Badge, Menu, Text } from '@mantine/core'; -import { IconDotsVertical, IconRefresh, IconUserCheck } from '@tabler/icons-react'; +import { ActionIcon, Badge, Group, Menu, Text, Tooltip } from '@mantine/core'; +import { IconDotsVertical, IconLock, IconRefresh, IconUserCheck } from '@tabler/icons-react'; import type { TFunction } from 'i18next'; import type { AdvancedColumn } from '@ema-platform/ui'; import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth'; @@ -78,13 +78,59 @@ export function examCandidateColumns( ), }, + { + // What the paper came to, if it has been marked — so the invigilator + // and the marking officer see at a glance whose result already exists + // (and whether the engine produced it, in which case it is locked). + header: t('exam.candidates.result'), + cell: ({ row }) => { + const result = row.original.result; + if (!result) { + return ( + + {t('exam.candidates.noResult')} + + ); + } + const outcome = t(`exam.candidates.outcome.${result.status}`); + const review = t(`result.review.${result.reviewStatus}`, result.reviewStatus); + return ( + + + : undefined} + > + {outcome} · {result.totalScore} + + + {review} + + + + ); + }, + }, { header: '', label: t('exam.candidates.record'), align: 'right', cell: ({ row }) => { const attemptStatus = row.original.attempt?.status; - const canRegrade = attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED'; + // Regrading creates a result; once one exists the API refuses + // (result_already_recorded), so the action is not offered. + const canRegrade = + (attemptStatus === 'SUBMITTED' || attemptStatus === 'EXPIRED') && + !row.original.result; return ( diff --git a/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx new file mode 100644 index 000000000..bf06295a7 --- /dev/null +++ b/apps/backoffice/src/app/features/exam/components/ExamQuestionActions/ExamQuestionCreateModal.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ActionIcon, + Button, + Checkbox, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core'; +import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { ModalFooter, notify } from '@ema-platform/ui'; +import { extractErrorMessage } from '@ema-platform/api'; +import { useCreateExamQuestionMutation } from '../../api/exam-api'; +import type { Exam, QuestionForm } from '../../types/exam'; +import { describeExamQuestionError } from './errors'; + +type DraftOption = { textEn: string; textAm: string; isCorrect: boolean }; + +const BLANK: DraftOption[] = [ + { textEn: '', textAm: '', isCorrect: false }, + { textEn: '', textAm: '', isCorrect: false }, +]; + +/** + * "Add new question from scratch": authored under this exam's subject and + * put on its paper in one call — the officer never leaves the exam or copies + * an id. The item is a real bank question (options, answer key), reusable on + * a later paper. + */ +export function ExamQuestionCreateModal({ + exam, + opened, + onClose, +}: { + exam: Exam; + opened: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [createQuestion, { isLoading }] = useCreateExamQuestionMutation(); + const [titleEn, setTitleEn] = useState(''); + const [titleAm, setTitleAm] = useState(''); + const [form, setForm] = useState(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE'); + const [points, setPoints] = useState(1); + const [options, setOptions] = useState(BLANK); + + const reset = () => { + setTitleEn(''); + setTitleAm(''); + setForm(exam.form === 'ESSAY' ? 'ESSAY' : 'CHOICE'); + setPoints(1); + setOptions(BLANK); + }; + const close = () => { + reset(); + onClose(); + }; + + const updateOption = (index: number, patch: Partial) => + setOptions((current) => current.map((o, i) => (i === index ? { ...o, ...patch } : o))); + + // A mixed (BOTH) paper takes either form; otherwise the question must match. + const formOptions = (exam.form === 'BOTH' ? ['ESSAY', 'CHOICE'] : [exam.form]).map((value) => ({ + value, + label: t(`exam.formType.${value}`), + })); + + const submit = async () => { + if (!titleEn.trim() || !form || !(points > 0)) { + notify.error(t('exam.newQuestion.fillRequired')); + return; + } + if (form === 'CHOICE') { + if (options.length < 2) return void notify.error(t('exam.newQuestion.needTwo')); + if (!options.some((o) => o.isCorrect)) return void notify.error(t('exam.newQuestion.needCorrect')); + if (options.some((o) => !o.textEn.trim())) return void notify.error(t('exam.newQuestion.textRequired')); + } + try { + await createQuestion({ + examId: exam.id, + // The API requires Amharic; it falls back to the English text server-side + // as well, but sending it explicitly keeps the request self-describing. + title: { en: titleEn.trim(), am: titleAm.trim() || titleEn.trim() }, + form, + points, + options: + form === 'CHOICE' + ? options.map((o) => ({ + text: { en: o.textEn.trim(), am: o.textAm.trim() || o.textEn.trim() }, + isCorrect: o.isCorrect, + })) + : undefined, + }).unwrap(); + notify.success(t('exam.newQuestion.created')); + close(); + } catch (error) { + notify.error(describeExamQuestionError(t, extractErrorMessage(error, t('exam.error')))); + } + }; + + return ( + + + {t('exam.newQuestion.hint')} + setTitleEn(e.currentTarget.value)} + size="sm" + required + /> + setTitleAm(e.currentTarget.value)} + size="sm" + /> + + setPosition((v as BiometricPosition) ?? 'RIGHT_THUMB')} + w={180} + /> + + + + ) : simulateEnabled ? ( <> } mb="sm" variant="light"> - No scanner is wired yet — this simulates a capture so the rest of the flow can be tested. + No scanner detected — this simulates a capture so the rest of the flow can be tested. setPosition((v as BiometricPosition) ?? 'RIGHT_THUMB')} + w={180} + /> + )} setDeviceId(e.currentTarget.value)} w={180} /> - - ) : ( - Not enrolled - )} + + {m.value === 'FINGERPRINT' + ? `${(enrollments ?? []).filter((e) => e.modality === 'FINGERPRINT').length} finger(s) enrolled` + : hasActive(m.value) ? 'Enrolled' : 'Not enrolled'} + ))} + {/* + One row per capture, not one per modality: a profile can hold + up to ten live FINGERPRINT rows (one per finger) plus one + FACE row, so revoke has to target this specific row's id — + never "the" FINGERPRINT enrollment, which no longer exists + as a singular thing. + */} {(enrollments ?? []).map((e) => ( - - {e.modality} captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''} - + + + {e.modality} + {e.position && e.position !== 'UNSPECIFIED' + ? ` (${FINGER_POSITIONS.find((f) => f.value === e.position)?.label ?? e.position})` + : ''}{' '} + captured {showDate(e.enrolledAt)}{e.deviceId ? ` · ${e.deviceId}` : ''} + + + ))} + {(enrollments ?? []).length === 0 && ( + Nothing captured yet. + )} )} diff --git a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts index d70322672..e3e1b0871 100644 --- a/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts +++ b/libs/api/src/lib/features/biometric-enrollment/biometric-enrollment.types.ts @@ -1,10 +1,25 @@ export type BiometricModality = 'FINGERPRINT' | 'FACE'; export type BiometricEnrollmentStatus = 'ACTIVE' | 'REVOKED'; +/** Which finger a FINGERPRINT template belongs to; UNSPECIFIED for FACE or an untracked capture. */ +export type BiometricPosition = + | 'RIGHT_THUMB' + | 'RIGHT_INDEX' + | 'RIGHT_MIDDLE' + | 'RIGHT_RING' + | 'RIGHT_LITTLE' + | 'LEFT_THUMB' + | 'LEFT_INDEX' + | 'LEFT_MIDDLE' + | 'LEFT_RING' + | 'LEFT_LITTLE' + | 'UNSPECIFIED'; + export interface BiometricEnrollment { id: string; profileId: string; modality: BiometricModality; + position: BiometricPosition | null; templateFormat: string; qualityScore: number | null; deviceId: string | null; @@ -22,6 +37,8 @@ export interface BiometricEnrollment { export interface EnrollBiometric { profileId: string; modality: BiometricModality; + /** Required in practice for FINGERPRINT captures; a real scanner always knows which finger it read. */ + position?: BiometricPosition; /** Vendor SDK template, base64. Never the raw scan image. */ template: string; templateFormat: string; diff --git a/libs/api/src/lib/features/biometric-enrollment/index.ts b/libs/api/src/lib/features/biometric-enrollment/index.ts index f824d5e0a..b5ab179a5 100644 --- a/libs/api/src/lib/features/biometric-enrollment/index.ts +++ b/libs/api/src/lib/features/biometric-enrollment/index.ts @@ -1,2 +1,3 @@ export * from './biometric-enrollment.types'; export * from './biometric-enrollment-api'; +export * from './mantra-capture-agent'; diff --git a/libs/api/src/lib/features/biometric-enrollment/mantra-capture-agent.ts b/libs/api/src/lib/features/biometric-enrollment/mantra-capture-agent.ts new file mode 100644 index 000000000..2c45334fc --- /dev/null +++ b/libs/api/src/lib/features/biometric-enrollment/mantra-capture-agent.ts @@ -0,0 +1,169 @@ +import type { BiometricPosition } from './biometric-enrollment.types'; + +/** + * Client for Mantra's Windows RD Service — the local agent that ships with + * MORPHS (and every other Mantra scanner) and is the only way a browser can + * reach a USB-attached device. It implements UIDAI's standard Registered + * Device Service contract (the same one every L1-certified scanner uses), + * exposing three verbs over plain HTTP on `127.0.0.1`: + * + * RDSERVICE http://127.0.0.1:/ → discovery: is it running, + * what are the paths for + * DEVICEINFO and CAPTURE + * DEVICEINFO http://127.0.0.1:/rd/info → device identity + * CAPTURE http://127.0.0.1:/rd/capture → the actual scan + * + * The service binds one port out of 11100–11105 (11100 unless something else + * already holds it), so discovery has to probe the range rather than assume + * a fixed port. + * + * This is the one piece "swap the simulated bytes for the real vendor SDK + * once a vendor is chosen" (see `BiometricEnrollmentPage.tsx`) always meant — + * everything else in the enroll pipeline (base64 template + format tag, + * encrypt, store) was already shaped for whatever this returns. + */ + +const CANDIDATE_PORTS = [11100, 11101, 11102, 11103, 11104, 11105] as const; +const DISCOVERY_TIMEOUT_MS = 800; +const CAPTURE_TIMEOUT_MS = 30_000; + +export interface MantraDeviceInfo { + /** Device provider/serial identity as reported by RD service — goes in `deviceId`. */ + deviceId: string; + /** RD service version string, kept for support/troubleshooting, not sent to the API. */ + rdsVersion: string; + /** The port discovery found it on. */ + port: number; +} + +export interface MantraCaptureResult { + /** Base64 PID template block — passed straight through as `EnrollBiometric.template`. */ + template: string; + templateFormat: string; + /** RD service's own capture quality score, 0–100, when the response carries one. */ + qualityScore?: number; + deviceId: string; +} + +export class MantraCaptureUnavailableError extends Error { + constructor(message = 'Mantra RD Service was not found on this machine.') { + super(message); + this.name = 'MantraCaptureUnavailableError'; + } +} + +export class MantraCaptureFailedError extends Error { + constructor( + message: string, + public readonly errCode?: string, + ) { + super(message); + this.name = 'MantraCaptureFailedError'; + } +} + +function withTimeout(ms: number): { signal: AbortSignal; cancel: () => void } { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + return { signal: controller.signal, cancel: () => clearTimeout(timer) }; +} + +async function rdRequest(port: number, path: string, method: string, timeoutMs: number, body?: string): Promise { + const { signal, cancel } = withTimeout(timeoutMs); + try { + // RD Service answers plain HTTP on localhost, and browsers block a page + // served over https:// from fetching http:// — this must be called from + // a counter page itself served over http://localhost or with the RD + // service's own local HTTPS certificate trusted, per Mantra's install guide. + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + body, + headers: body ? { 'Content-Type': 'application/xml' } : undefined, + signal, + }); + return await res.text(); + } finally { + cancel(); + } +} + +/** Parses `attr="value"` out of one XML tag without pulling in an XML library for three fields. */ +function attr(xml: string, tag: string, name: string): string | undefined { + const tagMatch = xml.match(new RegExp(`<${tag}\\b[^>]*>`, 'i')); + if (!tagMatch) return undefined; + const attrMatch = tagMatch[0].match(new RegExp(`${name}="([^"]*)"`, 'i')); + return attrMatch?.[1]; +} + +/** + * Probes the RD service port range and reads back device identity. Resolves + * to `null` (never throws) when nothing answers — callers use that to decide + * whether to show "Simulate Scan" instead, exactly like `simulateEnabled` + * already gates it server-side. + */ +export async function discoverMantraDevice(): Promise { + for (const port of CANDIDATE_PORTS) { + try { + const discovery = await rdRequest(port, '/', 'RDSERVICE', DISCOVERY_TIMEOUT_MS); + if (!/status="READY"/i.test(discovery)) continue; + + const infoPath = attr(discovery, 'Interface', 'path') ?? '/rd/info'; + const info = await rdRequest(port, infoPath, 'DEVICEINFO', DISCOVERY_TIMEOUT_MS); + const dpId = attr(info, 'DeviceInfo', 'dpId') ?? attr(info, 'DeviceInfo', 'dc'); + const rdsVersion = attr(info, 'DeviceInfo', 'rdsVer') ?? 'unknown'; + if (!dpId) continue; + + return { deviceId: dpId, rdsVersion, port }; + } catch { + // Nothing on this port (connection refused/timeout) — try the next one. + continue; + } + } + return null; +} + +/** + * One fingerprint capture for a specific finger. `position` drives nothing + * in the RD-service request itself (a single-finger scanner like a slap + * reader doesn't need to be told which finger it is), but the caller must + * still know which finger was placed, since MORPHS's four-finger and + * two-thumb slaps arrive pre-segmented by the vendor SDK into one image per + * finger with no position label of their own. + */ +export async function captureFingerprint( + device: MantraDeviceInfo, + position: BiometricPosition, +): Promise { + const pidOptions = ``; + + let response: string; + try { + response = await rdRequest(device.port, '/rd/capture', 'CAPTURE', CAPTURE_TIMEOUT_MS, pidOptions); + } catch (err) { + throw new MantraCaptureFailedError( + err instanceof Error && err.name === 'AbortError' ? 'Capture timed out.' : 'Could not reach the scanner.', + ); + } + + const errCode = attr(response, 'Resp', 'errCode'); + if (errCode && errCode !== '0') { + const errInfo = attr(response, 'Resp', 'errInfo') ?? `RD service error ${errCode}`; + throw new MantraCaptureFailedError(errInfo, errCode); + } + + const dataMatch = response.match(/]*>([\s\S]*?)<\/Data>/i); + if (!dataMatch) { + throw new MantraCaptureFailedError('Capture response had no template data.'); + } + + const qScore = attr(response, 'Resp', 'qScore'); + return { + template: dataMatch[1].trim(), + // PidData carries an encrypted PID block per UIDAI's spec, not a raw + // ISO/WSQ template — recorded as such so a real matcher/decryption step + // downstream isn't misled into treating it as plaintext ISO-19794-4. + templateFormat: 'UIDAI-PID-2.0', + qualityScore: qScore ? Number(qScore) : undefined, + deviceId: device.deviceId, + }; +} From e807839d74769dd0fbbb22ecc136dd9e76666195 Mon Sep 17 00:00:00 2001 From: nati Date: Mon, 7 Sep 2026 14:25:35 +0000 Subject: [PATCH 3/3] feat: add certificate regeneration functionality to LicenseReviewPage --- .../pages/LicenseReviewPage/index.tsx | 58 +++++++++++++++++++ .../lib/features/licensing/licensing-api.ts | 12 ++++ 2 files changed, 70 insertions(+) diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx index bcb410228..f253d8e16 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage/index.tsx @@ -34,6 +34,7 @@ import { IconPaperclip, IconPencil, IconQuestionMark, + IconRefresh, IconX, } from "@tabler/icons-react"; import { notifications } from "@mantine/notifications"; @@ -71,6 +72,7 @@ import { useScheduleInspectionMutation, useRescheduleInspectionMutation, useGetCertificateUrlForOfficerMutation, + useRegenerateCertificateMutation, uploadDocument, type ApplicationRemark, type RemarkTargetType, @@ -236,6 +238,8 @@ export function LicenseReviewPage() { const [scheduleIssuance] = useScheduleIssuanceMutation(); const [issueCertificate] = useIssueCertificateMutation(); const [getCertificateUrlForOfficer] = useGetCertificateUrlForOfficerMutation(); + const [regenerateCertificate, { isLoading: regeneratingCertificate }] = + useRegenerateCertificateMutation(); const [certificateBusy, setCertificateBusy] = useState<"view" | "download" | null>( null, ); @@ -720,6 +724,37 @@ export function LicenseReviewPage() { } } + /** + * Re-renders the stored certificate from whatever design is published now. + * Unlike View/Download above, this overwrites the stored PDF even though + * one already exists — the fix for a certificate printed from a design + * that turned out to be wrong (e.g. a placeholder published for a rank), + * where View/Download would otherwise keep handing back the same bad file. + */ + async function handleRegenerateCertificate() { + if (!app.issuedLicenseId) return; + try { + await regenerateCertificate(app.issuedLicenseId).unwrap(); + notifications.show({ + color: "teal", + title: t("review.certificateRegenerated", "Certificate regenerated"), + message: t( + "review.certificateRegeneratedBody", + "Re-rendered from the currently published design.", + ), + }); + } catch (err) { + notifications.show({ + color: "red", + title: t( + "review.certificateRegenerateError", + "Could not regenerate the certificate", + ), + message: extractErrorMessage(err), + }); + } + } + /** Opens the booking modal seeded with the visit already on the books. */ function openReschedule() { if (!pendingInspection) return; @@ -1156,6 +1191,29 @@ export function LicenseReviewPage() { > {t("review.download", "Download")} + {/* Same authority as issuing in the first place — reprints + the stored PDF from whatever design is published now, + for when the one on file turns out to be wrong. */} + {can(["can:issue:license-certificate"]) && ( + + + + )} )} diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 523f519cd..f82461c47 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -763,6 +763,17 @@ export const licensingApi = baseApi query: (id) => ({ url: `/licenses/${id}/certificate-backoffice` }), }), + /** + * Force re-renders a certificate that already has a stored PDF, from + * whatever design is published now — the two routes above only fill in + * a *missing* PDF, so a licence printed from a design that turns out to + * be wrong (a placeholder someone published, a broken background image) + * has no other way to pick up a fix once the design is corrected. + */ + regenerateCertificate: builder.mutation<{ url: string }, string>({ + query: (id) => ({ url: `/licenses/${id}/regenerate-certificate`, method: 'POST' }), + }), + // ------------------------------------------------------------- review getQueue: builder.query, QueueFilter | void>({ query: (params) => ({ @@ -1479,6 +1490,7 @@ export const { useGetLicensesQuery, useGetCertificateUrlMutation, useGetCertificateUrlForOfficerMutation, + useRegenerateCertificateMutation, useGetApplicationPaymentQuery, usePatchSectionMutation, useAddStaffMutation,