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:
parent
184250f903
commit
0679883273
8 changed files with 128 additions and 42 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue