From 5b3672061a0794f92dff953657b353a2b8fdbad1 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 31 Aug 2026 13:08:10 +0000 Subject: [PATCH 1/3] feat(license-review): implement actions resolution for seafarer certificates and add tests --- .../license-review/config/actions.test.ts | 64 +++++++++++++++++++ .../features/license-review/config/actions.ts | 18 +++++- .../license-review/config/license-types.ts | 8 ++- .../lib/features/licensing/licensing-api.ts | 11 +++- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 apps/backoffice/src/app/features/license-review/config/actions.test.ts diff --git a/apps/backoffice/src/app/features/license-review/config/actions.test.ts b/apps/backoffice/src/app/features/license-review/config/actions.test.ts new file mode 100644 index 000000000..9092437ce --- /dev/null +++ b/apps/backoffice/src/app/features/license-review/config/actions.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { ApplicationDetail } from "@ema-platform/api"; +import { resolveActions } from "./actions"; + +const REASONS = { + wrongStatus: "wrong status", + notAssigned: "not assigned", + noPermission: "no permission", + needsFlags: "needs flags", + needsCapital: "needs capital", + needsInspection: "needs inspection", + needsDocumentReviews: "needs document reviews", + inspectionNotYetDue: "not yet due", +}; + +/** Held by someone else, so ownership gating is what the assertions turn on. */ +function detailFor(licenseTypeKey: string): ApplicationDetail { + return { + application: { + id: "app-1", + status: "SUBMITTED", + assignedOfficerId: "another-officer", + licenseType: { key: licenseTypeKey }, + }, + availableEvents: ["claim", "assign", "request-adjustment"], + } as unknown as ApplicationDetail; +} + +function resolve(licenseTypeKey: string) { + return resolveActions({ + detail: detailFor(licenseTypeKey), + currentUserId: "me", + can: () => true, + reasons: REASONS, + flaggedCount: 1, + hasPendingInspection: false, + inspectionNotYetDue: false, + allDocumentsAccepted: true, + }); +} + +describe("resolveActions — seafarer certificates skip the queue", () => { + it("drops claim and assign for a CoC", () => { + const ids = resolve("CERTIFICATE_OF_COMPETENCY").map((a) => a.id); + expect(ids).not.toContain("claim"); + expect(ids).not.toContain("assign"); + expect(ids).not.toContain("assign-reviewer"); + }); + + it("lets an officer act on a CoP another officer holds", () => { + const adjust = resolve("CERTIFICATE_OF_PROFICIENCY").find( + (a) => a.id === "request-adjustment", + ); + expect(adjust?.enabled).toBe(true); + }); + + it("leaves an ordinary licence claimed and owner-gated", () => { + const actions = resolve("FREIGHT_FORWARDER"); + expect(actions.map((a) => a.id)).toContain("claim"); + const adjust = actions.find((a) => a.id === "request-adjustment"); + expect(adjust?.enabled).toBe(false); + expect(adjust?.disabledReason).toBe(REASONS.notAssigned); + }); +}); diff --git a/apps/backoffice/src/app/features/license-review/config/actions.ts b/apps/backoffice/src/app/features/license-review/config/actions.ts index a5ac52faa..b56355c92 100644 --- a/apps/backoffice/src/app/features/license-review/config/actions.ts +++ b/apps/backoffice/src/app/features/license-review/config/actions.ts @@ -1,4 +1,5 @@ import type { ApplicationDetail, LicenseStatus } from '@ema-platform/api'; +import { isSeafarerCertificate } from './license-types'; /** * Where an action is rendered. One tier per action, decided here rather than @@ -433,6 +434,10 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { const { detail, currentUserId, can, reasons } = ctx; const app = detail.application; const serverEvents = detail.availableEvents; + // A seafarer's certificate is reviewed by whoever opens it. There is no + // queue to claim it from and no reviewer to assign, so the two actions that + // move it through one are dropped and ownership stops gating the decisions. + const skipsAssignment = isSeafarerCertificate(app.licenseType?.key); return ACTIONS.filter((action) => can(action.permissions)).flatMap( (action) => { @@ -449,6 +454,15 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { return []; } + if ( + skipsAssignment && + (action.id === 'claim' || + action.id === 'assign' || + action.id === 'assign-reviewer') + ) { + return []; + } + // Scheduling and recording are the same slot at the same status; which // one applies depends on whether an inspection is already booked. if (action.id === 'schedule-inspection' && ctx.hasPendingInspection) return []; @@ -486,7 +500,9 @@ export function resolveActions(ctx: ResolveContext): ResolvedAction[] { // Decisions belong to whoever holds the application. const needsOwnership = - action.tier === 'primary' && action.id !== 'confirm-payment'; + !skipsAssignment && + action.tier === 'primary' && + action.id !== 'confirm-payment'; if ( needsOwnership && app.assignedOfficerId && diff --git a/apps/backoffice/src/app/features/license-review/config/license-types.ts b/apps/backoffice/src/app/features/license-review/config/license-types.ts index 7e920efa5..7bd1ac9b7 100644 --- a/apps/backoffice/src/app/features/license-review/config/license-types.ts +++ b/apps/backoffice/src/app/features/license-review/config/license-types.ts @@ -152,7 +152,13 @@ const PRESENTATION: Record = { const CERTIFICATE_KEY_PREFIXES = ['COC_', 'COP_', 'GOC_']; const CERTIFICATE_SECTIONS: DetailSection[] = ['overview', 'documents']; -function isSeafarerCertificate(key: string): boolean { +/** + * CoC, CoP and the endorsement/GOC family: a seafarer's own certificate rather + * than an organisation's licence. They skip the claim/assign queue — see + * `resolveActions`. + */ +export function isSeafarerCertificate(key: string | undefined): boolean { + if (!key) return false; return ( CERTIFICATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) || key === 'CERTIFICATE_OF_COMPETENCY' || diff --git a/libs/api/src/lib/features/licensing/licensing-api.ts b/libs/api/src/lib/features/licensing/licensing-api.ts index 8756f8f46..073b5393c 100644 --- a/libs/api/src/lib/features/licensing/licensing-api.ts +++ b/libs/api/src/lib/features/licensing/licensing-api.ts @@ -1007,8 +1007,17 @@ export const licensingApi = baseApi url: `/license-application-review/${id}/issue-certificate`, method: 'POST', }), + // The certificate this writes is what `License` lists read, so the + // issued register and the holder's own licences refresh with it — + // without them an officer issues a certificate that nobody's list shows. invalidatesTags: (_r, error, id) => - error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')], + error + ? [] + : [ + itemTag('LicenseApplication', id), + listTag('ApplicationQueue'), + listTag('License'), + ], }), // ------------------------------------------------------------- pickup From 09a6f492def3d64e84e879088f693dead6990459 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 1 Sep 2026 07:33:00 +0000 Subject: [PATCH 2/3] refactor(exam): drop the last trace of "Eligible to Register" The label was reverted to "Exam Paid" but the name survived as the internal stage key, so the concept was still in the codebase to be reintroduced by accident. The stage is named after the status it derives from now. --- .../features/certificates/pages/CertificatesPage.tsx | 2 +- .../src/app/features/licensing/exam-stage.spec.ts | 4 ++-- apps/portal/src/app/features/licensing/exam-stage.ts | 10 ++++++---- apps/portal/src/app/i18n/locales/am.ts | 2 +- apps/portal/src/app/i18n/locales/en.ts | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index 93814873e..360f5ef2d 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -114,7 +114,7 @@ const STATUS_COLOR: Record = { * keys because this page states its statuses the same way. */ const EXAM_STAGE_LABELS: Record = { - ELIGIBLE_TO_REGISTER: 'Eligible — Register for a Sitting', + EXAM_PAID: 'Exam Paid', REGISTERED: 'Exam Scheduled', ATTENDANCE_CONFIRMED: 'Exam Attendance Confirmed', SITTING: 'Exam In Progress', diff --git a/apps/portal/src/app/features/licensing/exam-stage.spec.ts b/apps/portal/src/app/features/licensing/exam-stage.spec.ts index 22ec0deca..2003537e9 100644 --- a/apps/portal/src/app/features/licensing/exam-stage.spec.ts +++ b/apps/portal/src/app/features/licensing/exam-stage.spec.ts @@ -29,8 +29,8 @@ describe('examStageFor', () => { expect(examStageFor({ status: 'CERTIFICATE_ISSUED' })).toBeNull(); }); - it('reads a paid application as eligible to register, not as waiting', () => { - expect(examStageFor({ status: 'EXAM_PAID' })).toBe('ELIGIBLE_TO_REGISTER'); + it('reads a paid application as Exam Paid, not as awaiting a date', () => { + expect(examStageFor({ status: 'EXAM_PAID' })).toBe('EXAM_PAID'); }); it('reports a registration whose attendance has not been taken', () => { diff --git a/apps/portal/src/app/features/licensing/exam-stage.ts b/apps/portal/src/app/features/licensing/exam-stage.ts index c7aaaaaec..1697f7c4b 100644 --- a/apps/portal/src/app/features/licensing/exam-stage.ts +++ b/apps/portal/src/app/features/licensing/exam-stage.ts @@ -16,7 +16,7 @@ import type { MyRegistration } from '../exams/pages/ExamsPage'; * already recorded — this only names what the records add up to. */ export type ExamStage = - | 'ELIGIBLE_TO_REGISTER' + | 'EXAM_PAID' | 'REGISTERED' | 'ATTENDANCE_CONFIRMED' | 'SITTING' @@ -41,9 +41,11 @@ export function examStageFor( if (app.status === 'EXAM_PASSED') return 'PASSED'; if (app.status === 'EXAM_FAILED') return 'FAILED'; - // EXAM_PAID is "prerequisites done, sitting not yet chosen": the fee has - // cleared and nothing is left but for the candidate to pick a session. - if (app.status === 'EXAM_PAID') return 'ELIGIBLE_TO_REGISTER'; + // The fee has cleared and nothing is left but for the candidate to pick a + // session. Named after the status rather than after what the candidate + // should do next: "Eligible to Register" read as a status of its own and + // said less than the plain one. + if (app.status === 'EXAM_PAID') return 'EXAM_PAID'; if (app.status !== 'EXAM_SCHEDULED') return null; diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index be22f0b46..db485310f 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -244,7 +244,7 @@ export const am: Translations = { empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።', }, examStage: { - ELIGIBLE_TO_REGISTER: "ብቁ ነዎት — ለፈተና ይመዝገቡ", + EXAM_PAID: "የፈተና ክፍያ ተከፍሏል", REGISTERED: "የፈተና ቀን፦ {{date}}", ATTENDANCE_CONFIRMED: "መገኘት ተረጋግጧል", SITTING: "ፈተና በመካሄድ ላይ", diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index ac1925ca9..ce8de654f 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -249,7 +249,7 @@ export const en = { * from the application status alone — see exam-stage.ts. */ examStage: { - ELIGIBLE_TO_REGISTER: 'Eligible — register for a sitting', + EXAM_PAID: 'Exam Paid', REGISTERED: 'Exam scheduled: {{date}}', ATTENDANCE_CONFIRMED: 'Attendance confirmed', SITTING: 'Exam in progress', From 6e32d555e63af7f0b6606cb33c4229bd297f98a6 Mon Sep 17 00:00:00 2001 From: mihretue Date: Tue, 1 Sep 2026 07:34:32 +0000 Subject: [PATCH 3/3] fix(exam): show EXAM_PAID as "Exam Paid" in the queue label map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revert of this label was lost when the branch was reset, so the back office kept rendering "Eligible to Register" — a status name nobody recognised, saying less than the plain one it replaced. The status names the fact the application records; the Register button carries the action. --- libs/api/src/lib/features/licensing/licensing.helpers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/api/src/lib/features/licensing/licensing.helpers.ts b/libs/api/src/lib/features/licensing/licensing.helpers.ts index c2396408c..4b39320d1 100644 --- a/libs/api/src/lib/features/licensing/licensing.helpers.ts +++ b/libs/api/src/lib/features/licensing/licensing.helpers.ts @@ -88,7 +88,7 @@ export const STATUS_LABELS: Record = { EXAM_PAYMENT_PENDING: 'Exam Fee Due', // Nobody assigns a date any more — the fee clearing is what makes the // candidate eligible to register for a published sitting themselves. - EXAM_PAID: 'Eligible to Register', + EXAM_PAID: 'Exam Paid', EXAM_SCHEDULED: 'Exam Scheduled', EXAM_PASSED: 'Exam Passed', EXAM_FAILED: 'Exam Not Passed',