First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).
What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
(3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
guard flag + manage_candidates, write a CrewAction, optional CV upload via
buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
min-experience filters as removable chips + match count + Clear all; Add-candidate
modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
added to the flag-gated Crewing nav (Manager + MPO).
Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
no-downgrade, permission gating. type-check clean; full unit (225) + integration
(153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.
Deferred: public careers intake API (A2, §13 open question); CV parsing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
5.4 KiB
TypeScript
143 lines
5.4 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);
|
|
|
|
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 };
|
|
}
|