"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 { CandidateSource } from "@prisma/client"; import type { Role } from "@prisma/client"; import { z } from "zod"; import { revalidatePath } from "next/cache"; type ActionResult = { ok: true; id?: string } | { error: string }; const LIST_PATH = "/crewing/candidates"; 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 }; } const candidateSchema = z.object({ name: z.string().trim().min(1, "Name is required"), source: z.nativeEnum(CandidateSource).default("CAREERS"), appliedRankId: z.string().optional(), currentRankId: z.string().optional(), experienceMonths: z.coerce.number().int().min(0).max(720).default(0), vesselTypeExperience: z.string().optional(), email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")), phone: z.string().optional(), notes: z.string().optional(), }); function parse(formData: FormData) { return candidateSchema.safeParse({ name: formData.get("name"), source: (formData.get("source") as string) || undefined, appliedRankId: (formData.get("appliedRankId") as string) || undefined, currentRankId: (formData.get("currentRankId") as string) || undefined, experienceMonths: (formData.get("experienceMonths") as string) || undefined, vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined, email: (formData.get("email") as string) || undefined, phone: (formData.get("phone") as string) || undefined, notes: (formData.get("notes") as string) || undefined, }); } // An EX_HAND source means a returning crew member; everyone else is NEW. The // CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE. function derive(source: CandidateSource) { const isExHand = source === "EX_HAND"; return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const; } // Store an optional CV upload and return its storage key (null if none). async function storeCv(formData: FormData, crewMemberId: string): Promise { const file = formData.get("cv"); if (!(file instanceof File) || file.size === 0) return null; const key = buildStorageKey("cv", crewMemberId, file.name); const buffer = Buffer.from(await file.arrayBuffer()); await uploadBuffer(key, buffer, file.type || "application/octet-stream"); return key; } export async function addCandidate(formData: FormData): Promise { const g = await guard("manage_candidates"); if ("error" in g) return g; const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const { type, status } = derive(d.source); // B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh // candidate (not already tagged EX_HAND) is matched to their existing EX_HAND // pool record by a stable key — email when given, else an exact name match — // and the SAME row is reused (so their tour history, documents and bank stay on // file) rather than creating a duplicate. (Heuristic: with no DOB on file a // name-only match can in theory collide; email is preferred when available.) if (d.source !== "EX_HAND") { const match = await db.crewMember.findFirst({ where: { status: "EX_HAND", ...(d.email ? { email: { equals: d.email, mode: "insensitive" } } : { name: { equals: d.name, mode: "insensitive" } }), }, select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true }, }); if (match) { const updated = await db.crewMember.update({ where: { id: match.id }, data: { // Keep EX_HAND type/status; refresh the application's details, never // discarding prior history (take the larger recorded experience). appliedRankId: d.appliedRankId || match.appliedRankId, currentRankId: d.currentRankId || match.currentRankId, email: d.email || match.email, phone: d.phone || match.phone, notes: d.notes || match.notes, experienceMonths: Math.max(d.experienceMonths, match.experienceMonths), vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience, actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } }, }, }); const cvKey = await storeCv(formData, updated.id); if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } }); revalidatePath(LIST_PATH); return { ok: true, id: updated.id }; } } const candidate = await db.crewMember.create({ data: { name: d.name, source: d.source, type, status, appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, vesselTypeExperience: d.vesselTypeExperience || null, email: d.email || null, phone: d.phone || null, notes: d.notes || null, actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } }, }, }); const cvKey = await storeCv(formData, candidate.id); if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } }); revalidatePath(LIST_PATH); return { ok: true, id: candidate.id }; } export async function updateCandidate(formData: FormData): Promise { const g = await guard("manage_candidates"); if ("error" in g) return g; const id = formData.get("id") as string; if (!id) return { error: "Candidate ID is required" }; const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const { type, status } = derive(d.source); const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } }); if (!existing) return { error: "Candidate not found" }; const cvKey = await storeCv(formData, id); await db.crewMember.update({ where: { id }, data: { name: d.name, source: d.source, // Don't downgrade an onboarded employee back to a candidate via an edit. type, status: existing.status === "EMPLOYEE" ? existing.status : status, appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, vesselTypeExperience: d.vesselTypeExperience || null, email: d.email || null, phone: d.phone || null, notes: d.notes || null, ...(cvKey ? { cvKey } : {}), actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } }, }, }); revalidatePath(LIST_PATH); revalidatePath(`${LIST_PATH}/${id}`); return { ok: true, id }; }