diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts index 4ea7f32..850923a 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({ @@ -332,7 +369,7 @@ export async function returnSalary(id: string, reason: string): Promise { await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } }); await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } }); - await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } }); + await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } }); }); revalidateApp(id, app.requisition.id); return { ok: true }; @@ -562,8 +599,33 @@ export async function onboardCandidate(formData: FormData): Promise 0) { + const key = buildStorageKey("contract", app.crewMember.id, file.name); + await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream"); + contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" }; + } + const result = await db.$transaction(async (tx) => { const employeeId = await generateEmployeeId(tx); const assignment = await tx.crewAssignment.create({ @@ -582,9 +644,23 @@ export async function onboardCandidate(formData: FormData): Promise 0) { - const key = buildStorageKey("contract", result.assignmentId, file.name); - await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream"); - await db.contractLetter.create({ - data: { assignmentId: result.assignmentId, fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" }, - }); - } - revalidateApp(id, app.requisition.id); return { ok: true, id: result.employeeId }; } diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx index 2ea86f8..16884e5 100644 --- a/App/app/(portal)/crewing/candidates/[id]/page.tsx +++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx @@ -21,7 +21,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise< const { id } = await params; const c = await db.crewMember.findUnique({ where: { id }, - include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } }, + include: { + appliedRank: { select: { name: true } }, + currentRank: { select: { name: true } }, + // B3 AC3 — pull the returning hand's history so the callout shows real records. + experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } }, + documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: true } }, + }, }); if (!c) notFound(); @@ -53,8 +59,42 @@ export default async function CandidateDetailPage({ params }: { params: Promise< {c.source === "EX_HAND" && (
- Returning crew. Prior documents, bank details and tour history are on file from earlier - assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase). + Returning crew. The interview may be waived with Manager approval.{" "} + {c.experienceRecords.length === 0 && c.documents.length === 0 ? ( + No prior records are on file yet. + ) : ( + Prior records on file from earlier assignments: + )} + + {c.experienceRecords.length > 0 && ( +
+

Tour history

+
    + {c.experienceRecords.map((e) => ( +
  • + {e.rank?.name ?? "—"} + {e.vesselType ? ` · ${e.vesselType}` : ""} + {e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""} + {e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""} +
  • + ))} +
+
+ )} + + {c.documents.length > 0 && ( +
+

Documents on file

+
+ {c.documents.map((doc) => ( + + {doc.docType} + {doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""} + + ))} +
+
+ )}
)} diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts index 5d7c928..63972ca 100644 --- a/App/app/(portal)/crewing/candidates/actions.ts +++ b/App/app/(portal)/crewing/candidates/actions.ts @@ -76,6 +76,45 @@ export async function addCandidate(formData: FormData): Promise { const d = parsed.data; const { type, status } = derive(d.source); + // B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh + // candidate (not already tagged EX_HAND) is matched to their existing EX_HAND + // pool record by a stable key — email when given, else an exact name match — + // and the SAME row is reused (so their tour history, documents and bank stay on + // file) rather than creating a duplicate. (Heuristic: with no DOB on file a + // name-only match can in theory collide; email is preferred when available.) + if (d.source !== "EX_HAND") { + const match = await db.crewMember.findFirst({ + where: { + status: "EX_HAND", + ...(d.email + ? { email: { equals: d.email, mode: "insensitive" } } + : { name: { equals: d.name, mode: "insensitive" } }), + }, + select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true }, + }); + if (match) { + const updated = await db.crewMember.update({ + where: { id: match.id }, + data: { + // Keep EX_HAND type/status; refresh the application's details, never + // discarding prior history (take the larger recorded experience). + appliedRankId: d.appliedRankId || match.appliedRankId, + currentRankId: d.currentRankId || match.currentRankId, + email: d.email || match.email, + phone: d.phone || match.phone, + notes: d.notes || match.notes, + experienceMonths: Math.max(d.experienceMonths, match.experienceMonths), + vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience, + actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } }, + }, + }); + const cvKey = await storeCv(formData, updated.id); + if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } }); + revalidatePath(LIST_PATH); + return { ok: true, id: updated.id }; + } + } + const candidate = await db.crewMember.create({ data: { name: d.name, diff --git a/App/app/(portal)/crewing/candidates/page.tsx b/App/app/(portal)/crewing/candidates/page.tsx index 80adfae..065ef37 100644 --- a/App/app/(portal)/crewing/candidates/page.tsx +++ b/App/app/(portal)/crewing/candidates/page.tsx @@ -46,5 +46,9 @@ export default async function CandidatesPage() { hasCv: Boolean(c.cvKey), })); + // B3 AC2 — ex-hands (proven crew) surface above new candidates by default. + // Stable sort preserves the createdAt-desc order within each group. + rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND")); + return ; } diff --git a/App/app/(portal)/crewing/crew/[id]/page.tsx b/App/app/(portal)/crewing/crew/[id]/page.tsx index 43a4250..703515b 100644 --- a/App/app/(portal)/crewing/crew/[id]/page.tsx +++ b/App/app/(portal)/crewing/crew/[id]/page.tsx @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; -import { canViewSalary, bankEpfValue } from "@/lib/crew-pii"; +import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii"; import { redirect, notFound } from "next/navigation"; import { CrewProfile } from "./crew-profile"; import type { Metadata } from "next"; @@ -68,7 +68,7 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id documents={c.documents.map((d) => ({ id: d.id, docType: d.docType, - number: d.number, + number: documentNumberValue(d.number, d.docType, role), issueDate: d.issueDate?.toISOString() ?? null, expiryDate: d.expiryDate?.toISOString() ?? null, verificationStatus: d.verificationStatus, diff --git a/App/app/(portal)/crewing/crew/actions.ts b/App/app/(portal)/crewing/crew/actions.ts index 2a1f7da..185835a 100644 --- a/App/app/(portal)/crewing/crew/actions.ts +++ b/App/app/(portal)/crewing/crew/actions.ts @@ -5,7 +5,7 @@ import { db } from "@/lib/db"; import { hasPermission, type Permission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; import { buildStorageKey, uploadBuffer } from "@/lib/storage"; -import { autoRaiseRequisition } from "@/lib/requisition-service"; +import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service"; import { SeafarerDocType, PpeItem } from "@prisma/client"; import { z } from "zod"; import { revalidatePath } from "next/cache"; @@ -83,9 +83,14 @@ export async function uploadDocument(formData: FormData): Promise export async function deleteDocument(id: string): Promise { const g = await guard("upload_crew_records"); if ("error" in g) return g; - const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true } }); + const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } }); if (!doc) return { error: "Document not found" }; - await db.seafarerDocument.delete({ where: { id } }); + await db.$transaction(async (tx) => { + await tx.seafarerDocument.delete({ where: { id } }); + await tx.crewAction.create({ + data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } }, + }); + }); revalidatePath(crewPath(doc.crewMemberId)); return { ok: true }; } @@ -178,7 +183,12 @@ export async function deleteNextOfKin(id: string): Promise { if ("error" in g) return g; const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } }); if (!nok) return { error: "Record not found" }; - await db.nextOfKin.delete({ where: { id } }); + await db.$transaction(async (tx) => { + await tx.nextOfKin.delete({ where: { id } }); + await tx.crewAction.create({ + data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } }, + }); + }); revalidatePath(crewPath(nok.crewMemberId)); return { ok: true }; } @@ -279,7 +289,9 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem const off = new Date(signOffDate); - await db.$transaction(async (tx) => { + // Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the + // seat can never become vacant without its backfill being raised. + const backfill = await db.$transaction(async (tx) => { await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } }); await tx.experienceRecord.create({ data: { @@ -300,15 +312,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem await tx.crewAction.create({ data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null }, }); + return autoRaiseRequisition( + { rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" }, + tx + ); }); - - // The seat is now vacant → auto-raise a backfill requisition (spec §5.3). - await autoRaiseRequisition({ - rankId: assignment.rankId, - vesselId: assignment.vesselId, - siteId: assignment.siteId, - reason: "SIGN_OFF", - }); + // Notify the office after the transaction commits. + await notifyAutoRaised(backfill); revalidatePath(crewPath(assignment.crewMemberId)); revalidatePath("/crewing/crew"); diff --git a/App/app/(portal)/crewing/leave/actions.ts b/App/app/(portal)/crewing/leave/actions.ts index 0363b85..a83487e 100644 --- a/App/app/(portal)/crewing/leave/actions.ts +++ b/App/app/(portal)/crewing/leave/actions.ts @@ -5,7 +5,7 @@ import { db } from "@/lib/db"; import { hasPermission, type Permission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; import { leaveCausesClash } from "@/lib/leave-clash"; -import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service"; +import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service"; import { notifyCrew } from "@/lib/notifier"; import { LeaveType } from "@prisma/client"; import type { Role } from "@prisma/client"; @@ -110,7 +110,9 @@ export async function decideLeave(id: string, approve: boolean, note?: string): return { ok: true }; } - const { clash } = await db.$transaction(async (tx) => { + // Leave approval + the clash check + any backfill requisition commit atomically + // (spec §5.3/§11): an approved leave can never leave a cover gap un-raised. + const backfill = await db.$transaction(async (tx) => { await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } }); await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } }); await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } }); @@ -121,18 +123,15 @@ export async function decideLeave(id: string, approve: boolean, note?: string): fromDate: leave.fromDate, toDate: leave.toDate, }); - return { clash }; + if (!clash) return null; + return autoRaiseRequisition( + { rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" }, + tx + ); }); - // A detected clash auto-raises a LEAVE requisition (reuses the Phase-2 helper). - if (clash) { - await autoRaiseRequisition({ - rankId: leave.assignment.rankId, - vesselId: leave.assignment.vesselId, - siteId: leave.assignment.siteId, - reason: "LEAVE", - }); - } + // Notify the office after the transaction commits. + if (backfill) await notifyAutoRaised(backfill); revalidate(); return { ok: true }; diff --git a/App/app/(portal)/crewing/requisitions/page.tsx b/App/app/(portal)/crewing/requisitions/page.tsx index f43c1cf..556ad90 100644 --- a/App/app/(portal)/crewing/requisitions/page.tsx +++ b/App/app/(portal)/crewing/requisitions/page.tsx @@ -25,6 +25,7 @@ export default async function RequisitionsPage() { vessel: { select: { name: true } }, site: { select: { name: true } }, raisedBy: { select: { name: true } }, + _count: { select: { applications: true } }, }, }), db.reliefRequest.findMany({ @@ -52,6 +53,7 @@ export default async function RequisitionsPage() { rankName: r.rank.name, location: r.vessel?.name ?? r.site?.name ?? "—", raisedBy: r.raisedBy?.name ?? "System", + candidateCount: r._count.applications, createdAt: r.createdAt.toISOString(), })); diff --git a/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx b/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx index 2f1ef6c..986b10c 100644 --- a/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx +++ b/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx @@ -16,6 +16,7 @@ type RequisitionRow = { rankName: string; location: string; raisedBy: string; + candidateCount: number; createdAt: string; }; @@ -58,21 +59,33 @@ export function RequisitionsManager({ const [search, setSearch] = useState(""); const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL"); const [location, setLocation] = useState("ALL"); + const [rank, setRank] = useState("ALL"); + const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL"); const locations = useMemo( () => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(), [requisitions] ); + const rankNames = useMemo( + () => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(), + [requisitions] + ); + const reasons = useMemo( + () => Array.from(new Set(requisitions.map((r) => r.reason))), + [requisitions] + ); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return requisitions.filter((r) => { if (status !== "ALL" && r.status !== status) return false; if (location !== "ALL" && r.location !== location) return false; + if (rank !== "ALL" && r.rankName !== rank) return false; + if (reason !== "ALL" && r.reason !== reason) return false; if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false; return true; }); - }, [requisitions, search, status, location]); + }, [requisitions, search, status, location, rank, reason]); return (
@@ -106,6 +119,18 @@ export function RequisitionsManager({ ))} + +
{/* Requisitions table */} @@ -117,6 +142,7 @@ export function RequisitionsManager({ Vessel / site Rank Reason + Candidates Raised by Status @@ -124,7 +150,7 @@ export function RequisitionsManager({ {filtered.length === 0 ? ( - + No requisitions match these filters. @@ -145,6 +171,7 @@ export function RequisitionsManager({ {r.location} {r.rankName} {REASON_LABEL[r.reason]} + {r.candidateCount} {r.raisedBy} {STATUS_LABEL[r.status]} diff --git a/App/lib/crew-pii.ts b/App/lib/crew-pii.ts index 0550c5a..ab06947 100644 --- a/App/lib/crew-pii.ts +++ b/App/lib/crew-pii.ts @@ -1,4 +1,4 @@ -import type { Role } from "@prisma/client"; +import type { Role, SeafarerDocType } from "@prisma/client"; // PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8). // Bank account / EPF identity numbers are full only for Accounts (and SuperUser); @@ -8,6 +8,11 @@ export function canViewFullBankEpf(role: Role): boolean { return role === "ACCOUNTS" || role === "SUPERUSER"; } +// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated +// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents +// (passport, CDC, STCW, COC, medical…) are not number-restricted. +const RESTRICTED_DOC_TYPES = new Set(["AADHAAR", "PAN"]); + export function canViewSalary(role: Role): boolean { // Office roles see salary; site staff see status only (§6, R7). return role !== "SITE_STAFF"; @@ -26,3 +31,18 @@ export function bankEpfValue(value: string | null | undefined, role: Role): stri if (!value) return "—"; return canViewFullBankEpf(role) ? value : maskTail(value); } + +// A seafarer document number, masked for non-privileged roles when the document +// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass +// through unchanged. Preserves the `string | null` contract the profile expects. +export function documentNumberValue( + value: string | null | undefined, + docType: SeafarerDocType, + role: Role +): string | null { + if (!value) return null; + if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) { + return maskTail(value); + } + return value; +} diff --git a/App/lib/requisition-service.ts b/App/lib/requisition-service.ts index fb6c4f2..92a3c86 100644 --- a/App/lib/requisition-service.ts +++ b/App/lib/requisition-service.ts @@ -89,18 +89,9 @@ export function getManagerRecipients(): Promise { }); } -/** - * System auto-raise: an OPEN requisition with no human actor (autoRaised), then - * notifies the office. Sign-off, end-of-contract and the leave-clash detector - * (later phases) all funnel through here. See spec §5.2/§5.3 (R6). - */ -export async function autoRaiseRequisition( - input: Omit -): Promise { - const requisition = await db.$transaction((tx) => - createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true }) - ); - +/** Notify the office that a requisition was auto-raised. Call AFTER the + * creating transaction commits (notifications are not part of the atomic write). */ +export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise { const recipients = await getOfficeRecipients(); const loc = requisitionLocationLabel(requisition); await notifyCrew({ @@ -110,6 +101,28 @@ export async function autoRaiseRequisition( body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`, link: `/crewing/requisitions/${requisition.id}`, }); +} +/** + * System auto-raise: an OPEN requisition with no human actor (autoRaised). + * Sign-off, end-of-contract and the leave-clash detector funnel through here. + * See spec §5.2/§5.3 (R6). + * + * Pass `tx` to create the backfill **atomically inside the caller's transaction** + * (so an approved leave / sign-off can never commit without its backfill) — the + * caller then owns the post-commit `notifyAutoRaised`. Called without `tx`, it + * runs its own transaction and notifies itself. + */ +export async function autoRaiseRequisition( + input: Omit, + tx?: Tx +): Promise { + const data = { ...input, raisedById: null, autoRaised: true }; + if (tx) { + // Caller's transaction — caller is responsible for notifyAutoRaised after commit. + return createRequisitionTx(tx, data); + } + const requisition = await db.$transaction((t) => createRequisitionTx(t, data)); + await notifyAutoRaised(requisition); return requisition; } diff --git a/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql b/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql new file mode 100644 index 0000000..2cb02d5 --- /dev/null +++ b/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql @@ -0,0 +1,51 @@ +-- Recreate CrewActionType: add explicit return/decline/delete audit types and +-- drop the unused GATE_FAILED value (see Crewing audit-trail consistency cleanup, +-- spec §11). One recreate adds + removes in a single migration. +BEGIN; +CREATE TYPE "CrewActionType_new" AS ENUM ( + 'REQUISITION_RAISED', + 'REQUISITION_ADVANCED', + 'REQUISITION_FILLED', + 'REQUISITION_CANCELLED', + 'RELIEF_REQUESTED', + 'RELIEF_CONVERTED', + 'RELIEF_CANCELLED', + 'CANDIDATE_ADDED', + 'CANDIDATE_UPDATED', + 'APPLICATION_CREATED', + 'GATE_PASSED', + 'REFERENCE_RECORDED', + 'SALARY_AGREED', + 'SALARY_APPROVED', + 'SALARY_RETURNED', + 'CANDIDATE_PROPOSED', + 'INTERVIEW_RECORDED', + 'WAIVER_REQUESTED', + 'WAIVER_APPROVED', + 'WAIVER_DECLINED', + 'CANDIDATE_SELECTED', + 'SELECTION_RETURNED', + 'APPLICATION_REJECTED', + 'CREW_ONBOARDED', + 'DOCUMENT_UPLOADED', + 'RECORD_UPDATED', + 'RECORD_DELETED', + 'PPE_ISSUED', + 'PPE_RETURNED', + 'EXPERIENCE_ADDED', + 'LEAVE_APPLIED', + 'LEAVE_DECIDED', + 'ATTENDANCE_RECORDED', + 'CREW_SIGNED_OFF', + 'RECORD_VERIFIED', + 'RECORD_REJECTED', + 'APPRAISAL_SUBMITTED', + 'APPRAISAL_VERIFIED', + 'APPRAISAL_APPROVED', + 'APPRAISAL_REJECTED' +); +ALTER TABLE "CrewAction" ALTER COLUMN "actionType" TYPE "CrewActionType_new" USING ("actionType"::text::"CrewActionType_new"); +ALTER TYPE "CrewActionType" RENAME TO "CrewActionType_old"; +ALTER TYPE "CrewActionType_new" RENAME TO "CrewActionType"; +DROP TYPE "CrewActionType_old"; +COMMIT; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 348239d..8016b48 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -135,19 +135,22 @@ enum CrewActionType { CANDIDATE_UPDATED APPLICATION_CREATED GATE_PASSED - GATE_FAILED REFERENCE_RECORDED SALARY_AGREED SALARY_APPROVED + SALARY_RETURNED CANDIDATE_PROPOSED INTERVIEW_RECORDED WAIVER_REQUESTED WAIVER_APPROVED + WAIVER_DECLINED CANDIDATE_SELECTED + SELECTION_RETURNED APPLICATION_REJECTED CREW_ONBOARDED DOCUMENT_UPLOADED RECORD_UPDATED + RECORD_DELETED PPE_ISSUED PPE_RETURNED EXPERIENCE_ADDED 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(); diff --git a/App/tests/integration/candidates.test.ts b/App/tests/integration/candidates.test.ts index 0baafe5..915ad8b 100644 --- a/App/tests/integration/candidates.test.ts +++ b/App/tests/integration/candidates.test.ts @@ -6,14 +6,21 @@ * its CrewAction rows) wholesale — no pre-existing rows to preserve. */ import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import React from "react"; +// The list page's JSX compiles to classic React.createElement in the node runner. +(globalThis as unknown as { React: typeof React }).React = React; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +// We read the page element's props directly; the client component is irrelevant. +vi.mock("@/app/(portal)/crewing/candidates/candidates-manager", () => ({ CandidatesManager: () => null })); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions"; +import CandidatesPage from "@/app/(portal)/crewing/candidates/page"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; @@ -88,6 +95,50 @@ describe("addCandidate", () => { }); }); +describe("ex-hand recognition + ordering (B3)", () => { + it("recognizes a returning hand by email and reuses the same row (AC1)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" })); + const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } }); + + // Re-applies as a fresh careers candidate with the same email → recognized. + const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId })); + expect("ok" in res && res.id).toBe(exhand.id); + expect(await db.crewMember.count()).toBe(1); // no duplicate row + + const after = await db.crewMember.findUniqueOrThrow({ where: { id: exhand.id }, include: { actions: true } }); + expect(after.status).toBe("EX_HAND"); + expect(after.appliedRankId).toBe(rankId); + expect(after.experienceMonths).toBe(120); // prior history preserved (max) + expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true); + }); + + it("recognizes a returning hand by exact name when no email is given (AC1)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" })); + const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive + const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } }); + expect("ok" in res && res.id).toBe(exhand.id); + expect(await db.crewMember.count()).toBe(1); + }); + + it("does not match a different person → creates a new candidate", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" })); + await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" })); + expect(await db.crewMember.count()).toBe(2); + }); + + it("lists ex-hands above new candidates by default (AC2)", async () => { + as(managerId, "MANAGER"); + await addCandidate(fd({ name: "New First", source: "CAREERS" })); + await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" })); + const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } }; + expect(el.props.candidates[0].status).toBe("EX_HAND"); + expect(el.props.candidates[0].name).toBe("Ex Second"); + }); +}); + describe("updateCandidate", () => { it("edits fields and writes a CANDIDATE_UPDATED action", async () => { as(managerId, "MANAGER"); diff --git a/App/tests/integration/crew-pii-page.test.ts b/App/tests/integration/crew-pii-page.test.ts new file mode 100644 index 0000000..1213b36 --- /dev/null +++ b/App/tests/integration/crew-pii-page.test.ts @@ -0,0 +1,87 @@ +/** + * Integration test for the server-side PII masking on the crew profile page. + * Identity-document numbers (Aadhaar/PAN) must be masked BEFORE they cross to the + * client component — full only for Accounts/SuperUser (Crewing-Implementation-Spec + * §6 / Roles-and-Permissions §3). We invoke the server component and inspect the + * props it hands to , so a regression that passes raw numbers to the + * client is caught here. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import React from "react"; + +// The integration runner compiles the page's JSX to classic React.createElement +// without injecting React; provide it so invoking the server component works. +(globalThis as unknown as { React: typeof React }).React = React; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); +// The client component is irrelevant to this test — we read element.props directly. +vi.mock("@/app/(portal)/crewing/crew/[id]/crew-profile", () => ({ CrewProfile: () => null })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import CrewProfilePage from "@/app/(portal)/crewing/crew/[id]/page"; +import { makeSession } from "./helpers"; +import type { Role } from "@prisma/client"; + +const AADHAAR = "123456789012"; +const PAN = "ABCDE1234F"; + +let crewId: string; +const as = (role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(`u-${role}`, role)); + +// Pull the documents prop the page would pass to the client component. +async function docsFor(role: Role) { + as(role); + const element = (await CrewProfilePage({ params: Promise.resolve({ id: crewId }) })) as { + props: { documents: Array<{ docType: string; number: string | null }> }; + }; + return element.props.documents; +} +const numberFor = (docs: Array<{ docType: string; number: string | null }>, docType: string) => + docs.find((d) => d.docType === docType)?.number ?? null; + +beforeAll(async () => { + const c = await db.crewMember.create({ + data: { name: "PII Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-PII${Date.now() % 100000}` }, + }); + crewId = c.id; + await db.seafarerDocument.createMany({ + data: [ + { crewMemberId: c.id, docType: "AADHAAR", number: AADHAAR }, + { crewMemberId: c.id, docType: "PAN", number: PAN }, + { crewMemberId: c.id, docType: "PASSPORT", number: "P1234567" }, + ], + }); +}); + +afterEach(() => vi.clearAllMocks()); + +afterAll(async () => { + await db.seafarerDocument.deleteMany({ where: { crewMemberId: crewId } }); + await db.crewMember.deleteMany({ where: { id: crewId } }); +}); + +describe("crew profile — identity-document masking (server-side)", () => { + it("masks Aadhaar/PAN for a MANAGER", async () => { + const docs = await docsFor("MANAGER"); + expect(numberFor(docs, "AADHAAR")).toBe("•••• 9012"); + expect(numberFor(docs, "PAN")).toBe("•••• 234F"); + // Non-identity documents are not restricted. + expect(numberFor(docs, "PASSPORT")).toBe("P1234567"); + }); + + it("masks Aadhaar/PAN for SITE_STAFF and the MPO too", async () => { + expect(numberFor(await docsFor("SITE_STAFF"), "AADHAAR")).toBe("•••• 9012"); + expect(numberFor(await docsFor("MANNING"), "PAN")).toBe("•••• 234F"); + }); + + it("shows Aadhaar/PAN in full to ACCOUNTS and SUPERUSER", async () => { + const acc = await docsFor("ACCOUNTS"); + expect(numberFor(acc, "AADHAAR")).toBe(AADHAAR); + expect(numberFor(acc, "PAN")).toBe(PAN); + expect(numberFor(await docsFor("SUPERUSER"), "AADHAAR")).toBe(AADHAAR); + }); +}); diff --git a/App/tests/integration/crew-records.test.ts b/App/tests/integration/crew-records.test.ts index 0f8bdd1..4fb9c14 100644 --- a/App/tests/integration/crew-records.test.ts +++ b/App/tests/integration/crew-records.test.ts @@ -13,7 +13,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { uploadDocument, deleteDocument, saveBankEpf, - addNextOfKin, issuePpe, returnPpe, addExperience, + addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, } from "@/app/(portal)/crewing/crew/actions"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; @@ -67,6 +67,8 @@ describe("documents", () => { expect("ok" in (await deleteDocument(doc.id))).toBe(true); expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0); + // Deletions of PII-bearing records are audited (M3). + expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1); }); it("is rejected for a role without upload_crew_records (accounts)", async () => { @@ -97,6 +99,10 @@ describe("next of kin", () => { expect("ok" in (await addNextOfKin(fd({ crewMemberId: id, name: "Spouse", relationship: "Wife", isEmergency: "true" })))).toBe(true); const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } }); expect(nok.isEmergency).toBe(true); + // Removal is audited (M3). + expect("ok" in (await deleteNextOfKin(nok.id))).toBe(true); + expect(await db.nextOfKin.count({ where: { crewMemberId: id } })).toBe(0); + expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1); }); }); diff --git a/App/tests/integration/crewing-gates.test.ts b/App/tests/integration/crewing-gates.test.ts new file mode 100644 index 0000000..429c626 --- /dev/null +++ b/App/tests/integration/crewing-gates.test.ts @@ -0,0 +1,213 @@ +/** + * Integration tests that lock in the Manager-only "return/decline" gates and the + * remaining verification gates across the crewing pipeline — the reconciliation + * rulings most likely to regress silently: + * - R8: salary/selection approval (and their *returns*) are Manager-only. + * - R2: an interview waiver can never reach a NEW candidate by any path. + * - R11/§8.11: PPE / next-of-kin verify gates (MPO) + bank reject-with-remarks. + * - §5.4/H3: only an MPO_VERIFIED appraisal can be Manager-approved. + * Forward happy-paths are already covered by applications/verification/appraisal + * suites; these focus on the negative and role-gating edges. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { + returnSalary, + returnSelection, + requestInterviewWaiver, + declineInterviewWaiver, +} from "@/app/(portal)/crewing/applications/actions"; +import { verifyBankEpf, verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions"; +import { raiseAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { ApplicationStage, GateResult, Role } from "@prisma/client"; + +let managerId: string; +let manningId: string; +let accountsId: string; +let siteStaffId: string; +let rankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itgates.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +let seq = 0; +async function applicationAt( + stage: ApplicationStage, + opts: { type?: "NEW" | "EX_HAND"; interviewResult?: "PENDING" | "ACCEPTED" } = {} +) { + seq += 1; + const req = await db.requisition.create({ data: { code: `REQ-G${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } }); + const cand = await db.crewMember.create({ + data: { + name: opts.type === "EX_HAND" ? "Ex G" : "New G", + type: opts.type ?? "NEW", + status: opts.type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", + source: opts.type === "EX_HAND" ? "EX_HAND" : "CAREERS", + appliedRankId: rankId, + }, + }); + const app = await db.application.create({ + data: { requisitionId: req.id, crewMemberId: cand.id, stage, type: opts.type ?? "NEW", interviewResult: opts.interviewResult ?? "PENDING" }, + }); + return { appId: app.id, reqId: req.id, candId: cand.id }; +} + +const gate = (applicationId: string, gateType: "SALARY" | "SELECTION" | "WAIVER", result: GateResult = "PENDING") => + db.applicationGate.create({ data: { applicationId, gate: gateType, result } }); + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + manningId = (await getSeedUser("manning@pelagia.local")).id; + accountsId = (await getSeedUser("accounts@pelagia.local")).id; + const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITGATES-SS", email: SS_EMAIL, name: "SS Gates", role: "SITE_STAFF" } }); + siteStaffId = ss.id; + rankId = (await db.rank.findFirstOrThrow()).id; + vesselId = (await db.vessel.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.appraisal.deleteMany({}); + await db.salaryStructure.deleteMany({}); + await db.applicationGate.deleteMany({}); + await db.referenceCheck.deleteMany({}); + await db.application.deleteMany({}); + await db.nextOfKin.deleteMany({}); + await db.ppeIssue.deleteMany({}); + await db.bankDetail.deleteMany({}); + await db.epfDetail.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.requisition.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("salary return is Manager-only and audited (R8)", () => { + it("MPO cannot return salary; Manager needs a reason; reason rejects the SALARY gate", async () => { + const { appId } = await applicationAt("SALARY_AGREEMENT"); + await db.salaryStructure.create({ data: { applicationId: appId, rateBasis: "MONTHLY", basic: 60000 } }); + await gate(appId, "SALARY"); + + as(manningId, "MANNING"); + expect(await returnSalary(appId, "Too high")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("error" in (await returnSalary(appId, " "))).toBe(true); // reason required + expect("ok" in (await returnSalary(appId, "Re-negotiate basic"))).toBe(true); + + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SALARY" } })).result).toBe("REJECTED"); + // Audited as a return, not as a forward "salary agreed". + expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SALARY_RETURNED" } })).toBe(1); + }); +}); + +describe("selection return is Manager-only (R8)", () => { + it("MPO cannot return a selection; Manager return resets the interview result and rejects the gate", async () => { + const { appId } = await applicationAt("INTERVIEW", { interviewResult: "ACCEPTED" }); + await gate(appId, "SELECTION"); + + as(manningId, "MANNING"); + expect(await returnSelection(appId, "Reconsider")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("ok" in (await returnSelection(appId, "Pending references"))).toBe(true); + const app = await db.application.findUniqueOrThrow({ where: { id: appId } }); + expect(app.interviewResult).toBe("PENDING"); + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SELECTION" } })).result).toBe("REJECTED"); + expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SELECTION_RETURNED" } })).toBe(1); + }); +}); + +describe("interview waiver can never reach a NEW candidate (R2)", () => { + it("the Manager cannot request a waiver (no request_interview_waiver) and NEW stays un-waived", async () => { + const { appId } = await applicationAt("INTERVIEW", { type: "NEW" }); + // Manager lacks request_interview_waiver entirely. + as(managerId, "MANAGER"); + expect(await requestInterviewWaiver(appId)).toEqual({ error: "Unauthorized" }); + // MPO can request, but the candidate type blocks it for a NEW hand. + as(manningId, "MANNING"); + expect("error" in (await requestInterviewWaiver(appId))).toBe(true); + expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).interviewWaived).toBe(false); + }); + + it("declining a waiver is Manager-only, needs a reason, and rejects the WAIVER gate", async () => { + const { appId } = await applicationAt("INTERVIEW", { type: "EX_HAND" }); + await gate(appId, "WAIVER"); + + as(manningId, "MANNING"); + expect(await declineInterviewWaiver(appId, "No")).toEqual({ error: "Unauthorized" }); + + as(managerId, "MANAGER"); + expect("error" in (await declineInterviewWaiver(appId, " "))).toBe(true); // reason required + expect("ok" in (await declineInterviewWaiver(appId, "Interview required"))).toBe(true); + expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "WAIVER" } })).result).toBe("REJECTED"); + expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "WAIVER_DECLINED" } })).toBe(1); + }); +}); + +describe("bank verification reject path (Accounts, §8.11)", () => { + it("rejecting bank details requires remarks and sets REJECTED", async () => { + const c = await db.crewMember.create({ data: { name: "Bank Reject", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "999", ifsc: "ICIC0001" } }); + + as(accountsId, "ACCOUNTS"); + expect("error" in (await verifyBankEpf(c.id, "bank", false))).toBe(true); // remarks required + expect("ok" in (await verifyBankEpf(c.id, "bank", false, "Name mismatch"))).toBe(true); + expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: c.id } })).verificationStatus).toBe("REJECTED"); + }); +}); + +describe("PPE & next-of-kin verify gates (MPO, §8.11 follow-up)", () => { + it("MPO verifies a next-of-kin record; site staff and Accounts cannot", async () => { + const c = await db.crewMember.create({ data: { name: "NoK Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse", relationship: "Wife", isEmergency: true } }); + + as(siteStaffId, "SITE_STAFF"); + expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" }); + as(accountsId, "ACCOUNTS"); + expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" }); + + as(manningId, "MANNING"); + expect("ok" in (await verifyNextOfKin(nok.id, true))).toBe(true); + expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nok.id } })).verificationStatus).toBe("VERIFIED"); + }); + + it("MPO rejects a PPE issue only with remarks", async () => { + const c = await db.crewMember.create({ data: { name: "PPE Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "BOILER_SUIT", size: "L" } }); + + as(manningId, "MANNING"); + expect("error" in (await verifyPpe(ppe.id, false))).toBe(true); // remarks required + expect("ok" in (await verifyPpe(ppe.id, false, "Wrong size logged"))).toBe(true); + expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).verificationStatus).toBe("REJECTED"); + }); +}); + +describe("appraisal approval requires MPO verification first (H3)", () => { + it("a SUBMITTED appraisal cannot be Manager-approved without MPO verification", async () => { + const c = await db.crewMember.create({ data: { name: "Appraisee G", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const assignment = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } }); + as(siteStaffId, "SITE_STAFF"); + const raised = await raiseAppraisal(fd({ assignmentId: assignment.id, period: "2026", competence: "4", conduct: "4", safety: "4" })); + if (!("ok" in raised)) throw new Error("raise failed"); + + // Straight to Manager approve, skipping MPO verify → blocked by the state machine. + as(managerId, "MANAGER"); + expect("error" in (await approveAppraisal(raised.id!, true))).toBe(true); + expect((await db.appraisal.findUniqueOrThrow({ where: { id: raised.id! } })).status).toBe("SUBMITTED"); + }); +}); diff --git a/App/tests/integration/epfo.test.ts b/App/tests/integration/epfo.test.ts new file mode 100644 index 0000000..4d48f5c --- /dev/null +++ b/App/tests/integration/epfo.test.ts @@ -0,0 +1,93 @@ +/** + * EPFO assisted-verification coverage: + * - the EpfoService deterministic STUB contract the app relies on (no live + * portal): OTP 000000 → matched; UAN/OTP validation; session expiry. + * - the Next proxy routes' verify_bank_epf permission gate (§6) — only Accounts + * (or SuperUser) may reach the upstream service. + * No EPFO_LIVE, no running service: the stub logic is imported directly and the + * upstream fetch is mocked. + */ +import { vi, describe, it, expect, beforeEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { auth } from "@/auth"; +import { POST as otpPOST } from "@/app/api/epfo/otp/route"; +import { POST as verifyPOST } from "@/app/api/epfo/route"; +import { stubOtp, stubVerify, isUan, STUB_MATCH_OTP } from "../../../EpfoService/src/stub"; +import { makeSession } from "./helpers"; +import type { NextRequest } from "next/server"; +import type { Role } from "@prisma/client"; + +const UAN = "100200300400"; +const as = (role: Role | null) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(role ? makeSession(`u-${role}`, role) : null); + +// Minimal NextRequest stand-in: the handlers only call req.json(). +const req = (body: unknown) => ({ json: async () => body } as unknown as NextRequest); + +beforeEach(() => vi.clearAllMocks()); + +describe("EpfoService stub contract", () => { + it("stubOtp validates the 12-digit UAN and opens a session", () => { + const ok = stubOtp(UAN, "sess-1"); + expect(ok.status).toBe(200); + expect(ok.body).toMatchObject({ sessionId: "sess-1", stub: true }); + expect(stubOtp("123", "sess-1").status).toBe(400); // too short + expect(stubOtp(undefined, "sess-1").status).toBe(400); + expect(isUan(UAN)).toBe(true); + expect(isUan("12345678901")).toBe(false); + }); + + it("stubVerify matches only OTP 000000 and validates session/uan/otp", () => { + const session = { uan: UAN }; + const matched = stubVerify(session, UAN, STUB_MATCH_OTP); + expect(matched.status).toBe(200); + expect(matched.body).toMatchObject({ matched: true, name: "EPFO Member (stub)", status: "ACTIVE" }); + + const wrong = stubVerify(session, UAN, "123456"); + expect(wrong.body).toMatchObject({ matched: false, name: null }); + + expect(stubVerify(undefined, UAN, STUB_MATCH_OTP).status).toBe(410); // expired/unknown session + expect(stubVerify(session, "999999999999", STUB_MATCH_OTP).status).toBe(400); // UAN mismatch + expect(stubVerify(session, UAN, "12").status).toBe(400); // OTP too short + expect(stubVerify(session, UAN, "abcd").status).toBe(400); // non-numeric OTP + }); +}); + +describe("EPFO proxy routes — verify_bank_epf gate (§6)", () => { + it("rejects an unauthenticated caller (401) on both routes", async () => { + as(null); + expect((await otpPOST(req({ uan: UAN }))).status).toBe(401); + expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(401); + }); + + it("forbids a role without verify_bank_epf (MPO → 403)", async () => { + as("MANNING"); + expect((await otpPOST(req({ uan: UAN }))).status).toBe(403); + expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(403); + }); + + it("lets Accounts through to the upstream service (mocked)", async () => { + as("ACCOUNTS"); + const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue( + new Response(JSON.stringify({ sessionId: "epfo_1", mobileHint: "••••••••", stub: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const res = await otpPOST(req({ uan: UAN })); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ sessionId: "epfo_1" }); + expect(fetchMock).toHaveBeenCalledOnce(); + fetchMock.mockRestore(); + }); + + it("validates the body before calling upstream (Accounts, missing fields → 400)", async () => { + as("ACCOUNTS"); + const fetchMock = vi.spyOn(global, "fetch"); + expect((await otpPOST(req({}))).status).toBe(400); + expect((await verifyPOST(req({ uan: UAN }))).status).toBe(400); // no sessionId/otp + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/App/tests/integration/leave-clash.test.ts b/App/tests/integration/leave-clash.test.ts new file mode 100644 index 0000000..eb481d0 --- /dev/null +++ b/App/tests/integration/leave-clash.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests for the Crewing R6 leave-clash detection + * (Crewing-Implementation-Spec §5.3 / Epic A5, Option A). The existing + * leave-attendance suite covers the all-active cases (strength 1 + a configured + * strength 2); these lock in the parts of `leaveCausesClash` that those don't + * exercise — the overlapping-leave cover subtraction and the date-overlap + * predicate — so an approved leave only auto-raises a backfill requisition when + * the *available* same-rank cover over the *window* actually drops below the + * required strength. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { applyLeave, decideLeave } from "@/app/(portal)/crewing/leave/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { Role } from "@prisma/client"; + +let managerId: string; +let siteStaffId: string; +let rankId: string; +let otherRankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itclash.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +async function makeAssignment(name: string, rId = rankId, status: "ACTIVE" | "ON_LEAVE" = "ACTIVE") { + const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + return db.crewAssignment.create({ + data: { status, signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId }, + }); +} + +// Seed a pre-existing APPROVED leave directly (bypasses the apply/decide flow so +// the window can be controlled precisely without side effects on this run). +async function approvedLeave(assignmentId: string, from: string, to: string) { + return db.leaveRequest.create({ + data: { + assignmentId, + type: "ANNUAL", + fromDate: new Date(from), + toDate: new Date(to), + status: "APPROVED", + appliedById: siteStaffId, + decidedById: managerId, + decidedAt: new Date(), + }, + }); +} + +async function applyAndApprove(assignmentId: string, from = "2026-07-01", to = "2026-07-10") { + as(siteStaffId, "SITE_STAFF"); + const res = await applyLeave(fd({ assignmentId, type: "ANNUAL", fromDate: from, toDate: to })); + if (!("ok" in res)) throw new Error("applyLeave failed"); + as(managerId, "MANAGER"); + await decideLeave(res.id!, true); +} + +const autoRaisedCount = () => db.requisition.count({ where: { autoRaised: true } }); + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + const ss = await db.user.upsert({ + where: { email: SS_EMAIL }, + update: { role: "SITE_STAFF", isActive: true }, + create: { employeeId: "ITCLASH-SS", email: SS_EMAIL, name: "SS Clash", role: "SITE_STAFF" }, + }); + siteStaffId = ss.id; + const ranks = await db.rank.findMany({ take: 2, orderBy: { name: "asc" } }); + rankId = ranks[0].id; + otherRankId = ranks[1]?.id ?? ranks[0].id; + vesselId = (await db.vessel.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.leaveRequest.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.requisition.deleteMany({}); + await db.vesselRankRequirement.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("clash — overlapping-leave cover subtraction (strength 1)", () => { + it("auto-raises when the only other same-rank crew is already on OVERLAPPING approved leave", async () => { + const a = await makeAssignment("Going On Leave"); + const b = await makeAssignment("Already On Leave"); + // B is already away across A's window → B is not available cover. + await approvedLeave(b.id, "2026-07-05", "2026-07-20"); + + await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); + + expect(await autoRaisedCount()).toBe(1); + const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } }); + expect(req.reason).toBe("LEAVE"); + expect(req.rankId).toBe(rankId); + expect(req.vesselId).toBe(vesselId); + }); + + it("does NOT auto-raise when the other crew's approved leave does NOT overlap the window", async () => { + const a = await makeAssignment("Going On Leave"); + const b = await makeAssignment("Away Later"); + // B's leave is in August — it does not overlap A's July window, so B still + // covers the rank during A's absence. + await approvedLeave(b.id, "2026-08-01", "2026-08-31"); + + await applyAndApprove(a.id, "2026-07-01", "2026-07-10"); + + expect(await autoRaisedCount()).toBe(0); + }); +}); + +describe("clash — rank + strength scoping", () => { + it("ignores cover from a DIFFERENT rank on the same vessel", async () => { + const a = await makeAssignment("Solo In Rank"); + // A different-rank crew member is not cover for A's rank. + await makeAssignment("Other Rank", otherRankId); + + await applyAndApprove(a.id); + + // With no same-rank cover left, the default-strength-1 clash fires + // (unless the two seeded ranks happen to be identical in a thin DB). + expect(await autoRaisedCount()).toBe(rankId === otherRankId ? 0 : 1); + }); + + it("does NOT auto-raise while configured strength is still met after the leave", async () => { + // Require 2; keep 3 active so one going on leave still leaves 2 cover. + await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } }); + const a = await makeAssignment("Going On Leave"); + await makeAssignment("Stays A"); + await makeAssignment("Stays B"); + + await applyAndApprove(a.id); + + expect(await autoRaisedCount()).toBe(0); + }); +}); diff --git a/App/tests/integration/onboarding.test.ts b/App/tests/integration/onboarding.test.ts index 4e833ff..6e20488 100644 --- a/App/tests/integration/onboarding.test.ts +++ b/App/tests/integration/onboarding.test.ts @@ -88,6 +88,29 @@ describe("onboardCandidate", () => { const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } }); expect(action.actorId).toBe(managerId); + // D3 AC2: the audit row records the created IDs in metadata. + const meta = action.metadata as { assignmentId?: string; employeeId?: string; salaryStructureId?: string } | null; + expect(meta?.assignmentId).toBe(assignment.id); + expect(meta?.employeeId).toBe(cm.employeeId); + expect(meta?.salaryStructureId).toBe(sal.id); + }); + + it("blocks onboarding when no salary structure is Manager-approved (D1)", async () => { + seq += 1; + const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } }); + const cand = await db.crewMember.create({ data: { name: "Unapproved Sal", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } }); + const appRow = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } }); + // Salary agreed but NOT Manager-approved (approvedById null). + await db.salaryStructure.create({ data: { applicationId: appRow.id, rateBasis: "MONTHLY", basic: 40000 } }); + + as(managerId, "MANAGER"); + const res = await onboardCandidate(fd({ applicationId: appRow.id, joiningDate: "2026-07-01" })); + expect("error" in res).toBe(true); + expect(await db.crewAssignment.count()).toBe(0); + // The candidate is untouched — still a CANDIDATE, no employee number. + const after = await db.crewMember.findUniqueOrThrow({ where: { id: cand.id } }); + expect(after.status).toBe("CANDIDATE"); + expect(after.employeeId).toBeNull(); }); it("requires a joining date", async () => { diff --git a/App/tests/integration/requisitions.test.ts b/App/tests/integration/requisitions.test.ts index c17fd79..d64656c 100644 --- a/App/tests/integration/requisitions.test.ts +++ b/App/tests/integration/requisitions.test.ts @@ -7,11 +7,17 @@ * so afterEach wipes them wholesale (no pre-existing rows to preserve). */ import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import React from "react"; +// The list page's JSX compiles to classic React.createElement in the node runner. +(globalThis as unknown as { React: typeof React }).React = React; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() })); +// We read the page element's props directly; the client component is irrelevant. +vi.mock("@/app/(portal)/crewing/requisitions/requisitions-manager", () => ({ RequisitionsManager: () => null })); import { auth } from "@/auth"; import { db } from "@/lib/db"; @@ -22,6 +28,7 @@ import { requestReliefCover, convertReliefToRequisition, } from "@/app/(portal)/crewing/requisitions/actions"; +import RequisitionsPage from "@/app/(portal)/crewing/requisitions/page"; import { autoRaiseRequisition } from "@/lib/requisition-service"; import { makeSession, getSeedUser, fd } from "./helpers"; import type { Role } from "@prisma/client"; @@ -52,6 +59,8 @@ beforeAll(async () => { afterEach(async () => { await db.crewAction.deleteMany({}); + await db.application.deleteMany({}); + await db.crewMember.deleteMany({}); await db.reliefRequest.deleteMany({}); await db.requisition.deleteMany({}); vi.clearAllMocks(); @@ -244,3 +253,21 @@ describe("autoRaiseRequisition (shared helper)", () => { expect(stored.actions[0].actorId).toBeNull(); }); }); + +describe("requisitions list (A3)", () => { + it("exposes a candidate count per requisition row", async () => { + as(managerId, "MANAGER"); + const req = await db.requisition.create({ data: { code: "REQ-A3", rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } }); + const empty = await db.requisition.create({ data: { code: "REQ-A3B", rankId, vesselId, reason: "LEAVE", status: "OPEN" } }); + for (const name of ["Cand A", "Cand B"]) { + const c = await db.crewMember.create({ data: { name, type: "NEW", status: "CANDIDATE", source: "CAREERS" } }); + await db.application.create({ data: { requisitionId: req.id, crewMemberId: c.id, stage: "SHORTLISTED", type: "NEW" } }); + } + + const el = (await RequisitionsPage()) as unknown as { + props: { requisitions: Array<{ id: string; candidateCount: number }> }; + }; + expect(el.props.requisitions.find((r) => r.id === req.id)?.candidateCount).toBe(2); + expect(el.props.requisitions.find((r) => r.id === empty.id)?.candidateCount).toBe(0); + }); +}); diff --git a/App/tests/unit/crew-pii.test.ts b/App/tests/unit/crew-pii.test.ts index 0301f62..48fc080 100644 --- a/App/tests/unit/crew-pii.test.ts +++ b/App/tests/unit/crew-pii.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue } from "@/lib/crew-pii"; +import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii"; // PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8). describe("crew PII masking", () => { @@ -43,4 +43,25 @@ describe("crew PII masking", () => { expect(bankEpfValue(null, "ACCOUNTS")).toBe("—"); }); }); + + describe("documentNumberValue", () => { + it("masks Aadhaar/PAN numbers for non-privileged roles", () => { + expect(documentNumberValue("123456789012", "AADHAAR", "MANAGER")).toBe("•••• 9012"); + expect(documentNumberValue("123456789012", "AADHAAR", "MANNING")).toBe("•••• 9012"); + expect(documentNumberValue("ABCDE1234F", "PAN", "SITE_STAFF")).toBe("•••• 234F"); + }); + it("shows Aadhaar/PAN in full to Accounts and SuperUser", () => { + expect(documentNumberValue("123456789012", "AADHAAR", "ACCOUNTS")).toBe("123456789012"); + expect(documentNumberValue("ABCDE1234F", "PAN", "SUPERUSER")).toBe("ABCDE1234F"); + }); + it("does not restrict non-identity documents for any role", () => { + expect(documentNumberValue("P1234567", "PASSPORT", "SITE_STAFF")).toBe("P1234567"); + expect(documentNumberValue("CDC-99", "CDC", "MANNING")).toBe("CDC-99"); + expect(documentNumberValue("STCW-1", "STCW", "MANAGER")).toBe("STCW-1"); + }); + it("returns null for an empty number regardless of type/role", () => { + expect(documentNumberValue(null, "AADHAAR", "ACCOUNTS")).toBeNull(); + expect(documentNumberValue("", "PASSPORT", "MANAGER")).toBeNull(); + }); + }); }); diff --git a/EpfoService/src/index.ts b/EpfoService/src/index.ts index ecf1070..80a719f 100644 --- a/EpfoService/src/index.ts +++ b/EpfoService/src/index.ts @@ -18,6 +18,7 @@ */ import express from "express"; import type { Browser, BrowserContext, Page } from "playwright"; +import { isUan, mobileHint, stubOtp, stubVerify } from "./stub"; const PORT = Number(process.env.PORT ?? 3004); const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min @@ -65,9 +66,6 @@ async function getBrowser(): Promise { return _browser; } -const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s); -const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••"); - // ── App ──────────────────────────────────────────────────────────────────────── const app = express(); @@ -80,16 +78,19 @@ app.get("/health", (_req, res) => { /** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */ app.post("/otp", async (req, res) => { const { uan } = req.body ?? {}; - if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" }); - const sessionId = newSessionId(); if (!LIVE) { - sessions.set(sessionId, { uan, createdAt: Date.now() }); - log("INFO", "OTP requested (stub)", { sessionId }); - return res.json({ sessionId, mobileHint: mobileHint(), stub: true }); + const r = stubOtp(uan, sessionId); + if (r.ok) { + sessions.set(sessionId, { uan, createdAt: Date.now() }); + log("INFO", "OTP requested (stub)", { sessionId }); + } + return res.status(r.status).json(r.body); } + if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" }); + try { const browser = await getBrowser(); const context = await browser.newContext(); @@ -109,19 +110,20 @@ app.post("/otp", async (req, res) => { /** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */ app.post("/verify", async (req, res) => { const { sessionId, uan, otp } = req.body ?? {}; - const s = sessionId && sessions.get(sessionId); + const s = (sessionId && sessions.get(sessionId)) || undefined; + + if (!LIVE) { + const r = stubVerify(s, uan, otp); + // A valid handshake consumes the session (one OTP per request). + if (r.ok && sessionId) sessions.delete(sessionId); + log("INFO", "Verify (stub)", { sessionId, matched: r.body.matched }); + return res.status(r.status).json(r.body); + } + if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" }); if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" }); if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" }); - if (!LIVE) { - sessions.delete(sessionId); - // Deterministic stub: OTP 000000 → matched member; anything else → not matched. - const matched = otp === "000000"; - log("INFO", "Verify (stub)", { sessionId, matched }); - return res.json({ matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true }); - } - try { // TODO(live): submit the OTP and scrape the member record (name/DOB/status). const result = { matched: false, name: null as string | null, status: null as string | null }; diff --git a/EpfoService/src/stub.ts b/EpfoService/src/stub.ts new file mode 100644 index 0000000..de6af13 --- /dev/null +++ b/EpfoService/src/stub.ts @@ -0,0 +1,42 @@ +/** + * Pure, dependency-free EPFO stub + validation logic (no express/playwright), so + * the deterministic contract the PPMS app relies on can be unit-tested without + * launching the service. `index.ts` uses these in its stub branches, so the + * tested logic IS the production stub behaviour. + * + * Deterministic stub contract (EPFO_LIVE unset): + * /otp validates the UAN and opens a session. + * /verify validates session + UAN + OTP; matched iff OTP === STUB_MATCH_OTP. + */ + +export const STUB_MATCH_OTP = "000000"; + +export const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s); +export const isOtp = (s: unknown): s is string => typeof s === "string" && /^\d{4,8}$/.test(s); + +export const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••"); + +export interface StubResult { + ok: boolean; + status: number; + body: Record; +} + +/** Stub of POST /otp — validate the UAN and (caller-supplied) open a session. */ +export function stubOtp(uan: unknown, sessionId: string): StubResult { + if (!isUan(uan)) return { ok: false, status: 400, body: { error: "A 12-digit UAN is required" } }; + return { ok: true, status: 200, body: { sessionId, mobileHint: mobileHint(), stub: true } }; +} + +/** Stub of POST /verify — validate the session/UAN/OTP and return the match. */ +export function stubVerify(session: { uan: string } | undefined, uan: unknown, otp: unknown): StubResult { + if (!session) return { ok: false, status: 410, body: { error: "Session expired — request a new OTP" } }; + if (!isUan(uan) || session.uan !== uan) return { ok: false, status: 400, body: { error: "UAN mismatch" } }; + if (!isOtp(otp)) return { ok: false, status: 400, body: { error: "A valid OTP is required" } }; + const matched = otp === STUB_MATCH_OTP; + return { + ok: true, + status: 200, + body: { matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true }, + }; +}