From 5b3672061a0794f92dff953657b353a2b8fdbad1 Mon Sep 17 00:00:00 2001 From: Nati Date: Mon, 31 Aug 2026 13:08:10 +0000 Subject: [PATCH] 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