- AC1: addCandidate recognizes a returning hand re-entered as a fresh candidate
— matched to their existing EX_HAND pool record by email (preferred) or exact
name — and reuses that row instead of creating a duplicate, preserving tour
history/documents/bank. Audited CANDIDATE_UPDATED { exHandRecognized: true }.
- AC2: the Candidates list sorts ex-hands above new candidates by default
(stable, preserving createdAt order within each group).
- AC3: the candidate detail "Returning crew" callout now renders the matched
member's actual tour history (ExperienceRecord) and documents on file.
candidates.test.ts covers email/name recognition, the no-match path, and the
ex-hand-first page ordering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
7.3 KiB
TypeScript
182 lines
7.3 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,
|
|
});
|
|
}
|
|
|
|
// 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<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;
|
|
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<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 { 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 };
|
|
}
|