Scaffolds EPFO/UAN verification the same way GST works — a standalone Playwright
proxy microservice + an /api proxy + an assisted affordance that records the
result. Aadhaar stays manual (UIDAI-restricted). Stacks on the follow-ups branch.
Behind NEXT_PUBLIC_CREWING_ENABLED.
What's in
- EpfoService/ (new microservice, GstService pattern): Express + Playwright.
POST /otp {uan} → session + OTP request; POST /verify {sessionId,uan,otp} →
member record; GET /health. EPFO is OTP-gated (no anonymous captcha lookup like
GST), so the handshake is two steps. Live portal navigation is gated behind
EPFO_LIVE (default STUB: OTP 000000 → matched) until real selectors/OTP are
validated. README documents the differences + that Aadhaar is out of scope.
- App: /api/epfo/otp + /api/epfo proxies (gated by verify_bank_epf) to
EPFO_SERVICE_URL. EpfDetail += epfoMemberName + epfoCheckedAt (migration
crewing_epfo_check). recordEpfoCheck action persists the EPFO result + audit.
- UI: an "EPFO check" affordance on the verification EPF rows — request OTP →
enter OTP → matched member → record. Aadhaar noted as manual-only.
Tests & docs
- Integration: verification.test.ts gains recordEpfoCheck (records name+timestamp,
Accounts-only gating). type-check clean; full unit (245) + integration (213)
green (RESEND_API_KEY unset).
- .env.example (EPFO_SERVICE_URL/EPFO_LIVE), CLAUDE.md, EpfoService/README.
Note: the EpfoService live portal selectors/OTP are stubbed and must be validated
against a real EPFO session before enabling EPFO_LIVE.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
7.1 KiB
TypeScript
165 lines
7.1 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
|
|
);
|
|
}
|
|
|
|
// ── EPFO assisted lookup (Accounts) ────────────────────────────────────────────
|
|
// Records the result of an EpfoService UAN check on the crew member's EpfDetail
|
|
// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo;
|
|
// this just persists the returned member name + a timestamp for the audit trail.
|
|
|
|
export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise<ActionResult> {
|
|
const g = await guard("verify_bank_epf");
|
|
if ("error" in g) return g;
|
|
|
|
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } });
|
|
if (!rec) return { error: "EPF details not found" };
|
|
|
|
await db.epfDetail.update({
|
|
where: { crewMemberId },
|
|
data: { epfoMemberName: memberName, epfoCheckedAt: new Date() },
|
|
});
|
|
await db.crewAction.create({
|
|
data: {
|
|
actionType: "RECORD_UPDATED",
|
|
actorId: g.userId,
|
|
crewMemberId,
|
|
note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match",
|
|
metadata: { record: "epfo_check" },
|
|
},
|
|
});
|
|
|
|
revalidatePath(PATH);
|
|
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
|
return { ok: true };
|
|
}
|