refactor(crewing): correct audit action types + atomic auto-raise backfills

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>
This commit is contained in:
Hardik 2026-06-22 23:46:23 +05:30
parent 184250f903
commit 0679883273
8 changed files with 128 additions and 42 deletions

View file

@ -369,7 +369,7 @@ export async function returnSalary(id: string, reason: string): Promise<ActionRe
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
@ -486,7 +486,7 @@ export async function declineInterviewWaiver(id: string, reason: string): Promis
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
@ -536,7 +536,7 @@ export async function returnSelection(id: string, reason: string): Promise<Actio
await db.$transaction(async (tx) => {
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };

View file

@ -5,7 +5,7 @@ import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { autoRaiseRequisition } from "@/lib/requisition-service";
import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
import { SeafarerDocType, PpeItem } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
@ -83,9 +83,14 @@ export async function uploadDocument(formData: FormData): Promise<ActionResult>
export async function deleteDocument(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true } });
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
if (!doc) return { error: "Document not found" };
await db.seafarerDocument.delete({ where: { id } });
await db.$transaction(async (tx) => {
await tx.seafarerDocument.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
});
});
revalidatePath(crewPath(doc.crewMemberId));
return { ok: true };
}
@ -178,7 +183,12 @@ export async function deleteNextOfKin(id: string): Promise<ActionResult> {
if ("error" in g) return g;
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
if (!nok) return { error: "Record not found" };
await db.nextOfKin.delete({ where: { id } });
await db.$transaction(async (tx) => {
await tx.nextOfKin.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
});
});
revalidatePath(crewPath(nok.crewMemberId));
return { ok: true };
}
@ -279,7 +289,9 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
const off = new Date(signOffDate);
await db.$transaction(async (tx) => {
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
// seat can never become vacant without its backfill being raised.
const backfill = await db.$transaction(async (tx) => {
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
await tx.experienceRecord.create({
data: {
@ -300,15 +312,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
});
return autoRaiseRequisition(
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
tx
);
});
// The seat is now vacant → auto-raise a backfill requisition (spec §5.3).
await autoRaiseRequisition({
rankId: assignment.rankId,
vesselId: assignment.vesselId,
siteId: assignment.siteId,
reason: "SIGN_OFF",
});
// Notify the office after the transaction commits.
await notifyAutoRaised(backfill);
revalidatePath(crewPath(assignment.crewMemberId));
revalidatePath("/crewing/crew");

View file

@ -5,7 +5,7 @@ import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { leaveCausesClash } from "@/lib/leave-clash";
import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service";
import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { LeaveType } from "@prisma/client";
import type { Role } from "@prisma/client";
@ -110,7 +110,9 @@ export async function decideLeave(id: string, approve: boolean, note?: string):
return { ok: true };
}
const { clash } = await db.$transaction(async (tx) => {
// Leave approval + the clash check + any backfill requisition commit atomically
// (spec §5.3/§11): an approved leave can never leave a cover gap un-raised.
const backfill = await db.$transaction(async (tx) => {
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
@ -121,18 +123,15 @@ export async function decideLeave(id: string, approve: boolean, note?: string):
fromDate: leave.fromDate,
toDate: leave.toDate,
});
return { clash };
if (!clash) return null;
return autoRaiseRequisition(
{ rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" },
tx
);
});
// A detected clash auto-raises a LEAVE requisition (reuses the Phase-2 helper).
if (clash) {
await autoRaiseRequisition({
rankId: leave.assignment.rankId,
vesselId: leave.assignment.vesselId,
siteId: leave.assignment.siteId,
reason: "LEAVE",
});
}
// Notify the office after the transaction commits.
if (backfill) await notifyAutoRaised(backfill);
revalidate();
return { ok: true };

View file

@ -89,18 +89,9 @@ export function getManagerRecipients(): Promise<User[]> {
});
}
/**
* 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 })
);
/** 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({
@ -110,6 +101,28 @@ export async function autoRaiseRequisition(
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;
}

View file

@ -0,0 +1,51 @@
-- Recreate CrewActionType: add explicit return/decline/delete audit types and
-- drop the unused GATE_FAILED value (see Crewing audit-trail consistency cleanup,
-- spec §11). One recreate adds + removes in a single migration.
BEGIN;
CREATE TYPE "CrewActionType_new" AS ENUM (
'REQUISITION_RAISED',
'REQUISITION_ADVANCED',
'REQUISITION_FILLED',
'REQUISITION_CANCELLED',
'RELIEF_REQUESTED',
'RELIEF_CONVERTED',
'RELIEF_CANCELLED',
'CANDIDATE_ADDED',
'CANDIDATE_UPDATED',
'APPLICATION_CREATED',
'GATE_PASSED',
'REFERENCE_RECORDED',
'SALARY_AGREED',
'SALARY_APPROVED',
'SALARY_RETURNED',
'CANDIDATE_PROPOSED',
'INTERVIEW_RECORDED',
'WAIVER_REQUESTED',
'WAIVER_APPROVED',
'WAIVER_DECLINED',
'CANDIDATE_SELECTED',
'SELECTION_RETURNED',
'APPLICATION_REJECTED',
'CREW_ONBOARDED',
'DOCUMENT_UPLOADED',
'RECORD_UPDATED',
'RECORD_DELETED',
'PPE_ISSUED',
'PPE_RETURNED',
'EXPERIENCE_ADDED',
'LEAVE_APPLIED',
'LEAVE_DECIDED',
'ATTENDANCE_RECORDED',
'CREW_SIGNED_OFF',
'RECORD_VERIFIED',
'RECORD_REJECTED',
'APPRAISAL_SUBMITTED',
'APPRAISAL_VERIFIED',
'APPRAISAL_APPROVED',
'APPRAISAL_REJECTED'
);
ALTER TABLE "CrewAction" ALTER COLUMN "actionType" TYPE "CrewActionType_new" USING ("actionType"::text::"CrewActionType_new");
ALTER TYPE "CrewActionType" RENAME TO "CrewActionType_old";
ALTER TYPE "CrewActionType_new" RENAME TO "CrewActionType";
DROP TYPE "CrewActionType_old";
COMMIT;

View file

@ -135,19 +135,22 @@ enum CrewActionType {
CANDIDATE_UPDATED
APPLICATION_CREATED
GATE_PASSED
GATE_FAILED
REFERENCE_RECORDED
SALARY_AGREED
SALARY_APPROVED
SALARY_RETURNED
CANDIDATE_PROPOSED
INTERVIEW_RECORDED
WAIVER_REQUESTED
WAIVER_APPROVED
WAIVER_DECLINED
CANDIDATE_SELECTED
SELECTION_RETURNED
APPLICATION_REJECTED
CREW_ONBOARDED
DOCUMENT_UPLOADED
RECORD_UPDATED
RECORD_DELETED
PPE_ISSUED
PPE_RETURNED
EXPERIENCE_ADDED

View file

@ -13,7 +13,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
uploadDocument, deleteDocument, saveBankEpf,
addNextOfKin, issuePpe, returnPpe, addExperience,
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
} from "@/app/(portal)/crewing/crew/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
@ -67,6 +67,8 @@ describe("documents", () => {
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0);
// Deletions of PII-bearing records are audited (M3).
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
});
it("is rejected for a role without upload_crew_records (accounts)", async () => {
@ -97,6 +99,10 @@ describe("next of kin", () => {
expect("ok" in (await addNextOfKin(fd({ crewMemberId: id, name: "Spouse", relationship: "Wife", isEmergency: "true" })))).toBe(true);
const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } });
expect(nok.isEmergency).toBe(true);
// Removal is audited (M3).
expect("ok" in (await deleteNextOfKin(nok.id))).toBe(true);
expect(await db.nextOfKin.count({ where: { crewMemberId: id } })).toBe(0);
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
});
});

View file

@ -110,6 +110,8 @@ describe("salary return is Manager-only and audited (R8)", () => {
expect("ok" in (await returnSalary(appId, "Re-negotiate basic"))).toBe(true);
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SALARY" } })).result).toBe("REJECTED");
// Audited as a return, not as a forward "salary agreed".
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SALARY_RETURNED" } })).toBe(1);
});
});
@ -126,6 +128,7 @@ describe("selection return is Manager-only (R8)", () => {
const app = await db.application.findUniqueOrThrow({ where: { id: appId } });
expect(app.interviewResult).toBe("PENDING");
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SELECTION" } })).result).toBe("REJECTED");
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SELECTION_RETURNED" } })).toBe(1);
});
});
@ -152,6 +155,7 @@ describe("interview waiver can never reach a NEW candidate (R2)", () => {
expect("error" in (await declineInterviewWaiver(appId, " "))).toBe(true); // reason required
expect("ok" in (await declineInterviewWaiver(appId, "Interview required"))).toBe(true);
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "WAIVER" } })).result).toBe("REJECTED");
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "WAIVER_DECLINED" } })).toBe(1);
});
});