Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged. What's in - Schema (crewing_pipeline migration): Application (one per requisition+candidate) + 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending = Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction += applicationId; pipeline CrewActionTypes. - State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager; approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES. - Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage, recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary, recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate (→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2). Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED. - Screens: pipeline board per requisition (7 columns + Add candidate); application workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the requisition detail. Central /approvals gains a crewing section (inline Approve/Return) for one unified Manager queue (§8.13 R8). Tests & docs - Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) — full happy path, salary/selection/waiver approvals + Manager-only gating, failed interview, reject, site-staff lockout. type-check clean; full unit (234) + integration (163) green. - CLAUDE.md "Crewing" updated with the Phase 3b surface. Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment, employeeId, requisition → FILLED, salary bound to the assignment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
537 lines
22 KiB
TypeScript
537 lines
22 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, getMpoRecipients } from "@/lib/requisition-service";
|
|
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" };
|
|
|
|
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;
|
|
|
|
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_AGREED", 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_REQUESTED", 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: "INTERVIEW_RECORDED", 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());
|
|
}
|