pelagia-portal/App/app/(portal)/crewing/appraisals/actions.ts
Hardik c14a22588e
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): Phase 5b — appraisal (flagged)
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>
2026-06-22 22:09:32 +05:30

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