"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 { canCancel, canPerformAction, getTransition, type RequisitionAction, } from "@/lib/requisition-state-machine"; import { createRequisitionTx, getMpoRecipients, getOfficeRecipients, requisitionLocationLabel, } from "@/lib/requisition-service"; import { notifyCrew } from "@/lib/notifier"; import { RequisitionReason } 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/requisitions"; // Crewing flag + permission guard. Returns the actor on success. 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 }; } // ── Raise a requisition (MPO / Manager) ─────────────────────────────────────── const raiseSchema = z .object({ rankId: z.string().min(1, "Rank is required"), vesselId: z.string().optional(), siteId: z.string().optional(), reason: z.nativeEnum(RequisitionReason).default("NEW_VACANCY"), neededBy: z.string().optional(), notes: z.string().optional(), }) .refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required", }); export async function raiseRequisition(formData: FormData): Promise { const g = await guard("raise_requisition"); if ("error" in g) return g; const parsed = raiseSchema.safeParse({ rankId: formData.get("rankId"), vesselId: (formData.get("vesselId") as string) || undefined, siteId: (formData.get("siteId") as string) || undefined, reason: (formData.get("reason") as string) || undefined, neededBy: (formData.get("neededBy") as string) || undefined, notes: (formData.get("notes") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const requisition = await db.$transaction((tx) => createRequisitionTx(tx, { rankId: d.rankId, vesselId: d.vesselId || null, siteId: d.siteId || null, reason: d.reason, neededBy: d.neededBy ? new Date(d.neededBy) : null, notes: d.notes || null, raisedById: g.userId, }) ); // Notify the MPO pool so it can start sourcing (spec §11). Don't self-notify. const recipients = (await getMpoRecipients()).filter((u) => u.id !== g.userId); if (recipients.length) { const loc = requisitionLocationLabel(requisition); await notifyCrew({ event: "REQUISITION_RAISED", recipients, subject: `Requisition ${requisition.code} raised`, body: `A ${requisition.rank.name} vacancy on ${loc} has been raised (${requisition.code}).`, link: `${LIST_PATH}/${requisition.id}`, }); } revalidatePath(LIST_PATH); return { ok: true, id: requisition.id }; } // ── Withdraw / cancel a requisition (Manager, from OPEN/SHORTLISTING) ────────── export async function cancelRequisition(id: string, reason: string): Promise { const g = await guard("cancel_requisition"); if ("error" in g) return g; const trimmed = reason?.trim(); if (!trimmed) return { error: "A reason is required to withdraw a requisition" }; const req = await db.requisition.findUnique({ where: { id }, select: { status: true } }); if (!req) return { error: "Requisition not found" }; if (!canCancel(req.status, g.role)) { return { error: `A requisition cannot be withdrawn once it is ${req.status}` }; } await db.requisition.update({ where: { id }, data: { status: "CANCELLED", cancelledAt: new Date(), cancellationReason: trimmed, actions: { create: { actionType: "REQUISITION_CANCELLED", actorId: g.userId, note: trimmed }, }, }, }); revalidatePath(LIST_PATH); revalidatePath(`${LIST_PATH}/${id}`); return { ok: true }; } // ── Advance a requisition through the pipeline stages ────────────────────────── // Phase 2 exposes the transitions; the recruitment pipeline (Phase 3) drives // them as candidates progress. Role gating comes from the state machine. export async function transitionRequisition( id: string, action: RequisitionAction ): Promise { if (!CREWING_ENABLED) return { error: "Crewing is not enabled" }; const session = await auth(); if (!session?.user) return { error: "Unauthorized" }; const req = await db.requisition.findUnique({ where: { id }, select: { status: true } }); if (!req) return { error: "Requisition not found" }; const transition = getTransition(req.status, action); if (!transition) return { error: `Cannot ${action} from ${req.status}` }; if (!canPerformAction(req.status, action, session.user.role)) return { error: "Unauthorized" }; await db.requisition.update({ where: { id }, data: { status: transition.to, filledAt: transition.to === "FILLED" ? new Date() : undefined, actions: { create: { actionType: transition.to === "FILLED" ? "REQUISITION_FILLED" : "REQUISITION_ADVANCED", actorId: session.user.id, metadata: { from: req.status, to: transition.to }, }, }, }, }); revalidatePath(LIST_PATH); revalidatePath(`${LIST_PATH}/${id}`); return { ok: true }; } // ── Relief cover request (site staff) ────────────────────────────────────────── // Site staff flag a foreseen gap; the office converts it into a requisition. The // site-staff origination UI lands with the Leave/clash screen (Phase 4); the // action exists now so the office-side convert flow and auto-raise share a path. const reliefSchema = z .object({ rankId: z.string().min(1, "Rank is required"), vesselId: z.string().optional(), siteId: z.string().optional(), note: z.string().optional(), }) .refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required", }); export async function requestReliefCover(formData: FormData): Promise { const g = await guard("request_relief_cover"); if ("error" in g) return g; const parsed = reliefSchema.safeParse({ rankId: formData.get("rankId"), vesselId: (formData.get("vesselId") as string) || undefined, siteId: (formData.get("siteId") as string) || undefined, note: (formData.get("note") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const relief = await db.$transaction(async (tx) => { const created = await tx.reliefRequest.create({ data: { rankId: d.rankId, vesselId: d.vesselId || null, siteId: d.siteId || null, note: d.note || null, requestedById: g.userId, }, include: { rank: true, vessel: true, site: true }, }); // CrewAction has no relief relation; record the id in metadata. await tx.crewAction.create({ data: { actionType: "RELIEF_REQUESTED", actorId: g.userId, metadata: { reliefRequestId: created.id, rankId: d.rankId }, }, }); return created; }); const recipients = await getOfficeRecipients(); if (recipients.length) { const loc = requisitionLocationLabel(relief); await notifyCrew({ event: "RELIEF_REQUESTED", recipients, subject: `Relief cover requested — ${relief.rank.name} on ${loc}`, body: `A site has requested relief cover for a ${relief.rank.name} on ${loc}. Convert it to a requisition to start sourcing.`, link: LIST_PATH, }); } revalidatePath(LIST_PATH); return { ok: true, id: relief.id }; } // ── Convert a relief request into a requisition (MPO / Manager) ──────────────── const convertSchema = z.object({ reliefRequestId: z.string().min(1, "Relief request is required"), reason: z.nativeEnum(RequisitionReason).default("REPLACEMENT"), neededBy: z.string().optional(), notes: z.string().optional(), }); export async function convertReliefToRequisition(formData: FormData): Promise { const g = await guard("convert_relief_to_requisition"); if ("error" in g) return g; const parsed = convertSchema.safeParse({ reliefRequestId: formData.get("reliefRequestId"), reason: (formData.get("reason") as string) || undefined, neededBy: (formData.get("neededBy") as string) || undefined, notes: (formData.get("notes") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const relief = await db.reliefRequest.findUnique({ where: { id: d.reliefRequestId } }); if (!relief) return { error: "Relief request not found" }; if (relief.status !== "OPEN") return { error: "This relief request has already been handled" }; const requisition = await db.$transaction(async (tx) => { const req = await createRequisitionTx(tx, { rankId: relief.rankId, vesselId: relief.vesselId, siteId: relief.siteId, reason: d.reason, neededBy: d.neededBy ? new Date(d.neededBy) : null, notes: d.notes || null, raisedById: g.userId, }); await tx.reliefRequest.update({ where: { id: relief.id }, data: { status: "CONVERTED", convertedRequisitionId: req.id }, }); await tx.crewAction.create({ data: { actionType: "RELIEF_CONVERTED", actorId: g.userId, requisitionId: req.id, metadata: { reliefRequestId: relief.id }, }, }); return req; }); // Let the requester know their relief request became a requisition. const requester = await db.user.findUnique({ where: { id: relief.requestedById } }); if (requester && requester.isActive && requester.id !== g.userId) { const loc = requisitionLocationLabel(requisition); await notifyCrew({ event: "RELIEF_CONVERTED", recipients: [requester], subject: `Relief cover converted — ${requisition.code}`, body: `Your relief request for a ${requisition.rank.name} on ${loc} is now requisition ${requisition.code}.`, link: `${LIST_PATH}/${requisition.id}`, }); } revalidatePath(LIST_PATH); return { ok: true, id: requisition.id }; }