feat(crewing): resolve self-contained deferred follow-ups (flagged)

Clears the self-contained deferrals tracked across phases. Stacks on 5b appraisal.
Behind NEXT_PUBLIC_CREWING_ENABLED.

- SITE_STAFF login on onboard/placement (Epic D follow-up): lib/crew-login.ts
  maybeCreateSiteStaffLogin creates a passwordless SITE_STAFF User (sharing the
  CRW- employee no., siteId = the assignment's site) when a grantsLogin rank is
  onboarded (onboardCandidate) or placed (placeCrew) and the crew member has an
  email. No-op otherwise.
- Own-site scoping (Epic E follow-up, §8.7): User.siteId added (migration
  crewing_followups); the Crew directory filters a SITE_STAFF user with a home site
  to crew whose active assignment is at that site (graceful when unset). The link is
  set at login creation.
- PPE / next-of-kin verify gates (Epic F/I follow-up): PpeIssue/NextOfKin gained
  verificationStatus + verifiedById; verifyPpe / verifyNextOfKin (verify_site_records,
  MPO) + queue sections in /crewing/verification.

Tests & docs
- Integration: crewing-followups.test.ts (6) — login created/skipped by rank+email
  (+ siteId set), PPE/NoK verify + reject-reason + already-decided guard + gating.
  type-check clean; full unit (245) + integration (211) green (RESEND_API_KEY unset).
- CLAUDE.md updated.

Part of Epic D (#78), Epic E (#79), Epic F (#80), Epic I (#83).
Still deferred (not self-contained): public careers API (A2); Pay-status pay rows (Phase 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 22:28:23 +05:30
parent c14a22588e
commit df3b4bdc97
11 changed files with 318 additions and 27 deletions

View file

@ -200,6 +200,12 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Three surfaces** (§8.14): the PM raises + sees status on the crew profile **Appraisals** tab; the MPO verifies in the **Verification** queue (Appraisals section); the Manager approves in the central **/approvals** queue (Appraisal kind).
- This completes **Phase 5** (I + H). Remaining roadmap: **Phase 6** — payroll (Pay-status tab + Approvals "Wage"), dashboards, notifications (J, M).
**Crewing follow-ups (resolved deferrals):** the self-contained deferrals from earlier phases are now done:
- **SITE_STAFF login on onboard/placement**`lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site.
- **Own-site scoping (§8.7)**`User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above.
- **PPE / next-of-kin verify gates**`PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`.
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
### GST Calculation
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.

View file

@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
@ -156,6 +157,8 @@ export async function placeCrew(formData: FormData): Promise<ActionResult> {
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
await tx.crewMember.update({ where: { id: crew.id }, data });
await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } });
// Management ranks (grantsLogin) become a SITE_STAFF login on placement.
await maybeCreateSiteStaffLogin(tx, { name: crew.name, email: crew.email, employeeId: data.employeeId ?? crew.employeeId }, d.rankId, d.siteId || null);
});
revalidatePath(PATH);

View file

@ -12,6 +12,7 @@ import {
} from "@/lib/application-pipeline";
import { getManagerRecipients } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client";
@ -556,7 +557,7 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
where: { id },
include: {
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
crewMember: { select: { id: true } },
crewMember: { select: { id: true, name: true, email: true } },
},
});
if (!app) return { error: "Application not found" };
@ -593,6 +594,8 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
where: { id: app.crewMember.id },
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
});
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
return { assignmentId: assignment.id, employeeId };
});

View file

@ -15,10 +15,18 @@ export default async function CrewPage() {
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
// NOTE: site-staff "own site only" scoping (§8.7) needs a User↔Site link that
// isn't modelled yet — deferred to a follow-up; for now all active crew show.
// Own-site scoping (§8.7): a site-staff user with a home site sees only crew whose
// active assignment is at that site. Without a home site they remain unscoped.
let siteScopeId: string | null = null;
if (session.user.role === "SITE_STAFF") {
siteScopeId = (await db.user.findUnique({ where: { id: session.user.id }, select: { siteId: true } }))?.siteId ?? null;
}
const crew = await db.crewMember.findMany({
where: { status: "EMPLOYEE" },
where: {
status: "EMPLOYEE",
...(siteScopeId ? { assignments: { some: { status: { not: "SIGNED_OFF" }, siteId: siteScopeId } } } : {}),
},
orderBy: { name: "asc" },
include: {
currentRank: { select: { name: true } },

View file

@ -82,3 +82,53 @@ export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf",
revalidatePath(`/crewing/crew/${crewMemberId}`);
return { ok: true };
}
// ── PPE / next-of-kin verification (MPO) ───────────────────────────────────────
async function verifyRecord(
load: () => Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>,
set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise<unknown>,
recordLabel: string,
approve: boolean,
remarks: string | undefined,
userId: string
): Promise<ActionResult> {
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const rec = await load();
if (!rec) return { error: "Record not found" };
if (rec.verificationStatus !== "PENDING") return { error: `This record is already ${rec.verificationStatus.toLowerCase()}` };
await set(approve ? "VERIFIED" : "REJECTED", userId);
await db.crewAction.create({
data: { actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED", actorId: userId, crewMemberId: rec.crewMemberId, note: remarks?.trim() || null, metadata: { record: recordLabel } },
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${rec.crewMemberId}`);
return { ok: true };
}
export async function verifyPpe(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
return verifyRecord(
() => db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
(status, userId) => db.ppeIssue.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
"ppe",
approve,
remarks,
g.userId
);
}
export async function verifyNextOfKin(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
return verifyRecord(
() => db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
(status, userId) => db.nextOfKin.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
"next_of_kin",
approve,
remarks,
g.userId
);
}

View file

@ -19,7 +19,7 @@ export default async function VerificationPage() {
const canAppraisals = hasPermission(role, "verify_appraisal");
if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard");
const [docs, bank, epf, appraisals] = await Promise.all([
const [docs, bank, epf, appraisals, ppe, nok] = await Promise.all([
canDocs
? db.seafarerDocument.findMany({
where: { verificationStatus: "PENDING" },
@ -47,6 +47,12 @@ export default async function VerificationPage() {
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})
: [],
canDocs
? db.ppeIssue.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { issuedDate: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
canDocs
? db.nextOfKin.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
]);
return (
@ -66,6 +72,8 @@ export default async function VerificationPage() {
bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))}
epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))}
appraisals={appraisals.map((a) => ({ id: a.id, crewName: a.assignment.crewMember.name, rank: a.assignment.rank.name, period: a.period, comments: a.comments }))}
ppe={ppe.map((p) => ({ id: p.id, crewName: p.crewMember.name, item: p.item, size: p.size }))}
nok={nok.map((n) => ({ id: n.id, crewName: n.crewMember.name, name: n.name, relationship: n.relationship }))}
canDocs={canDocs}
canBankEpf={canBankEpf}
canAppraisals={canAppraisals}

View file

@ -4,8 +4,9 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { SeafarerDocType } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { verifyDocument, verifyBankEpf } from "./actions";
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin } from "./actions";
import { verifyAppraisal } from "../appraisals/actions";
import type { PpeItem } from "@prisma/client";
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
@ -15,6 +16,8 @@ type Doc = { id: string; crewName: string; location: string; docType: SeafarerDo
type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null };
type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null };
type Appr = { id: string; crewName: string; rank: string; period: string; comments: string | null };
type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null };
type Nok = { id: string; crewName: string; name: string; relationship: string | null };
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
@ -71,7 +74,7 @@ function Card({ title, sub, empty, children }: { title: string; sub: string; emp
);
}
export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) {
export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) {
return (
<div className="max-w-4xl">
<div className="mb-6">
@ -99,6 +102,42 @@ export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canB
</Card>
)}
{canDocs && (
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{ppe.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canDocs && (
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{nok.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canBankEpf && (
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">

37
App/lib/crew-login.ts Normal file
View file

@ -0,0 +1,37 @@
import type { Prisma } from "@prisma/client";
// Promote a crew member to a portal login when their rank grants one (PM /
// Assistant PM / Site In-charge — Rank.grantsLogin, spec §3/§4.1). Called from
// onboarding and direct placement, inside their transaction. Creates a SITE_STAFF
// User with no password (set later via the profile / SSO). No-op when the rank
// doesn't grant a login, the crew member has no email/employee no., or a matching
// user already exists. Returns true when a login was created.
export async function maybeCreateSiteStaffLogin(
tx: Prisma.TransactionClient,
crew: { name: string; email: string | null; employeeId: string | null },
rankId: string,
siteId?: string | null
): Promise<boolean> {
const rank = await tx.rank.findUnique({ where: { id: rankId }, select: { grantsLogin: true } });
if (!rank?.grantsLogin) return false;
if (!crew.email || !crew.employeeId) return false;
const existing = await tx.user.findFirst({
where: { OR: [{ email: crew.email }, { employeeId: crew.employeeId }] },
select: { id: true },
});
if (existing) return false;
await tx.user.create({
data: {
employeeId: crew.employeeId,
email: crew.email,
name: crew.name,
role: "SITE_STAFF",
passwordHash: null,
siteId: siteId ?? null,
},
});
return true;
}

View file

@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "NextOfKin" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
ADD COLUMN "verifiedById" TEXT;
-- AlterTable
ALTER TABLE "PpeIssue" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
ADD COLUMN "verifiedById" TEXT;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "siteId" TEXT;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -318,6 +318,10 @@ model User {
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
reliefRequested ReliefRequest[] @relation("ReliefRequester")
crewActions CrewAction[]
// Site-staff home site (Crewing §8.7 own-site scoping). Null = unscoped.
siteId String?
site Site? @relation(fields: [siteId], references: [id])
}
model SuperUserRequest {
@ -349,6 +353,7 @@ model Site {
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
staff User[]
}
model Vessel {
@ -1040,15 +1045,17 @@ model SeafarerDocument {
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
model NextOfKin {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
name String
relationship String?
phone String?
address String?
isEmergency Boolean @default(false)
createdAt DateTime @default(now())
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
name String
relationship String?
phone String?
address String?
isEmergency Boolean @default(false)
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
verifiedById String?
createdAt DateTime @default(now())
}
// A tour-of-duty experience row — added manually or auto-appended at sign-off
@ -1070,15 +1077,17 @@ model ExperienceRecord {
// PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate`
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
model PpeIssue {
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
item PpeItem
size String?
quantity Int @default(1)
issuedDate DateTime @default(now())
returnedDate DateTime?
issuedById String?
comment String?
createdAt DateTime @default(now())
id String @id @default(cuid())
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
item PpeItem
size String?
quantity Int @default(1)
issuedDate DateTime @default(now())
returnedDate DateTime?
issuedById String?
comment String?
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
verifiedById String?
createdAt DateTime @default(now())
}

View file

@ -0,0 +1,115 @@
/**
* Integration tests for the self-contained crewing follow-ups:
* - SITE_STAFF login creation on placement/onboarding (grantsLogin ranks)
* - PPE / next-of-kin verification gates
* (Own-site scoping is exercised via the siteId set on the created login.)
*/
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 { placeCrew } from "@/app/(portal)/admin/crew/actions";
import { verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let manningId: string;
let accountsId: string;
let loginRankId: string;
let plainRankId: string;
let siteId: string;
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
const LOGIN_EMAIL = "pmlogin.itfu@example.local";
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
manningId = (await getSeedUser("manning@pelagia.local")).id;
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
loginRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: true } })).id;
plainRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: false } })).id;
siteId = (await db.site.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.ppeIssue.deleteMany({});
await db.nextOfKin.deleteMany({});
await db.crewAssignment.deleteMany({});
await db.crewMember.deleteMany({});
await db.user.deleteMany({ where: { email: LOGIN_EMAIL } });
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: LOGIN_EMAIL } });
});
describe("SITE_STAFF login on placement (grantsLogin ranks)", () => {
it("creates a SITE_STAFF login (with home site) for a management-rank placement", async () => {
const c = await db.crewMember.create({ data: { name: "New PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } });
as(managerId, "MANAGER");
expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true);
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } });
const login = await db.user.findUniqueOrThrow({ where: { email: LOGIN_EMAIL } });
expect(login.role).toBe("SITE_STAFF");
expect(login.employeeId).toBe(after.employeeId); // shares the CRW- number
expect(login.passwordHash).toBeNull();
expect(login.siteId).toBe(siteId); // own-site link set at creation
});
it("creates no login for a non-login rank", async () => {
const c = await db.crewMember.create({ data: { name: "Deck Hand", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } });
as(managerId, "MANAGER");
await placeCrew(fd({ crewMemberId: c.id, rankId: plainRankId, siteId, signOnDate: "2026-07-01" }));
expect(await db.user.findUnique({ where: { email: LOGIN_EMAIL } })).toBeNull();
});
it("skips the login when the crew member has no email (placement still succeeds)", async () => {
const c = await db.crewMember.create({ data: { name: "No Email PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN" } });
as(managerId, "MANAGER");
expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true);
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
});
});
describe("PPE / next-of-kin verification (MPO)", () => {
async function crewWithRecords() {
const c = await db.crewMember.create({ data: { name: "Verify Me", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "HELMET" } });
const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse" } });
return { ppeId: ppe.id, nokId: nok.id };
}
it("MPO verifies PPE and next-of-kin", async () => {
const { ppeId, nokId } = await crewWithRecords();
as(manningId, "MANNING");
expect("ok" in (await verifyPpe(ppeId, true))).toBe(true);
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppeId } })).verificationStatus).toBe("VERIFIED");
expect("ok" in (await verifyNextOfKin(nokId, true))).toBe(true);
expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nokId } })).verificationStatus).toBe("VERIFIED");
});
it("rejection requires a reason; already-decided is guarded", async () => {
const { ppeId } = await crewWithRecords();
as(manningId, "MANNING");
expect("error" in (await verifyPpe(ppeId, false))).toBe(true);
expect("ok" in (await verifyPpe(ppeId, false, "Wrong size"))).toBe(true);
expect("error" in (await verifyPpe(ppeId, true))).toBe(true); // already rejected
});
it("is rejected for roles without verify_site_records (accounts)", async () => {
const { ppeId } = await crewWithRecords();
as(accountsId, "ACCOUNTS");
expect(await verifyPpe(ppeId, true)).toEqual({ error: "Unauthorized" });
});
});