Audit-trail & transaction consistency (spec §11 "one transition, one row"): - Action types: returnSalary/returnSelection/declineInterviewWaiver no longer mislabel a backward decision as its forward action. New CrewActionType members SALARY_RETURNED / SELECTION_RETURNED / WAIVER_DECLINED; added RECORD_DELETED; dropped the unused GATE_FAILED (migration recreates the enum). - Deletions are audited: deleteDocument / deleteNextOfKin now write a RECORD_DELETED CrewAction (PII removals are traceable). - Atomicity: autoRaiseRequisition takes an optional tx so the leave-clash and sign-off backfills are created INSIDE the approval/sign-off transaction; the office notification (notifyAutoRaised) fires after commit. An approved leave or a sign-off can no longer commit without its backfill requisition. Tests assert the corrected action types (crewing-gates, crew-records) and the existing clash/sign-off suites still pass with the in-transaction backfill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
4.6 KiB
TypeScript
128 lines
4.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"] } },
|
|
});
|
|
}
|
|
|
|
/** Manager recipients — for the approval gates (salary / selection / waiver). */
|
|
export function getManagerRecipients(): Promise<User[]> {
|
|
return db.user.findMany({
|
|
where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
|
|
});
|
|
}
|
|
|
|
/** Notify the office that a requisition was auto-raised. Call AFTER the
|
|
* creating transaction commits (notifications are not part of the atomic write). */
|
|
export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise<void> {
|
|
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}`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* System auto-raise: an OPEN requisition with no human actor (autoRaised).
|
|
* Sign-off, end-of-contract and the leave-clash detector funnel through here.
|
|
* See spec §5.2/§5.3 (R6).
|
|
*
|
|
* Pass `tx` to create the backfill **atomically inside the caller's transaction**
|
|
* (so an approved leave / sign-off can never commit without its backfill) — the
|
|
* caller then owns the post-commit `notifyAutoRaised`. Called without `tx`, it
|
|
* runs its own transaction and notifies itself.
|
|
*/
|
|
export async function autoRaiseRequisition(
|
|
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">,
|
|
tx?: Tx
|
|
): Promise<RequisitionWithRefs> {
|
|
const data = { ...input, raisedById: null, autoRaised: true };
|
|
if (tx) {
|
|
// Caller's transaction — caller is responsible for notifyAutoRaised after commit.
|
|
return createRequisitionTx(tx, data);
|
|
}
|
|
const requisition = await db.$transaction((t) => createRequisitionTx(t, data));
|
|
await notifyAutoRaised(requisition);
|
|
return requisition;
|
|
}
|