Phase 1 of the Crewing module per wiki Crewing-Implementation-Spec §12, all dark behind NEXT_PUBLIC_CREWING_ENABLED (off by default — production unchanged). - schema: add SITE_STAFF to Role; add Rank (self-referential org hierarchy, like Account) + RankDocRequirement, RankCategory & SeafarerDocType enums. - permissions: full §6 crewing grant matrix (PO_ROLE_PERMISSIONS + CREWING_ROLE_PERMISSIONS merged); SITE_STAFF row; MPO has no attendance/leave, approvals are Manager-only, manage_ranks is Manager+Admin. - feature flag: CREWING_ENABLED (opt-in "true"). - nav: flag-gated Crewing section scaffold + "Ranks & documents" under Admin. - reference data: rank-data.ts + rank-doc-data.ts seeded via shared seed-ranks.ts in both dev and prod seeds (19 ranks, 118 doc requirements). - screen: /admin/ranks — rank hierarchy card + per-rank required-documents card. - role-label/prefix maps updated for the new role. Tests: unit (permission matrix + flag), integration (ranks admin CRUD, parent linking, cycle/children guards, doc-requirement upsert/remove, permission gating). Docs: CLAUDE.md "Crewing (feature-flagged)" section + env var. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
6.3 KiB
TypeScript
187 lines
6.3 KiB
TypeScript
"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<boolean> {
|
|
if (rankId === candidateParentId) return true;
|
|
const all = await db.rank.findMany({ select: { id: true, parentId: true } });
|
|
const childrenOf = new Map<string, string[]>();
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
const denied = await guard();
|
|
if (denied) return denied;
|
|
|
|
await db.rankDocRequirement.delete({ where: { id } });
|
|
revalidatePath("/admin/ranks");
|
|
return { ok: true };
|
|
}
|