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() },
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
});
|
});
|
||||||
await db.crewAction.create({
|
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);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
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() },
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
});
|
});
|
||||||
await db.crewAction.create({
|
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);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -536,7 +536,7 @@ export async function returnSelection(id: string, reason: string): Promise<Actio
|
||||||
await db.$transaction(async (tx) => {
|
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.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.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);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
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 { SeafarerDocType, PpeItem } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
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> {
|
export async function deleteDocument(id: string): Promise<ActionResult> {
|
||||||
const g = await guard("upload_crew_records");
|
const g = await guard("upload_crew_records");
|
||||||
if ("error" in g) return g;
|
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" };
|
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));
|
revalidatePath(crewPath(doc.crewMemberId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +183,12 @@ export async function deleteNextOfKin(id: string): Promise<ActionResult> {
|
||||||
if ("error" in g) return g;
|
if ("error" in g) return g;
|
||||||
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
||||||
if (!nok) return { error: "Record not found" };
|
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));
|
revalidatePath(crewPath(nok.crewMemberId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +289,9 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
|
|
||||||
const off = new Date(signOffDate);
|
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.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
|
||||||
await tx.experienceRecord.create({
|
await tx.experienceRecord.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -300,15 +312,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
await tx.crewAction.create({
|
await tx.crewAction.create({
|
||||||
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
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
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
// Notify the office after the transaction commits.
|
||||||
// The seat is now vacant → auto-raise a backfill requisition (spec §5.3).
|
await notifyAutoRaised(backfill);
|
||||||
await autoRaiseRequisition({
|
|
||||||
rankId: assignment.rankId,
|
|
||||||
vesselId: assignment.vesselId,
|
|
||||||
siteId: assignment.siteId,
|
|
||||||
reason: "SIGN_OFF",
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(crewPath(assignment.crewMemberId));
|
revalidatePath(crewPath(assignment.crewMemberId));
|
||||||
revalidatePath("/crewing/crew");
|
revalidatePath("/crewing/crew");
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { leaveCausesClash } from "@/lib/leave-clash";
|
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 { notifyCrew } from "@/lib/notifier";
|
||||||
import { LeaveType } from "@prisma/client";
|
import { LeaveType } from "@prisma/client";
|
||||||
import type { Role } 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 };
|
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.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.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" } } });
|
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,
|
fromDate: leave.fromDate,
|
||||||
toDate: leave.toDate,
|
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).
|
// Notify the office after the transaction commits.
|
||||||
if (clash) {
|
if (backfill) await notifyAutoRaised(backfill);
|
||||||
await autoRaiseRequisition({
|
|
||||||
rankId: leave.assignment.rankId,
|
|
||||||
vesselId: leave.assignment.vesselId,
|
|
||||||
siteId: leave.assignment.siteId,
|
|
||||||
reason: "LEAVE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -89,18 +89,9 @@ export function getManagerRecipients(): Promise<User[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Notify the office that a requisition was auto-raised. Call AFTER the
|
||||||
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
|
* creating transaction commits (notifications are not part of the atomic write). */
|
||||||
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
|
export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise<void> {
|
||||||
* (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 recipients = await getOfficeRecipients();
|
||||||
const loc = requisitionLocationLabel(requisition);
|
const loc = requisitionLocationLabel(requisition);
|
||||||
await notifyCrew({
|
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}.`,
|
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
|
||||||
link: `/crewing/requisitions/${requisition.id}`,
|
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;
|
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
|
CANDIDATE_UPDATED
|
||||||
APPLICATION_CREATED
|
APPLICATION_CREATED
|
||||||
GATE_PASSED
|
GATE_PASSED
|
||||||
GATE_FAILED
|
|
||||||
REFERENCE_RECORDED
|
REFERENCE_RECORDED
|
||||||
SALARY_AGREED
|
SALARY_AGREED
|
||||||
SALARY_APPROVED
|
SALARY_APPROVED
|
||||||
|
SALARY_RETURNED
|
||||||
CANDIDATE_PROPOSED
|
CANDIDATE_PROPOSED
|
||||||
INTERVIEW_RECORDED
|
INTERVIEW_RECORDED
|
||||||
WAIVER_REQUESTED
|
WAIVER_REQUESTED
|
||||||
WAIVER_APPROVED
|
WAIVER_APPROVED
|
||||||
|
WAIVER_DECLINED
|
||||||
CANDIDATE_SELECTED
|
CANDIDATE_SELECTED
|
||||||
|
SELECTION_RETURNED
|
||||||
APPLICATION_REJECTED
|
APPLICATION_REJECTED
|
||||||
CREW_ONBOARDED
|
CREW_ONBOARDED
|
||||||
DOCUMENT_UPLOADED
|
DOCUMENT_UPLOADED
|
||||||
RECORD_UPDATED
|
RECORD_UPDATED
|
||||||
|
RECORD_DELETED
|
||||||
PPE_ISSUED
|
PPE_ISSUED
|
||||||
PPE_RETURNED
|
PPE_RETURNED
|
||||||
EXPERIENCE_ADDED
|
EXPERIENCE_ADDED
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
uploadDocument, deleteDocument, saveBankEpf,
|
uploadDocument, deleteDocument, saveBankEpf,
|
||||||
addNextOfKin, issuePpe, returnPpe, addExperience,
|
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
|
||||||
} from "@/app/(portal)/crewing/crew/actions";
|
} from "@/app/(portal)/crewing/crew/actions";
|
||||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
@ -67,6 +67,8 @@ describe("documents", () => {
|
||||||
|
|
||||||
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
|
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
|
||||||
expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0);
|
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 () => {
|
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);
|
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 } });
|
const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } });
|
||||||
expect(nok.isEmergency).toBe(true);
|
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("ok" in (await returnSalary(appId, "Re-negotiate basic"))).toBe(true);
|
||||||
|
|
||||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SALARY" } })).result).toBe("REJECTED");
|
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 } });
|
const app = await db.application.findUniqueOrThrow({ where: { id: appId } });
|
||||||
expect(app.interviewResult).toBe("PENDING");
|
expect(app.interviewResult).toBe("PENDING");
|
||||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SELECTION" } })).result).toBe("REJECTED");
|
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("error" in (await declineInterviewWaiver(appId, " "))).toBe(true); // reason required
|
||||||
expect("ok" in (await declineInterviewWaiver(appId, "Interview required"))).toBe(true);
|
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.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