"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 { leaveCausesClash } from "@/lib/leave-clash"; import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service"; import { notifyCrew } from "@/lib/notifier"; import { LeaveType } 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 LEAVE_PATH = "/crewing/leave"; 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 }; } function revalidate() { revalidatePath(LEAVE_PATH); revalidatePath("/approvals"); } // ── Apply for leave (Site staff, on behalf of a crew member) ─────────────────── const applySchema = z .object({ assignmentId: z.string().min(1, "Crew member is required"), type: z.nativeEnum(LeaveType).default("ANNUAL"), fromDate: z.string().min(1, "From date is required"), toDate: z.string().min(1, "To date is required"), reason: z.string().optional(), }) .refine((d) => new Date(d.toDate) >= new Date(d.fromDate), { message: "To date must be on or after the from date" }); export async function applyLeave(formData: FormData): Promise { const g = await guard("apply_leave"); if ("error" in g) return g; const parsed = applySchema.safeParse({ assignmentId: formData.get("assignmentId"), type: (formData.get("type") as string) || undefined, fromDate: formData.get("fromDate"), toDate: formData.get("toDate"), reason: (formData.get("reason") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; const assignment = await db.crewAssignment.findUnique({ where: { id: d.assignmentId }, include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } }, }); if (!assignment) return { error: "Crew assignment not found" }; if (assignment.status === "SIGNED_OFF") return { error: "This crew member has signed off" }; const leave = await db.leaveRequest.create({ data: { assignmentId: d.assignmentId, type: d.type, fromDate: new Date(d.fromDate), toDate: new Date(d.toDate), reason: d.reason ?? null, appliedById: g.userId, }, }); await db.crewAction.create({ data: { actionType: "LEAVE_APPLIED", actorId: g.userId, crewMemberId: assignment.crewMember.id } }); const managers = await getManagerRecipients(); await notifyCrew({ event: "LEAVE_FOR_APPROVAL", recipients: managers, subject: `Leave for approval — ${assignment.crewMember.name}`, body: `${assignment.crewMember.name} (${assignment.rank.name}) has a leave request from ${d.fromDate} to ${d.toDate} awaiting your decision.`, link: LEAVE_PATH, }); revalidate(); return { ok: true, id: leave.id }; } // ── Decide leave (Manager) ───────────────────────────────────────────────────── // On approval the assignment goes ON_LEAVE and a clash check runs; if it would // leave the vessel with no same-rank cover, a LEAVE requisition is auto-raised. export async function decideLeave(id: string, approve: boolean, note?: string): Promise { const g = await guard("decide_leave"); if ("error" in g) return g; const leave = await db.leaveRequest.findUnique({ where: { id }, include: { assignment: { select: { id: true, crewMemberId: true, rankId: true, vesselId: true, siteId: true } } }, }); if (!leave) return { error: "Leave request not found" }; if (leave.status !== "APPLIED") return { error: `This leave request is already ${leave.status}` }; if (!approve && !note?.trim()) return { error: "A reason is required to decline" }; if (!approve) { await db.leaveRequest.update({ where: { id }, data: { status: "REJECTED", decidedById: g.userId, decidedAt: new Date(), reason: note?.trim() || leave.reason } }); await db.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, note: note?.trim() || null, metadata: { decision: "REJECTED" } } }); revalidate(); return { ok: true }; } // Leave approval + the clash check + any backfill requisition commit atomically // (spec §5.3/§11): an approved leave can never leave a cover gap un-raised. const backfill = await db.$transaction(async (tx) => { await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } }); await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } }); await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } }); const clash = await leaveCausesClash(tx, { assignmentId: leave.assignment.id, rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, fromDate: leave.fromDate, toDate: leave.toDate, }); if (!clash) return null; return autoRaiseRequisition( { rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" }, tx ); }); // Notify the office after the transaction commits. if (backfill) await notifyAutoRaised(backfill); revalidate(); return { ok: true }; }