Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on 5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5. What's in - Schema: Appraisal (on CrewAssignment) + AppraisalStatus (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal. - State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED, MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. - Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/ site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal (approve_appraisal — Manager); reject paths require remarks; notifications APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL. - Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals tab; MPO verifies in the Verification queue (Appraisals section); Manager approves in the central /approvals queue (Appraisal kind). Tests & docs - Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) — raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise, site staff can't verify, MPO can't approve). type-check clean; full unit (245) + integration (205) green (verified with RESEND_API_KEY unset). - CLAUDE.md updated — completes Phase 5 (I + H). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
6.6 KiB
TypeScript
146 lines
6.6 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 } from "@/lib/appraisal-state-machine";
|
|
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
|
|
import { notifyCrew } from "@/lib/notifier";
|
|
import type { Role } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
|
|
|
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 };
|
|
}
|
|
|
|
function loadAppraisal(id: string) {
|
|
return db.appraisal.findUnique({
|
|
where: { id },
|
|
include: { assignment: { include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } } } },
|
|
});
|
|
}
|
|
|
|
function revalidate(crewMemberId: string) {
|
|
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
|
revalidatePath("/crewing/verification");
|
|
revalidatePath("/approvals");
|
|
}
|
|
|
|
// ── Raise an appraisal (PM / site staff) ───────────────────────────────────────
|
|
|
|
const raiseSchema = z.object({
|
|
assignmentId: z.string().min(1, "Crew assignment is required"),
|
|
period: z.string().trim().min(1, "Period is required"),
|
|
comments: z.string().optional(),
|
|
competence: z.coerce.number().int().min(1).max(5).optional(),
|
|
conduct: z.coerce.number().int().min(1).max(5).optional(),
|
|
safety: z.coerce.number().int().min(1).max(5).optional(),
|
|
});
|
|
|
|
export async function raiseAppraisal(formData: FormData): Promise<ActionResult> {
|
|
const g = await guard("raise_appraisal");
|
|
if ("error" in g) return g;
|
|
|
|
const parsed = raiseSchema.safeParse(Object.fromEntries(formData));
|
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
const d = parsed.data;
|
|
|
|
const assignment = await db.crewAssignment.findUnique({
|
|
where: { id: d.assignmentId },
|
|
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
|
|
});
|
|
if (!assignment) return { error: "Crew assignment not found" };
|
|
|
|
const appraisal = await db.appraisal.create({
|
|
data: {
|
|
assignmentId: d.assignmentId,
|
|
period: d.period,
|
|
comments: d.comments ?? null,
|
|
ratings: { competence: d.competence ?? null, conduct: d.conduct ?? null, safety: d.safety ?? null },
|
|
status: "SUBMITTED",
|
|
addedById: g.userId,
|
|
},
|
|
});
|
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_SUBMITTED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
|
|
|
|
const mpos = await getMpoRecipients();
|
|
await notifyCrew({
|
|
event: "APPRAISAL_FOR_VERIFICATION",
|
|
recipients: mpos,
|
|
subject: `Appraisal to verify — ${assignment.crewMember.name}`,
|
|
body: `An appraisal for ${assignment.crewMember.name} (${assignment.rank.name}, ${d.period}) awaits MPO verification.`,
|
|
link: "/crewing/verification",
|
|
});
|
|
|
|
revalidate(assignment.crewMember.id);
|
|
return { ok: true, id: appraisal.id };
|
|
}
|
|
|
|
// ── Verify (MPO) ───────────────────────────────────────────────────────────────
|
|
|
|
export async function verifyAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
|
const g = await guard("verify_appraisal");
|
|
if ("error" in g) return g;
|
|
|
|
const a = await loadAppraisal(id);
|
|
if (!a) return { error: "Appraisal not found" };
|
|
|
|
if (!approve) {
|
|
if (!canReject(a.status)) return { error: `Cannot reject from ${a.status}` };
|
|
if (!remarks?.trim()) return { error: "A reason is required to reject" };
|
|
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
|
revalidate(a.assignment.crewMember.id);
|
|
return { ok: true };
|
|
}
|
|
|
|
if (!canPerformAction(a.status, "verify", g.role)) return { error: `Cannot verify from ${a.status}` };
|
|
await db.appraisal.update({ where: { id }, data: { status: "MPO_VERIFIED", verifiedById: g.userId } });
|
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_VERIFIED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
|
|
|
const managers = await getManagerRecipients();
|
|
await notifyCrew({
|
|
event: "APPRAISAL_FOR_APPROVAL",
|
|
recipients: managers,
|
|
subject: `Appraisal for approval — ${a.assignment.crewMember.name}`,
|
|
body: `${a.assignment.crewMember.name}'s appraisal (${a.assignment.rank.name}, ${a.period}) has been MPO-verified and awaits your approval.`,
|
|
link: "/approvals",
|
|
});
|
|
|
|
revalidate(a.assignment.crewMember.id);
|
|
return { ok: true };
|
|
}
|
|
|
|
// ── Approve (Manager) ──────────────────────────────────────────────────────────
|
|
|
|
export async function approveAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
|
const g = await guard("approve_appraisal");
|
|
if ("error" in g) return g;
|
|
|
|
const a = await loadAppraisal(id);
|
|
if (!a) return { error: "Appraisal not found" };
|
|
|
|
if (!approve) {
|
|
if (!canReject(a.status)) return { error: `Cannot return from ${a.status}` };
|
|
if (!remarks?.trim()) return { error: "A reason is required to return" };
|
|
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
|
revalidate(a.assignment.crewMember.id);
|
|
return { ok: true };
|
|
}
|
|
|
|
if (!canPerformAction(a.status, "approve", g.role)) return { error: `Cannot approve from ${a.status}` };
|
|
await db.appraisal.update({ where: { id }, data: { status: "MANAGER_APPROVED", approvedById: g.userId } });
|
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_APPROVED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
|
|
|
revalidate(a.assignment.crewMember.id);
|
|
return { ok: true };
|
|
}
|