From 0679883273926f8090893a07e7a38762adf7ddc9 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 23:46:23 +0530 Subject: [PATCH] refactor(crewing): correct audit action types + atomic auto-raise backfills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../(portal)/crewing/applications/actions.ts | 6 +-- App/app/(portal)/crewing/crew/actions.ts | 36 ++++++++----- App/app/(portal)/crewing/leave/actions.ts | 23 ++++----- App/lib/requisition-service.ts | 37 +++++++++----- .../migration.sql | 51 +++++++++++++++++++ App/prisma/schema.prisma | 5 +- App/tests/integration/crew-records.test.ts | 8 ++- App/tests/integration/crewing-gates.test.ts | 4 ++ 8 files changed, 128 insertions(+), 42 deletions(-) create mode 100644 App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts index 6baf102..850923a 100644 --- a/App/app/(portal)/crewing/applications/actions.ts +++ b/App/app/(portal)/crewing/applications/actions.ts @@ -369,7 +369,7 @@ export async function returnSalary(id: string, reason: string): Promise { 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 }; diff --git a/App/app/(portal)/crewing/crew/actions.ts b/App/app/(portal)/crewing/crew/actions.ts index 2a1f7da..185835a 100644 --- a/App/app/(portal)/crewing/crew/actions.ts +++ b/App/app/(portal)/crewing/crew/actions.ts @@ -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 export async function deleteDocument(id: string): Promise { 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 { 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"); diff --git a/App/app/(portal)/crewing/leave/actions.ts b/App/app/(portal)/crewing/leave/actions.ts index 0363b85..a83487e 100644 --- a/App/app/(portal)/crewing/leave/actions.ts +++ b/App/app/(portal)/crewing/leave/actions.ts @@ -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 }; diff --git a/App/lib/requisition-service.ts b/App/lib/requisition-service.ts index fb6c4f2..92a3c86 100644 --- a/App/lib/requisition-service.ts +++ b/App/lib/requisition-service.ts @@ -89,18 +89,9 @@ export function getManagerRecipients(): Promise { }); } -/** - * 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 -): Promise { - 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 { 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, + tx?: Tx +): Promise { + 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; } diff --git a/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql b/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql new file mode 100644 index 0000000..2cb02d5 --- /dev/null +++ b/App/prisma/migrations/20260622180000_crewing_audit_action_types/migration.sql @@ -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; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 348239d..8016b48 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -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 diff --git a/App/tests/integration/crew-records.test.ts b/App/tests/integration/crew-records.test.ts index 0f8bdd1..4fb9c14 100644 --- a/App/tests/integration/crew-records.test.ts +++ b/App/tests/integration/crew-records.test.ts @@ -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); }); }); diff --git a/App/tests/integration/crewing-gates.test.ts b/App/tests/integration/crewing-gates.test.ts index 6e733bd..429c626 100644 --- a/App/tests/integration/crewing-gates.test.ts +++ b/App/tests/integration/crewing-gates.test.ts @@ -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); }); });