Office/admin crewing-management surface behind a new manage_crew permission (Manager + SuperUser + Admin). Stacks on 4b. Behind NEXT_PUBLIC_CREWING_ENABLED. What's in - Permission: manage_crew added to the §6 matrix (MGR/SU/ADMIN). - Direct placement (placeCrew): a Manager assigns a crew member to a vessel/site WITHOUT a requisition — creates an ACTIVE CrewAssignment, promotes a candidate to EMPLOYEE with a CRW- number (generateEmployeeId), blocked if already actively assigned. - Admin crew CRUD: createCrewMember / updateCrewMember / deleteCrewMember (delete blocked when assignments/applications exist). - Crew strength config: upsert/delete VesselRankRequirement (the minStrength that drives R6 leave-clash detection). - Screens under Administration (flag-gated, MGR/SU/ADMIN): /admin/crew (list + add/ edit/delete + Place modal) and /admin/crew-strength (requirement table + form). Tests & docs - Unit: permissions-crewing.test.ts gains a manage_crew check. Integration: crewing-admin.test.ts (9) — CRUD, delete guard, direct placement (+promotion, +active-assignment guard), strength upsert/delete, manage_crew gating. type-check clean; full unit (241) + integration (192) green. - CLAUDE.md updated with the crewing-admin surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
6.5 KiB
TypeScript
134 lines
6.5 KiB
TypeScript
/**
|
|
* Integration tests for the crewing-admin actions: admin crew CRUD, Manager
|
|
* direct placement (no requisition), and per-vessel/per-rank strength config —
|
|
* all gated by the new manage_crew permission.
|
|
*/
|
|
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
|
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } from "@/app/(portal)/admin/crew/actions";
|
|
import { upsertRequirement, deleteRequirement } from "@/app/(portal)/admin/crew-strength/actions";
|
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
|
import type { Role } from "@prisma/client";
|
|
|
|
let managerId: string;
|
|
let adminId: string;
|
|
let siteStaffId: string;
|
|
let rankId: string;
|
|
let vesselId: string;
|
|
|
|
const SS_EMAIL = "sitestaff@itadm.local";
|
|
const as = (userId: string, role: Role) =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
beforeAll(async () => {
|
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
|
adminId = (await getSeedUser("admin@pelagia.local")).id;
|
|
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITADM-SS", email: SS_EMAIL, name: "SS Adm", role: "SITE_STAFF" } });
|
|
siteStaffId = ss.id;
|
|
rankId = (await db.rank.findFirstOrThrow()).id;
|
|
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.crewAction.deleteMany({});
|
|
await db.crewAssignment.deleteMany({});
|
|
await db.vesselRankRequirement.deleteMany({});
|
|
await db.crewMember.deleteMany({});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
|
});
|
|
|
|
describe("admin crew CRUD (manage_crew)", () => {
|
|
it("admin creates and edits a crew member", async () => {
|
|
as(adminId, "ADMIN");
|
|
const res = await createCrewMember(fd({ name: "Direct Hire", status: "CANDIDATE", source: "WALK_IN" }));
|
|
expect("ok" in res && res.ok).toBe(true);
|
|
const c = await db.crewMember.findFirstOrThrow({ where: { name: "Direct Hire" } });
|
|
expect(c.source).toBe("WALK_IN");
|
|
|
|
await updateCrewMember(fd({ id: c.id, name: "Direct Hire", status: "BLACKLISTED", source: "WALK_IN" }));
|
|
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("BLACKLISTED");
|
|
});
|
|
|
|
it("is rejected for roles without manage_crew (site staff)", async () => {
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect(await createCrewMember(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
|
|
expect(await db.crewMember.count()).toBe(0);
|
|
});
|
|
|
|
it("blocks deletion of crew with assignments", async () => {
|
|
as(managerId, "MANAGER");
|
|
const c = await db.crewMember.create({ data: { name: "Has Assignment", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
|
await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } });
|
|
expect("error" in (await deleteCrewMember(c.id))).toBe(true);
|
|
expect(await db.crewMember.findUnique({ where: { id: c.id } })).not.toBeNull();
|
|
});
|
|
|
|
it("deletes a crew member with no assignments/applications", async () => {
|
|
as(managerId, "MANAGER");
|
|
const c = await db.crewMember.create({ data: { name: "Removable", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
|
expect("ok" in (await deleteCrewMember(c.id))).toBe(true);
|
|
expect(await db.crewMember.findUnique({ where: { id: c.id } })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("direct placement (Manager, no requisition)", () => {
|
|
it("places a candidate → ACTIVE assignment + promoted to EMPLOYEE with a CRW- number", async () => {
|
|
as(managerId, "MANAGER");
|
|
const c = await db.crewMember.create({ data: { name: "To Place", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
|
const res = await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" }));
|
|
expect("ok" in res && res.ok).toBe(true);
|
|
|
|
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: c.id } });
|
|
expect(assignment.status).toBe("ACTIVE");
|
|
expect(assignment.requisitionId).toBeNull(); // no requisition
|
|
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } });
|
|
expect(after.status).toBe("EMPLOYEE");
|
|
expect(after.employeeId).toMatch(/^CRW-\d+$/);
|
|
expect(after.currentRankId).toBe(rankId);
|
|
});
|
|
|
|
it("refuses to place crew that already has an active assignment", async () => {
|
|
as(managerId, "MANAGER");
|
|
const c = await db.crewMember.create({ data: { name: "Already Placed", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
|
await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } });
|
|
expect("error" in (await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" })))).toBe(true);
|
|
});
|
|
|
|
it("is rejected for roles without manage_crew", async () => {
|
|
as(siteStaffId, "SITE_STAFF");
|
|
const c = await db.crewMember.create({ data: { name: "X", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
|
expect(await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|
|
|
|
describe("crew strength config (manage_crew)", () => {
|
|
it("upserts and removes a vessel/rank requirement", async () => {
|
|
as(managerId, "MANAGER");
|
|
expect("ok" in (await upsertRequirement(fd({ vesselId, rankId, minStrength: "3" })))).toBe(true);
|
|
let req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } });
|
|
expect(req.minStrength).toBe(3);
|
|
// Upsert updates in place.
|
|
await upsertRequirement(fd({ vesselId, rankId, minStrength: "5" }));
|
|
req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } });
|
|
expect(req.minStrength).toBe(5);
|
|
expect(await db.vesselRankRequirement.count()).toBe(1);
|
|
|
|
expect("ok" in (await deleteRequirement(req.id))).toBe(true);
|
|
expect(await db.vesselRankRequirement.count()).toBe(0);
|
|
});
|
|
|
|
it("is rejected for roles without manage_crew", async () => {
|
|
as(siteStaffId, "SITE_STAFF");
|
|
expect(await upsertRequirement(fd({ vesselId, rankId, minStrength: "2" }))).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|