feat(crewing): Phase 5a — verification queue (flagged) #73
8 changed files with 410 additions and 0 deletions
|
|
@ -186,6 +186,12 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
|
|||
- **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`).
|
||||
- This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications).
|
||||
|
||||
**Phase 5a — Verification (Epic I; spec §8.11/R11):** the office queue for site-entered records (Phase 5 ships as 5a verification → 5b appraisal).
|
||||
|
||||
- **Actions** (`crewing/verification/actions.ts`): `verifyDocument(id, approve, remarks)` (`verify_site_records` — MPO/Manager) sets a `SeafarerDocument`'s `verificationStatus` + `verifiedById`; `verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)` (`verify_bank_epf` — Accounts) does the same for `BankDetail`/`EpfDetail`. Rejection requires remarks; both write a `CrewAction` (`RECORD_VERIFIED`/`RECORD_REJECTED`). No new models — the verification fields already existed (3b/4a).
|
||||
- **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7).
|
||||
- **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models).
|
||||
|
||||
### 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`.
|
||||
|
|
|
|||
84
App/app/(portal)/crewing/verification/actions.ts
Normal file
84
App/app/(portal)/crewing/verification/actions.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import type { Role } from "@prisma/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
const PATH = "/crewing/verification";
|
||||
|
||||
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||
return { userId: session.user.id, role: session.user.role };
|
||||
}
|
||||
|
||||
// ── Document verification (MPO / Manager) ──────────────────────────────────────
|
||||
|
||||
export async function verifyDocument(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_site_records");
|
||||
if ("error" in g) return g;
|
||||
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
|
||||
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } });
|
||||
if (!doc) return { error: "Document not found" };
|
||||
if (doc.verificationStatus !== "PENDING") return { error: `This document is already ${doc.verificationStatus.toLowerCase()}` };
|
||||
|
||||
await db.seafarerDocument.update({
|
||||
where: { id },
|
||||
data: { verificationStatus: approve ? "VERIFIED" : "REJECTED", verifiedById: g.userId },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: {
|
||||
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: doc.crewMemberId,
|
||||
note: remarks?.trim() || null,
|
||||
metadata: { record: "document" },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${doc.crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Bank / EPF verification (Accounts) ─────────────────────────────────────────
|
||||
|
||||
export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf", approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_bank_epf");
|
||||
if ("error" in g) return g;
|
||||
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
|
||||
const status = approve ? "VERIFIED" : "REJECTED";
|
||||
if (kind === "bank") {
|
||||
const rec = await db.bankDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||
if (!rec) return { error: "Bank details not found" };
|
||||
if (rec.verificationStatus !== "PENDING") return { error: `Bank details already ${rec.verificationStatus.toLowerCase()}` };
|
||||
await db.bankDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||
} else {
|
||||
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||
if (!rec) return { error: "EPF details not found" };
|
||||
if (rec.verificationStatus !== "PENDING") return { error: `EPF details already ${rec.verificationStatus.toLowerCase()}` };
|
||||
await db.epfDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||
}
|
||||
|
||||
await db.crewAction.create({
|
||||
data: {
|
||||
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||
actorId: g.userId,
|
||||
crewMemberId,
|
||||
note: remarks?.trim() || null,
|
||||
metadata: { record: kind },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
64
App/app/(portal)/crewing/verification/page.tsx
Normal file
64
App/app/(portal)/crewing/verification/page.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { VerificationManager } from "./verification-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Verification" };
|
||||
|
||||
export default async function VerificationPage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
const canDocs = hasPermission(role, "verify_site_records");
|
||||
const canBankEpf = hasPermission(role, "verify_bank_epf");
|
||||
if (!canDocs && !canBankEpf) redirect("/dashboard");
|
||||
|
||||
const [docs, bank, epf] = await Promise.all([
|
||||
canDocs
|
||||
? db.seafarerDocument.findMany({
|
||||
where: { verificationStatus: "PENDING" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
crewMember: {
|
||||
select: {
|
||||
name: true,
|
||||
assignments: { where: { status: { not: "SIGNED_OFF" } }, take: 1, include: { vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [],
|
||||
canBankEpf
|
||||
? db.bankDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
canBankEpf
|
||||
? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
]);
|
||||
|
||||
return (
|
||||
<VerificationManager
|
||||
docs={docs.map((d) => {
|
||||
const a = d.crewMember.assignments[0];
|
||||
return {
|
||||
id: d.id,
|
||||
crewName: d.crewMember.name,
|
||||
location: a?.vessel?.name ?? a?.site?.name ?? "—",
|
||||
docType: d.docType,
|
||||
number: d.number,
|
||||
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||
submitted: d.createdAt.toISOString(),
|
||||
};
|
||||
})}
|
||||
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 }))}
|
||||
canDocs={canDocs}
|
||||
canBankEpf={canBankEpf}
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
139
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
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() : "—");
|
||||
const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Date());
|
||||
|
||||
type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string };
|
||||
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 };
|
||||
|
||||
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
async function verify() {
|
||||
setPending(true); setError("");
|
||||
const res = await onVerify();
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else router.refresh();
|
||||
}
|
||||
async function reject(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await onReject(reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={verify} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Verify</button>
|
||||
<button onClick={() => setOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Reject</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||
<AdminDialog title="Reject record" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={reject} className="space-y-4 text-left">
|
||||
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for rejection" />
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Reject</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
|
||||
<p className="text-xs text-neutral-500 mt-0.5 mb-3">{sub}</p>
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
{empty ? <p className="px-4 py-10 text-center text-sm text-neutral-400">Nothing awaiting verification.</p> : (
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationManager({ docs, bank, epf, canDocs, canBankEpf }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; canDocs: boolean; canBankEpf: boolean }) {
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Verification</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Site-entered records awaiting office verification.</p>
|
||||
</div>
|
||||
|
||||
{canDocs && (
|
||||
<Card title="Documents" sub="Verify or reject crew documents (MPO)." empty={docs.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">Vessel / site</th><th className="px-4 py-3">Document</th><th className="px-4 py-3">Expiry</th><th className="px-4 py-3">Submitted</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d.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">{d.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{d.location}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{label(d.docType)}{d.number ? ` · ${d.number}` : ""}</td>
|
||||
<td className="px-4 py-3">{d.expiryDate ? <span className={isExpired(d.expiryDate) ? "text-danger-700 font-medium" : "text-neutral-600"}>{fmt(d.expiryDate)}{isExpired(d.expiryDate) ? " · expired" : ""}</span> : "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{fmt(d.submitted)}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyDocument(d.id, true)} onReject={(r) => verifyDocument(d.id, false, r)} /></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">
|
||||
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Account</th><th className="px-4 py-3">IFSC</th><th className="px-4 py-3">Bank</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{bank.map((b) => (
|
||||
<tr key={b.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{b.crewName}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{b.accountNumber ?? "—"}{b.accountName ? ` (${b.accountName})` : ""}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{b.ifsc ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{b.bankName ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(b.crewMemberId, "bank", true)} onReject={(r) => verifyBankEpf(b.crewMemberId, "bank", false, r)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canBankEpf && (
|
||||
<Card title="EPF details" sub="Verify or reject crew EPF / identity details (Accounts)." empty={epf.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">UAN</th><th className="px-4 py-3">Aadhaar</th><th className="px-4 py-3">PF no.</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{epf.map((e) => (
|
||||
<tr key={e.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{e.crewName}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
CalendarCheck,
|
||||
UserCog,
|
||||
Gauge,
|
||||
BadgeCheck,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
|
|
@ -87,6 +88,7 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
|||
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
|
||||
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
||||
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
||||
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
|
||||
]
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_VERIFIED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_REJECTED';
|
||||
|
|
@ -155,6 +155,8 @@ enum CrewActionType {
|
|||
LEAVE_DECIDED
|
||||
ATTENDANCE_RECORDED
|
||||
CREW_SIGNED_OFF
|
||||
RECORD_VERIFIED
|
||||
RECORD_REJECTED
|
||||
}
|
||||
|
||||
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
|
||||
|
|
|
|||
103
App/tests/integration/verification.test.ts
Normal file
103
App/tests/integration/verification.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Integration tests for Crewing Phase 5a verification: documents (MPO) and
|
||||
* bank/EPF (Accounts), with role gating per §6/§8.11.
|
||||
*/
|
||||
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 }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { verifyDocument, verifyBankEpf } from "@/app/(portal)/crewing/verification/actions";
|
||||
import { makeSession, getSeedUser } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let manningId: string;
|
||||
let accountsId: string;
|
||||
let siteStaffId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itver.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function crewWithRecords() {
|
||||
const c = await db.crewMember.create({ data: { name: "To Verify", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const doc = await db.seafarerDocument.create({ data: { crewMemberId: c.id, docType: "PASSPORT", number: "P999" } });
|
||||
await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "123456789", ifsc: "HDFC0001" } });
|
||||
await db.epfDetail.create({ data: { crewMemberId: c.id, uan: "UAN-1" } });
|
||||
return { crewId: c.id, docId: doc.id };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
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: "ITVER-SS", email: SS_EMAIL, name: "SS Ver", role: "SITE_STAFF" } });
|
||||
siteStaffId = ss.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.seafarerDocument.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("document verification (MPO)", () => {
|
||||
it("verifies a document with an audit row", async () => {
|
||||
const { crewId, docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await verifyDocument(docId, true))).toBe(true);
|
||||
const d = await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } });
|
||||
expect(d.verificationStatus).toBe("VERIFIED");
|
||||
expect(d.verifiedById).toBe(manningId);
|
||||
expect(await db.crewAction.count({ where: { crewMemberId: crewId, actionType: "RECORD_VERIFIED" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("rejection requires a reason and records it", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyDocument(docId, false))).toBe(true);
|
||||
expect("ok" in (await verifyDocument(docId, false, "Illegible scan"))).toBe(true);
|
||||
expect((await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } })).verificationStatus).toBe("REJECTED");
|
||||
});
|
||||
|
||||
it("won't re-verify an already-decided document", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
await verifyDocument(docId, true);
|
||||
expect("error" in (await verifyDocument(docId, true))).toBe(true);
|
||||
});
|
||||
|
||||
it("is rejected for roles without verify_site_records (accounts, site staff)", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank/EPF verification (Accounts)", () => {
|
||||
it("Accounts verifies bank and EPF", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect("ok" in (await verifyBankEpf(crewId, "bank", true))).toBe(true);
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
|
||||
expect("ok" in (await verifyBankEpf(crewId, "epf", true))).toBe(true);
|
||||
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
|
||||
});
|
||||
|
||||
it("is rejected for the MPO (no verify_bank_epf)", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue