"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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 }; }