pelagia-portal/App/app/(portal)/crewing/applications/actions.ts
Hardik 0679883273 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>
2026-06-22 23:46:23 +05:30

680 lines
29 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import {
canPerformAction,
canReject,
getTransition,
type ApplicationAction,
} from "@/lib/application-pipeline";
import { getManagerRecipients } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const appPath = (id: string) => `/crewing/applications/${id}`;
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// Load an application with the bits the actions need; null if missing.
async function loadApp(id: string) {
return db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
crewMember: { select: { id: true, name: true, type: true } },
},
});
}
function revalidateApp(applicationId: string, requisitionId: string) {
revalidatePath(appPath(applicationId));
revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
revalidatePath("/approvals");
}
// ── Add a candidate to a requisition's pipeline ────────────────────────────────
export async function addApplication(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const requisitionId = formData.get("requisitionId") as string;
const crewMemberId = formData.get("crewMemberId") as string;
if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
const [requisition, candidate, existing] = await Promise.all([
db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
]);
if (!requisition) return { error: "Requisition not found" };
if (!candidate) return { error: "Candidate not found" };
if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
return { error: `Cannot add candidates to a ${requisition.status} requisition` };
}
if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
const application = await db.application.create({
data: {
requisitionId,
crewMemberId,
type: candidate.type,
stage: "SHORTLISTED",
actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
},
});
// First candidate moves the requisition from OPEN into sourcing.
if (requisition.status === "OPEN") {
await db.requisition.update({
where: { id: requisitionId },
data: {
status: "SHORTLISTING",
actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
},
});
}
revalidateApp(application.id, requisitionId);
return { ok: true, id: application.id };
}
// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
// select have dedicated actions below.
export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
return { error: "Use the dedicated action for this step" };
}
const g = await guard("manage_candidates");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, action);
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
// gate is completed by `verify_competency`.
if (action === "verify_competency") {
const references = await db.referenceCheck.count({ where: { applicationId: id } });
if (references === 0) {
return { error: "Record at least one reference check before completing competency & references" };
}
}
await db.application.update({
where: { id },
data: {
stage: transition.to,
// Completing the competency & references stage records its gate.
...(action === "verify_competency"
? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
: {}),
actions: {
create: {
actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
actorId: g.userId,
crewMemberId: app.crewMemberId,
metadata: { from: app.stage, to: transition.to },
},
},
},
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
const referenceSchema = z.object({
refereeName: z.string().trim().min(1, "Referee name is required"),
refereeContact: z.string().optional(),
outcome: z.string().optional(),
note: z.string().optional(),
});
export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
const g = await guard("record_reference_check");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const parsed = referenceSchema.safeParse({
refereeName: formData.get("refereeName"),
refereeContact: (formData.get("refereeContact") as string) || undefined,
outcome: (formData.get("outcome") as string) || undefined,
note: (formData.get("note") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
await db.referenceCheck.create({
data: {
applicationId: id,
refereeName: parsed.data.refereeName,
refereeContact: parsed.data.refereeContact ?? null,
outcome: parsed.data.outcome ?? null,
note: parsed.data.note ?? null,
recordedById: g.userId,
},
});
await db.crewAction.create({
data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
const docsSchema = z.object({
accountName: z.string().optional(),
accountNumber: z.string().optional(),
ifsc: z.string().optional(),
bankName: z.string().optional(),
uan: z.string().optional(),
aadhaarLast4: z.string().optional(),
pfNumber: z.string().optional(),
note: z.string().optional(),
});
export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, "verify_docs");
if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
const parsed = docsSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const crewMemberId = app.crewMember.id;
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
// the seat's rank is EXPIRED.
// Scope note (documented limitation): seafarer documents are collected on the
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
// usually has none on file, so a hard "missing document" block would stall the
// whole funnel. We therefore gate on what is available (expiry of documents the
// candidate already holds); the "all required documents present" check is
// enforced post-onboarding in the verification queue (§8.11). Once careers
// intake (A2) uploads documents pre-onboarding, tighten this to also require
// presence of every mandatory docType.
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
if (reqRank) {
const [required, candidateDocs] = await Promise.all([
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
]);
const requiredTypes = new Set(required.map((r) => r.docType));
const now = new Date();
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
if (expired.length > 0) {
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
}
}
// C4 (experience check) is deferred: the Requisition has no min-experience
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
// the candidate's ExperienceRecord total against it here and flag a shortfall.
await db.$transaction(async (tx) => {
// Capture bank / EPF (PII — encryption deferred to Phase 4).
await tx.bankDetail.upsert({
where: { crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.application.update({
where: { id },
data: {
stage: transition.to,
gates: {
create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
},
actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
const salarySchema = z.object({
rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
basic: z.coerce.number().positive("Basic must be greater than 0"),
victualingPerDay: z.coerce.number().min(0).default(0),
currency: z.string().default("INR"),
});
export async function agreeSalary(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
const parsed = salarySchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
await db.$transaction(async (tx) => {
// One live proposed structure per application — replace any prior draft.
await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
await tx.salaryStructure.create({
data: {
applicationId: id,
rateBasis: d.rateBasis,
basic: d.basic,
victualingPerDay: d.victualingPerDay,
currency: d.currency,
},
});
// Salary gate goes PENDING for the Manager's queue.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
update: { result: "PENDING", decidedById: null, note: null },
create: { applicationId: id, gate: "SALARY", result: "PENDING" },
});
await tx.crewAction.create({
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
});
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SALARY_FOR_APPROVAL",
recipients: managers,
subject: `Salary for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveSalary(id: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
await db.$transaction(async (tx) => {
await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: {
stage: "PROPOSED",
actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return for revision" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "SALARY" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
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 };
}
// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
const g = await guard("record_interview_result");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
if (!accepted) {
// A failed interview rejects the application.
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
}
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
});
// Selection now pending for the Manager.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SELECTION_FOR_APPROVAL",
recipients: managers,
subject: `Selection for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
const g = await guard("request_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
await db.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
});
await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "WAIVER_REQUESTED",
recipients: managers,
subject: `Interview waiver requested — ${app.crewMember.name}`,
body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewWaived: true } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
// Waived → selection is now pending.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to decline" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "WAIVER" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
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 };
}
export async function selectCandidate(id: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
}
await db.$transaction(async (tx) => {
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
});
// The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
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: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
async function rejectApplicationInternal(
id: string,
crewMemberId: string,
requisitionId: string,
userId: string,
reason: string
): Promise<ActionResult> {
await db.application.update({
where: { id },
data: {
stage: "REJECTED",
rejectedReason: reason,
rejectedAt: new Date(),
actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
},
});
revalidateApp(id, requisitionId);
return { ok: true };
}
export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to reject" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
}
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
// One transaction off a SELECTED application: assign the employee number, create
// the ACTIVE assignment, bind the approved salary, flip the application to
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
// Login-account creation for management ranks is a deferred follow-up.
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("onboard_crew");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const joiningStr = formData.get("joiningDate") as string;
if (!joiningStr) return { error: "A joining date is required" };
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
crewMember: { select: { id: true, name: true, email: true } },
},
});
if (!app) return { error: "Application not found" };
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
// D1 (spec §8.5): onboarding is blocked until the salary structure is
// Manager-approved. Without this guard a SELECTED application that somehow has
// no approved structure would still "succeed" but bind zero salary rows
// (the updateMany below would match nothing) — a silent payroll gap.
const approvedSalary = await db.salaryStructure.findFirst({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
select: { id: true },
orderBy: { createdAt: "desc" },
});
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
const joiningDate = new Date(joiningStr);
// Upload the optional contract letter BEFORE the transaction (storage I/O),
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
// event (spec §11). The blob key is keyed on the crew member (stable before the
// assignment exists); if the tx fails we leave only a harmless orphan blob,
// never a fully-onboarded crew member with no contract row.
const file = formData.get("contract");
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
if (file instanceof File && file.size > 0) {
const key = buildStorageKey("contract", app.crewMember.id, file.name);
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
}
const result = await db.$transaction(async (tx) => {
const employeeId = await generateEmployeeId(tx);
const assignment = await tx.crewAssignment.create({
data: {
status: "ACTIVE",
signOnDate: joiningDate,
crewMemberId: app.crewMember.id,
rankId: app.requisition.rankId,
vesselId: app.requisition.vesselId,
siteId: app.requisition.siteId,
requisitionId: app.requisition.id,
},
});
// Bind the Manager-approved salary structure to the new assignment.
await tx.salaryStructure.updateMany({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
});
if (contract) {
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
}
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
await tx.application.update({
where: { id },
data: {
stage: "ONBOARDED",
actions: {
create: {
actionType: "CREW_ONBOARDED",
actorId: g.userId,
crewMemberId: app.crewMember.id,
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
},
},
},
});
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
});
await tx.crewMember.update({
where: { id: app.crewMember.id },
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
});
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
return { assignmentId: assignment.id, employeeId };
});
revalidateApp(id, app.requisition.id);
return { ok: true, id: result.employeeId };
}