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>
680 lines
29 KiB
TypeScript
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 };
|
|
}
|