pelagia-portal/App/app/(portal)/crewing/crew/actions.ts
Hardik e951a44a67
All checks were successful
PR checks / checks (pull_request) Successful in 41s
PR checks / integration (pull_request) Successful in 30s
fix(crewing): make rank-held universal, ex-hand an admin-only flag
Rank held applies to every candidate, not just ex-hands; it auto-updates
for returning crew on sign-off. Ex-hand designation is decoupled from the
Source dropdown and owned by the office:

- Candidate form: drop the EX_HAND source option, relabel "Rank held
  (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE
  (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate
  no longer rewrites type/status, so an admin-set EX_HAND or onboarded
  EMPLOYEE is never clobbered by a candidate edit.
- Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand
  (returning crew)" checkbox -- the only place ex-hand is tagged.
- List/detail ex-hand indicators key on type === EX_HAND (not source).
- Sign-off preserves the original recruitment source when flipping to EX_HAND.
- Tests seed EX_HAND rows directly; assert candidate intake stays NEW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:33:50 +05:30

329 lines
14 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 { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
import { SeafarerDocType, PpeItem } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
// Whole months between two dates (floored), min 0 — for the experience record.
function monthsBetween(from: Date, to: Date): number {
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0);
return Math.max(0, months);
}
type ActionResult = { ok: true; id?: string } | { error: string };
const crewPath = (id: string) => `/crewing/crew/${id}`;
async function guard(permission: Permission): Promise<{ error: string } | { userId: string }> {
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 };
}
async function requireCrew(id: string) {
return db.crewMember.findUnique({ where: { id }, select: { id: true } });
}
// ── Documents ──────────────────────────────────────────────────────────────
const docSchema = z.object({
crewMemberId: z.string().min(1),
docType: z.nativeEnum(SeafarerDocType),
number: z.string().optional(),
issueDate: z.string().optional(),
expiryDate: z.string().optional(),
});
export async function uploadDocument(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = docSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
docType: formData.get("docType"),
number: (formData.get("number") as string) || undefined,
issueDate: (formData.get("issueDate") as string) || undefined,
expiryDate: (formData.get("expiryDate") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
let fileKey: string | null = null;
const file = formData.get("file");
if (file instanceof File && file.size > 0) {
fileKey = buildStorageKey("crew-document", d.crewMemberId, file.name);
await uploadBuffer(fileKey, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
}
await db.seafarerDocument.create({
data: {
crewMemberId: d.crewMemberId,
docType: d.docType,
number: d.number ?? null,
fileKey,
issueDate: d.issueDate ? new Date(d.issueDate) : null,
expiryDate: d.expiryDate ? new Date(d.expiryDate) : null,
},
});
await db.crewAction.create({ data: { actionType: "DOCUMENT_UPLOADED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { docType: d.docType } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteDocument(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
if (!doc) return { error: "Document not found" };
await db.$transaction(async (tx) => {
await tx.seafarerDocument.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
});
});
revalidatePath(crewPath(doc.crewMemberId));
return { ok: true };
}
// ── Bank & EPF ───────────────────────────────────────────────────────────────
const bankEpfSchema = z.object({
crewMemberId: z.string().min(1),
accountName: z.string().optional(),
accountNumber: z.string().optional(),
ifsc: z.string().optional(),
bankName: z.string().optional(),
uan: z.string().optional(),
aadhaarLast4: z.string().optional(),
pfNumber: z.string().optional(),
});
export async function saveBankEpf(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = bankEpfSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.$transaction(async (tx) => {
await tx.bankDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId: d.crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId: d.crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "bank_epf" } } });
});
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Next of kin / emergency ────────────────────────────────────────────────
const nokSchema = z.object({
crewMemberId: z.string().min(1),
name: z.string().trim().min(1, "Name is required"),
relationship: z.string().optional(),
phone: z.string().optional(),
address: z.string().optional(),
isEmergency: z.boolean().optional(),
});
export async function addNextOfKin(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = nokSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
name: formData.get("name"),
relationship: (formData.get("relationship") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
address: (formData.get("address") as string) || undefined,
isEmergency: formData.get("isEmergency") === "on" || formData.get("isEmergency") === "true",
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.nextOfKin.create({
data: {
crewMemberId: d.crewMemberId,
name: d.name,
relationship: d.relationship ?? null,
phone: d.phone ?? null,
address: d.address ?? null,
isEmergency: d.isEmergency ?? false,
},
});
await db.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "next_of_kin" } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteNextOfKin(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
if (!nok) return { error: "Record not found" };
await db.$transaction(async (tx) => {
await tx.nextOfKin.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
});
});
revalidatePath(crewPath(nok.crewMemberId));
return { ok: true };
}
// ── PPE ──────────────────────────────────────────────────────────────────────
const ppeSchema = z.object({
crewMemberId: z.string().min(1),
item: z.nativeEnum(PpeItem),
size: z.string().optional(),
quantity: z.coerce.number().int().min(1).default(1),
comment: z.string().optional(),
});
export async function issuePpe(formData: FormData): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const parsed = ppeSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.ppeIssue.create({
data: { crewMemberId: d.crewMemberId, item: d.item, size: d.size ?? null, quantity: d.quantity, comment: d.comment ?? null, issuedById: g.userId },
});
await db.crewAction.create({ data: { actionType: "PPE_ISSUED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { item: d.item } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function returnPpe(id: string): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const ppe = await db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, returnedDate: true } });
if (!ppe) return { error: "PPE record not found" };
if (ppe.returnedDate) return { error: "Already returned" };
await db.ppeIssue.update({ where: { id }, data: { returnedDate: new Date() } });
await db.crewAction.create({ data: { actionType: "PPE_RETURNED", actorId: g.userId, crewMemberId: ppe.crewMemberId } });
revalidatePath(crewPath(ppe.crewMemberId));
return { ok: true };
}
// ── Experience ─────────────────────────────────────────────────────────────
const expSchema = z.object({
crewMemberId: z.string().min(1),
vesselType: z.string().optional(),
rankId: z.string().optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
durationMonths: z.coerce.number().int().min(0).optional(),
});
export async function addExperience(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = expSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.experienceRecord.create({
data: {
crewMemberId: d.crewMemberId,
vesselType: d.vesselType ?? null,
rankId: d.rankId || null,
fromDate: d.fromDate ? new Date(d.fromDate) : null,
toDate: d.toDate ? new Date(d.toDate) : null,
durationMonths: d.durationMonths ?? null,
source: "declared",
},
});
await db.crewAction.create({ data: { actionType: "EXPERIENCE_ADDED", actorId: g.userId, crewMemberId: d.crewMemberId } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Sign off (Phase 4c, Epic K) ────────────────────────────────────────────────
// Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD,
// flip the crew member back to EX_HAND (so they return to the Candidates pool), and
// auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper).
export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise<ActionResult> {
const g = await guard("sign_off_crew");
if ("error" in g) return g;
if (!signOffDate) return { error: "A sign-off date is required" };
const assignment = await db.crewAssignment.findUnique({
where: { id: assignmentId },
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
});
if (!assignment) return { error: "Assignment not found" };
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" };
const off = new Date(signOffDate);
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
// seat can never become vacant without its backfill being raised.
const backfill = await db.$transaction(async (tx) => {
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
await tx.experienceRecord.create({
data: {
crewMemberId: assignment.crewMemberId,
rankId: assignment.rankId,
vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null,
fromDate: assignment.signOnDate,
toDate: off,
durationMonths: monthsBetween(assignment.signOnDate, off),
source: "internal",
},
});
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
// returning hand. The ex-hand flag lives on type/status — their original
// source (how they were first recruited) is preserved. currentRank (rank
// held) is refreshed to the tour they just signed off from.
await tx.crewMember.update({
where: { id: assignment.crewMemberId },
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
});
await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
});
return autoRaiseRequisition(
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
tx
);
});
// Notify the office after the transaction commits.
await notifyAutoRaised(backfill);
revalidatePath(crewPath(assignment.crewMemberId));
revalidatePath("/crewing/crew");
return { ok: true };
}