feat(crewing): enforce recruitment vetting gates C5 + partial C3

- C5 (Epic C5 AC1): advanceStage("verify_competency") now requires ≥1
  ReferenceCheck before leaving COMPETENCY_AND_REFERENCES.
- C3 (Epic C3 AC1): verifyDocuments blocks advancement when a mandatory document
  for the seat's rank that the candidate holds is expired. Missing-document
  presence stays enforced post-onboarding in the verification queue (seafarer
  docs aren't collected pre-onboarding) — documented inline + in wiki Tech-Debt.
- C4 (experience): deferred with an inline note (Requisition has no
  min-experience field yet — Epic A2 AC1).

applications.test.ts: reference-gate block/pass and expired-required-doc
block/renew-pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 23:40:08 +05:30
parent d796e81efc
commit 184250f903
2 changed files with 74 additions and 0 deletions

View file

@ -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<ActionResult>
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({

View file

@ -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();