Merge pull request 'feat(crewing): review hardening — PII mask, vetting gates, audit/atomicity, ex-hand, A3, EPFO tests' (#90) from feat/crewing-review-hardening into feat/crewing-epfo
Reviewed-on: #90
This commit is contained in:
commit
86fe23167c
25 changed files with 1104 additions and 79 deletions
|
|
@ -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({
|
||||
|
|
@ -332,7 +369,7 @@ export async function returnSalary(id: string, reason: string): Promise<ActionRe
|
|||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
||||
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
||||
});
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
|
|
@ -449,7 +486,7 @@ export async function declineInterviewWaiver(id: string, reason: string): Promis
|
|||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
||||
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
||||
});
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
|
|
@ -499,7 +536,7 @@ export async function returnSelection(id: string, reason: string): Promise<Actio
|
|||
await db.$transaction(async (tx) => {
|
||||
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<ActionResult
|
|||
});
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
|
||||
|
||||
// D1 (spec §8.5): onboarding is blocked until the salary structure is
|
||||
// Manager-approved. Without this guard a SELECTED application that somehow has
|
||||
// no approved structure would still "succeed" but bind zero salary rows
|
||||
// (the updateMany below would match nothing) — a silent payroll gap.
|
||||
const approvedSalary = await db.salaryStructure.findFirst({
|
||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
|
||||
|
||||
const joiningDate = new Date(joiningStr);
|
||||
|
||||
// Upload the optional contract letter BEFORE the transaction (storage I/O),
|
||||
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
|
||||
// event (spec §11). The blob key is keyed on the crew member (stable before the
|
||||
// assignment exists); if the tx fails we leave only a harmless orphan blob,
|
||||
// never a fully-onboarded crew member with no contract row.
|
||||
const file = formData.get("contract");
|
||||
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
|
||||
if (file instanceof File && file.size > 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<ActionResult
|
|||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
|
||||
});
|
||||
if (contract) {
|
||||
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
|
||||
}
|
||||
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: { stage: "ONBOARDED", actions: { create: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
|
||||
data: {
|
||||
stage: "ONBOARDED",
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "CREW_ONBOARDED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: app.crewMember.id,
|
||||
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.requisition.update({
|
||||
where: { id: app.requisition.id },
|
||||
|
|
@ -599,16 +675,6 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
|||
return { assignmentId: assignment.id, employeeId };
|
||||
});
|
||||
|
||||
// Contract letter (optional) — stored after the core transaction.
|
||||
const file = formData.get("contract");
|
||||
if (file instanceof File && file.size > 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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
||||
<strong>Returning crew.</strong> 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).
|
||||
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
||||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||||
<span>No prior records are on file yet.</span>
|
||||
) : (
|
||||
<span>Prior records on file from earlier assignments:</span>
|
||||
)}
|
||||
|
||||
{c.experienceRecords.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
|
||||
<ul className="space-y-1">
|
||||
{c.experienceRecords.map((e) => (
|
||||
<li key={e.id} className="text-sm text-purple-900">
|
||||
{e.rank?.name ?? "—"}
|
||||
{e.vesselType ? ` · ${e.vesselType}` : ""}
|
||||
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
|
||||
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.documents.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.documents.map((doc) => (
|
||||
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
|
||||
{doc.docType}
|
||||
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,45 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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 <CandidatesManager candidates={rows} ranks={ranks} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ActionResult>
|
|||
export async function deleteDocument(id: string): Promise<ActionResult> {
|
||||
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<ActionResult> {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
|
|
@ -106,6 +119,18 @@ export function RequisitionsManager({
|
|||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
|
||||
<option value="ALL">All ranks</option>
|
||||
{rankNames.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||
<option value="ALL">All reasons</option>
|
||||
{reasons.map((r) => (
|
||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Requisitions table */}
|
||||
|
|
@ -117,6 +142,7 @@ export function RequisitionsManager({
|
|||
<th className="px-4 py-3">Vessel / site</th>
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Reason</th>
|
||||
<th className="px-4 py-3">Candidates</th>
|
||||
<th className="px-4 py-3">Raised by</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
|
|
@ -124,7 +150,7 @@ export function RequisitionsManager({
|
|||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
||||
No requisitions match these filters.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -145,6 +171,7 @@ export function RequisitionsManager({
|
|||
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
|
||||
<td className="px-4 py-3 text-neutral-700 tabular-nums">{r.candidateCount}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||
|
|
|
|||
|
|
@ -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<SeafarerDocType>(["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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,18 +89,9 @@ export function getManagerRecipients(): Promise<User[]> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<NewRequisitionInput, "raisedById" | "autoRaised">
|
||||
): Promise<RequisitionWithRefs> {
|
||||
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<void> {
|
||||
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<NewRequisitionInput, "raisedById" | "autoRaised">,
|
||||
tx?: Tx
|
||||
): Promise<RequisitionWithRefs> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
87
App/tests/integration/crew-pii-page.test.ts
Normal file
87
App/tests/integration/crew-pii-page.test.ts
Normal file
|
|
@ -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 <CrewProfile>, 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<unknown>).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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
213
App/tests/integration/crewing-gates.test.ts
Normal file
213
App/tests/integration/crewing-gates.test.ts
Normal file
|
|
@ -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<unknown>).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");
|
||||
});
|
||||
});
|
||||
93
App/tests/integration/epfo.test.ts
Normal file
93
App/tests/integration/epfo.test.ts
Normal file
|
|
@ -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<unknown>).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();
|
||||
});
|
||||
});
|
||||
149
App/tests/integration/leave-clash.test.ts
Normal file
149
App/tests/integration/leave-clash.test.ts
Normal file
|
|
@ -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<unknown>).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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Browser> {
|
|||
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 };
|
||||
|
|
|
|||
42
EpfoService/src/stub.ts
Normal file
42
EpfoService/src/stub.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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 },
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue