"use server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; import { RankCategory, SeafarerDocType } from "@prisma/client"; import { z } from "zod"; import { revalidatePath } from "next/cache"; type ActionResult = { ok: true } | { error: string }; async function guard(): Promise<{ error: string } | null> { if (!CREWING_ENABLED) return { error: "Crewing is not enabled" }; const session = await auth(); if (!session?.user || !hasPermission(session.user.role, "manage_ranks")) { return { error: "Unauthorized" }; } return null; } const rankSchema = z.object({ code: z.string().trim().min(1, "Code is required").max(16, "Code is too long"), name: z.string().trim().min(1, "Name is required"), description: z.string().optional(), parentId: z.string().optional(), category: z.nativeEnum(RankCategory), isSeafarer: z.boolean(), grantsLogin: z.boolean(), }); function parseRank(formData: FormData) { return rankSchema.safeParse({ code: formData.get("code"), name: formData.get("name"), description: (formData.get("description") as string) || undefined, parentId: (formData.get("parentId") as string) || undefined, category: formData.get("category"), isSeafarer: formData.get("isSeafarer") === "on" || formData.get("isSeafarer") === "true", grantsLogin: formData.get("grantsLogin") === "on" || formData.get("grantsLogin") === "true", }); } // True if `candidateParentId` is `rankId` itself or one of its descendants — // setting it as the parent would create a cycle. async function wouldCycle(rankId: string, candidateParentId: string): Promise { if (rankId === candidateParentId) return true; const all = await db.rank.findMany({ select: { id: true, parentId: true } }); const childrenOf = new Map(); for (const r of all) { if (r.parentId) { const list = childrenOf.get(r.parentId) ?? []; list.push(r.id); childrenOf.set(r.parentId, list); } } const stack = [rankId]; while (stack.length) { const cur = stack.pop()!; if (cur === candidateParentId) return true; stack.push(...(childrenOf.get(cur) ?? [])); } return false; } export async function createRank(formData: FormData): Promise { const denied = await guard(); if (denied) return denied; const parsed = parseRank(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const data = parsed.data; const exists = await db.rank.findUnique({ where: { code: data.code } }); if (exists) return { error: "A rank with that code already exists" }; await db.rank.create({ data: { code: data.code, name: data.name, description: data.description ?? null, parentId: data.parentId ?? null, category: data.category, isSeafarer: data.isSeafarer, grantsLogin: data.grantsLogin, }, }); revalidatePath("/admin/ranks"); return { ok: true }; } export async function updateRank(formData: FormData): Promise { const denied = await guard(); if (denied) return denied; const id = formData.get("id") as string; if (!id) return { error: "Rank ID is required" }; const parsed = parseRank(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const data = parsed.data; const conflict = await db.rank.findFirst({ where: { code: data.code, id: { not: id } } }); if (conflict) return { error: "Another rank already uses that code" }; if (data.parentId && (await wouldCycle(id, data.parentId))) { return { error: "A rank cannot report to itself or one of its sub-ranks" }; } await db.rank.update({ where: { id }, data: { code: data.code, name: data.name, description: data.description ?? null, parentId: data.parentId ?? null, category: data.category, isSeafarer: data.isSeafarer, grantsLogin: data.grantsLogin, }, }); revalidatePath("/admin/ranks"); return { ok: true }; } export async function deleteRank(id: string): Promise { const denied = await guard(); if (denied) return denied; const hasChildren = await db.rank.findFirst({ where: { parentId: id } }); if (hasChildren) return { error: "Cannot delete: this rank has sub-ranks. Reassign or remove them first." }; // Document requirements cascade on delete. await db.rank.delete({ where: { id } }); revalidatePath("/admin/ranks"); return { ok: true }; } export async function toggleRankActive(id: string): Promise { const denied = await guard(); if (denied) return denied; const rank = await db.rank.findUnique({ where: { id }, select: { isActive: true } }); if (!rank) return { error: "Rank not found" }; await db.rank.update({ where: { id }, data: { isActive: !rank.isActive } }); revalidatePath("/admin/ranks"); return { ok: true }; } const docReqSchema = z.object({ rankId: z.string().min(1), docType: z.nativeEnum(SeafarerDocType), isMandatory: z.boolean(), note: z.string().optional(), }); export async function addRankDocRequirement(formData: FormData): Promise { const denied = await guard(); if (denied) return denied; const parsed = docReqSchema.safeParse({ rankId: formData.get("rankId"), docType: formData.get("docType"), isMandatory: formData.get("isMandatory") === "on" || formData.get("isMandatory") === "true", note: (formData.get("note") as string) || undefined, }); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const data = parsed.data; await db.rankDocRequirement.upsert({ where: { rankId_docType: { rankId: data.rankId, docType: data.docType } }, update: { isMandatory: data.isMandatory, note: data.note ?? null }, create: { rankId: data.rankId, docType: data.docType, isMandatory: data.isMandatory, note: data.note ?? null }, }); revalidatePath("/admin/ranks"); return { ok: true }; } export async function removeRankDocRequirement(id: string): Promise { const denied = await guard(); if (denied) return denied; await db.rankDocRequirement.delete({ where: { id } }); revalidatePath("/admin/ranks"); return { ok: true }; }