import { PermissionKey, ProjectStatus } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; // ─── Allowed status transitions ─────────────────────────────────────────────── const ALLOWED_TRANSITIONS: Record = { [ProjectStatus.DRAFT]: [ProjectStatus.ACTIVE, ProjectStatus.CANCELLED], [ProjectStatus.ACTIVE]: [ProjectStatus.ON_HOLD, ProjectStatus.COMPLETED, ProjectStatus.CANCELLED], [ProjectStatus.ON_HOLD]: [ProjectStatus.ACTIVE, ProjectStatus.CANCELLED], [ProjectStatus.COMPLETED]: [ProjectStatus.ACTIVE], // re-open only [ProjectStatus.CANCELLED]: [ProjectStatus.DRAFT], // revive only }; import { adminProcedure, managerProcedure, requirePermission, type TRPCContext } from "../trpc.js"; type ProjectLifecycleContext = Pick & { permissions: Set; }; type ProjectLifecycleDependencies = { invalidateDashboardCacheInBackground: () => void; dispatchProjectWebhookInBackground: ( db: TRPCContext["db"], event: string, payload: Record, ) => void; }; async function deleteProjectCascade( ctx: Pick, project: { id: string; name: string; shortCode: string }, ) { await ctx.db.$transaction(async (tx) => { await tx.assignment.deleteMany({ where: { projectId: project.id } }); await tx.demandRequirement.deleteMany({ where: { projectId: project.id } }); await tx.calculationRule.updateMany({ where: { projectId: project.id }, data: { projectId: null }, }); await tx.project.delete({ where: { id: project.id } }); await tx.auditLog.create({ data: { entityType: "Project", entityId: project.id, action: "DELETE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { before: { id: project.id, name: project.name, shortCode: project.shortCode } } as never, }, }); }); } async function deleteProjectsCascade( ctx: Pick, projects: Array<{ id: string; name: string; shortCode: string }>, ) { await ctx.db.$transaction(async (tx) => { const ids = projects.map((project) => project.id); await tx.assignment.deleteMany({ where: { projectId: { in: ids } } }); await tx.demandRequirement.deleteMany({ where: { projectId: { in: ids } } }); await tx.calculationRule.updateMany({ where: { projectId: { in: ids } }, data: { projectId: null }, }); await tx.project.deleteMany({ where: { id: { in: ids } } }); await tx.auditLog.create({ data: { entityType: "Project", entityId: ids.join(","), action: "DELETE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { before: projects } as never, }, }); }); } export function createProjectLifecycleProcedures( dependencies: ProjectLifecycleDependencies, ) { return { updateStatus: managerProcedure .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const current = await ctx.db.project.findUnique({ where: { id: input.id }, select: { id: true, status: true }, }); if (!current) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } if (current.status !== input.status) { const allowed = ALLOWED_TRANSITIONS[current.status] ?? []; if (!allowed.includes(input.status)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Cannot transition project from ${current.status} to ${input.status}. Allowed: ${allowed.join(", ") || "none"}.`, }); } } const result = await ctx.db.project.update({ where: { id: input.id }, data: { status: input.status }, }); dependencies.invalidateDashboardCacheInBackground(); dependencies.dispatchProjectWebhookInBackground(ctx.db, "project.status_changed", { id: result.id, shortCode: result.shortCode, name: result.name, status: result.status, }); return result; }), batchUpdateStatus: managerProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(100), status: z.nativeEnum(ProjectStatus), }), ) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const updated = await ctx.db.$transaction(async (tx) => { const results = await Promise.all( input.ids.map((id) => tx.project.update({ where: { id }, data: { status: input.status } }), ), ); await tx.auditLog.create({ data: { entityType: "Project", entityId: input.ids.join(","), action: "UPDATE", changes: { after: { status: input.status, ids: input.ids } }, }, }); return results; }); dependencies.invalidateDashboardCacheInBackground(); return { count: updated.length }; }), delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const project = await ctx.db.project.findUnique({ where: { id: input.id }, select: { id: true, name: true, shortCode: true }, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } await deleteProjectCascade(ctx, project); dependencies.invalidateDashboardCacheInBackground(); return { id: input.id, name: project.name }; }), batchDelete: adminProcedure .input( z.object({ ids: z.array(z.string()).min(1).max(50), }), ) .mutation(async ({ ctx, input }) => { const projects = await ctx.db.project.findMany({ where: { id: { in: input.ids } }, select: { id: true, name: true, shortCode: true }, }); if (projects.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "No projects found" }); } await deleteProjectsCascade(ctx, projects); dependencies.invalidateDashboardCacheInBackground(); return { count: projects.length }; }), }; }