diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts index 8abf91c..6baf102 100644 --- a/App/app/(portal)/crewing/applications/actions.ts +++ b/App/app/(portal)/crewing/applications/actions.ts @@ -115,6 +115,16 @@ export async function advanceStage(id: string, action: ApplicationAction): Promi if (!transition) return { error: `Cannot ${action} from ${app.stage}` }; if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" }; + // C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before + // leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references + // gate is completed by `verify_competency`. + if (action === "verify_competency") { + const references = await db.referenceCheck.count({ where: { applicationId: id } }); + if (references === 0) { + return { error: "Record at least one reference check before completing competency & references" }; + } + } + await db.application.update({ where: { id }, data: { @@ -207,6 +217,33 @@ export async function verifyDocuments(formData: FormData): Promise const d = parsed.data; const crewMemberId = app.crewMember.id; + // C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for + // the seat's rank is EXPIRED. + // Scope note (documented limitation): seafarer documents are collected on the + // crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate + // usually has none on file, so a hard "missing document" block would stall the + // whole funnel. We therefore gate on what is available (expiry of documents the + // candidate already holds); the "all required documents present" check is + // enforced post-onboarding in the verification queue (§8.11). Once careers + // intake (A2) uploads documents pre-onboarding, tighten this to also require + // presence of every mandatory docType. + const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } }); + if (reqRank) { + const [required, candidateDocs] = await Promise.all([ + db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }), + db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }), + ]); + const requiredTypes = new Set(required.map((r) => r.docType)); + const now = new Date(); + const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now); + if (expired.length > 0) { + return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` }; + } + } + // C4 (experience check) is deferred: the Requisition has no min-experience + // criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare + // the candidate's ExperienceRecord total against it here and flag a shortfall. + await db.$transaction(async (tx) => { // Capture bank / EPF (PII — encryption deferred to Phase 4). await tx.bankDetail.upsert({ diff --git a/App/tests/integration/applications.test.ts b/App/tests/integration/applications.test.ts index 5299dfe..cd485b5 100644 --- a/App/tests/integration/applications.test.ts +++ b/App/tests/integration/applications.test.ts @@ -69,6 +69,7 @@ afterEach(async () => { await db.salaryStructure.deleteMany({}); await db.applicationGate.deleteMany({}); await db.referenceCheck.deleteMany({}); + await db.seafarerDocument.deleteMany({}); await db.application.deleteMany({}); await db.bankDetail.deleteMany({}); await db.epfDetail.deleteMany({}); @@ -191,6 +192,42 @@ describe("interview waiver (ex-hands, R2)", () => { }); }); +describe("vetting gates (C3/C5)", () => { + it("blocks completing competency & references until a reference is recorded (C5)", async () => { + const { applicationId } = await newApplication(); + as(manningId, "MANNING"); + await advanceStage(applicationId, "start_competency"); // → COMPETENCY_AND_REFERENCES + // No reference recorded yet → cannot advance. + expect("error" in (await advanceStage(applicationId, "verify_competency"))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("COMPETENCY_AND_REFERENCES"); + // Record one → now it advances. + await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" })); + expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION"); + }); + + it("blocks document verification when a required document on file is expired (C3)", async () => { + const { applicationId, requisitionId, crewMemberId } = await newApplication(); + await setStage(applicationId, "DOC_VERIFICATION"); + const reqRank = (await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).rankId; + await db.rankDocRequirement.upsert({ + where: { rankId_docType: { rankId: reqRank, docType: "MEDICAL_FITNESS" } }, + update: { isMandatory: true }, + create: { rankId: reqRank, docType: "MEDICAL_FITNESS", isMandatory: true }, + }); + await db.seafarerDocument.create({ data: { crewMemberId, docType: "MEDICAL_FITNESS", expiryDate: new Date("2020-01-01") } }); + + as(manningId, "MANNING"); + expect("error" in (await verifyDocuments(fd({ applicationId })))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION"); + + // Renew the document → advancement proceeds. + await db.seafarerDocument.updateMany({ where: { crewMemberId }, data: { expiryDate: new Date("2030-01-01") } }); + expect("ok" in (await verifyDocuments(fd({ applicationId })))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT"); + }); +}); + describe("rejection", () => { it("MPO rejects from a mid stage", async () => { const { applicationId } = await newApplication();