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>
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
"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 };
|
|
}
|
|
|
|
// ── 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
|
|
);
|
|
}
|