pelagia-portal/App/lib/requisition-service.ts
Hardik 0b2ed9ac07
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): Phase 2 — requisitions + relief requests (flagged)
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>
2026-06-22 18:22:59 +05:30

108 lines
3.6 KiB
TypeScript

/**
* Requisition service helpers shared by the crewing server actions and by the
* system auto-raise paths (sign-off / end-of-contract / leave-clash backfill,
* Phase 3/4). Kept out of the "use server" action module so non-action code can
* import the auto-raise helper. See Crewing-Implementation-Spec §5.2/§5.3 (R6).
*/
import { db } from "@/lib/db";
import { generateRequisitionCode } from "@/lib/requisition-number";
import { notifyCrew } from "@/lib/notifier";
import type { Prisma, RequisitionReason, User } from "@prisma/client";
type Tx = Prisma.TransactionClient;
export interface NewRequisitionInput {
rankId: string;
vesselId?: string | null;
siteId?: string | null;
reason: RequisitionReason;
neededBy?: Date | null;
notes?: string | null;
raisedById?: string | null; // null = system-raised
autoRaised?: boolean;
}
type RequisitionWithRefs = Prisma.RequisitionGetPayload<{
include: { rank: true; vessel: true; site: true };
}>;
/**
* Core requisition creator — run inside a transaction. Generates the code and
* writes the REQUISITION_RAISED CrewAction. Callers own notification + any
* relief-request linking afterwards.
*/
export async function createRequisitionTx(
tx: Tx,
input: NewRequisitionInput
): Promise<RequisitionWithRefs> {
const code = await generateRequisitionCode(tx);
return tx.requisition.create({
data: {
code,
reason: input.reason,
autoRaised: input.autoRaised ?? false,
neededBy: input.neededBy ?? null,
notes: input.notes ?? null,
rankId: input.rankId,
vesselId: input.vesselId ?? null,
siteId: input.siteId ?? null,
raisedById: input.raisedById ?? null,
actions: {
create: {
actionType: "REQUISITION_RAISED",
actorId: input.raisedById ?? null,
metadata: input.autoRaised ? { auto: true, reason: input.reason } : undefined,
},
},
},
include: { rank: true, vessel: true, site: true },
});
}
/** Human label for a requisition's cost axis (vessel preferred, else site). */
export function requisitionLocationLabel(r: {
vessel: { name: string } | null;
site: { name: string } | null;
}): string {
return r.vessel?.name ?? r.site?.name ?? "—";
}
/** Office recipients (MPO sources recruitment; Manager oversees). */
export function getOfficeRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "MANAGER", "SUPERUSER"] } },
});
}
/** MPO recipients — for "requisition raised → MPO" (spec §11). */
export function getMpoRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "SUPERUSER"] } },
});
}
/**
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
*/
export async function autoRaiseRequisition(
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
): Promise<RequisitionWithRefs> {
const requisition = await db.$transaction((tx) =>
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
);
const recipients = await getOfficeRecipients();
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} auto-raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
link: `/crewing/requisitions/${requisition.id}`,
});
return requisition;
}