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>
173 lines
7.1 KiB
TypeScript
173 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 { 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,
|
|
});
|
|
}
|
|
|
|
// Store an optional CV upload and return its storage key (null if none).
|
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
|
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<ActionResult> {
|
|
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;
|
|
|
|
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
|
// candidate 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. (Ex-hand is set by the office on the admin crew record; the
|
|
// candidate form never tags it directly. Heuristic: with no DOB on file a
|
|
// name-only match can in theory collide; email is preferred when available.)
|
|
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,
|
|
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
|
|
// is an office/admin designation set on the crew record, not here.
|
|
type: "NEW",
|
|
status: "CANDIDATE",
|
|
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<ActionResult> {
|
|
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 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,
|
|
// type/status are left untouched — ex-hand / employee designation is owned
|
|
// by the office (admin crew record + sign-off), never by a candidate edit.
|
|
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 };
|
|
}
|