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:
parent
c14a22588e
commit
df3b4bdc97
11 changed files with 318 additions and 27 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
37
App/lib/crew-login.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
115
App/tests/integration/crewing-followups.test.ts
Normal file
115
App/tests/integration/crewing-followups.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue