Second slice of the Crewing module per wiki Crewing-Implementation-Spec §12 (build order item 2). Everything stays behind NEXT_PUBLIC_CREWING_ENABLED; production is unchanged. Schema is added incrementally — this lands the requisition lifecycle layer. What's in - Schema: Requisition (OPEN→SHORTLISTING→PROPOSING→INTERVIEWING→SELECTED→FILLED, →CANCELLED), ReliefRequest, CrewAction (the POAction mirror) + their enums. Migration crewing_requisitions. - State machine: lib/requisition-state-machine.ts mirrors po-state-machine (selection Manager-only; orthogonal cancel from OPEN/SHORTLISTING by cancel_requisition holders, §6). Codes REQ-9000… via lib/requisition-number.ts. - Actions: raise/cancel/transition + requestReliefCover/convertReliefToRequisition, each guarding flag+permission+state, writing a CrewAction and notifying. Shared autoRaiseRequisition() (lib/requisition-service.ts) is the backfill entry point for sign-off / leave-clash (later phases). - Notifier: notifyCrew() PO-independent path + CrewNotificationEvent. - Screens: /crewing/requisitions (list + Raise modal + relief convert) and /crewing/requisitions/[id] (detail). Requisitions added to the flag-gated Crewing sidebar (Manager + MPO, §7). Tests & docs - Unit: requisition-state-machine.test.ts (11). - Integration: requisitions.test.ts (15) — raise/cancel/transition, relief request + convert, auto-raise, permission gating. - CLAUDE.md "Crewing" section updated with the Phase 2 surface. Deferred: sign-off/experience (Epic K, §12 item 2) depends on the crew/assignment models from Phase 3/4; autoRaiseRequisition() is ready for it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
"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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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<ActionResult> {
|
|
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 };
|
|
}
|