First slice of Phase 5 (verification + appraisal). The office queue for verifying site-entered records, per Crewing-Implementation-Spec §8.11/R11. Stacks on 4c. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Schema: CrewActionType += RECORD_VERIFIED/RECORD_REJECTED (migration crewing_verification_actions). No model changes — SeafarerDocument/BankDetail/ EpfDetail already carry verificationStatus + verifiedById (3b/4a). - Actions (crewing/verification/actions.ts): verifyDocument (verify_site_records — MPO/Manager) and verifyBankEpf (verify_bank_epf — Accounts) set verificationStatus VERIFIED/REJECTED + verifiedById; rejection requires remarks; each writes a CrewAction. Already-decided records are guarded. - Screen: /crewing/verification — role-aware (MPO: pending documents with expiry flags; Accounts: pending bank/EPF), Verify / Reject-with-remarks. Leave is not here (Manager approval, R11). Verification added to nav (MPO + Accounts + SU, §7). Tests & docs - Integration: verification.test.ts (6) — doc verify/reject + already-decided guard, bank/EPF verify, permission gating (Accounts can't verify docs, MPO can't verify bank/EPF). type-check clean; full unit (241) + integration (201) green (verified with RESEND_API_KEY unset, mimicking CI). - CLAUDE.md updated. Deferred (per decision): PPE / next-of-kin verification gates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
3.8 KiB
TypeScript
84 lines
3.8 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 };
|
|
}
|