From a23ef2c8b54b57617188fa220b90a18e93078e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 09:03:00 +0200 Subject: [PATCH] refactor(api): extract project lifecycle procedures --- packages/api/src/router/project-lifecycle.ts | 155 +++++++++++++++++++ packages/api/src/router/project.ts | 131 +--------------- 2 files changed, 161 insertions(+), 125 deletions(-) create mode 100644 packages/api/src/router/project-lifecycle.ts diff --git a/packages/api/src/router/project-lifecycle.ts b/packages/api/src/router/project-lifecycle.ts new file mode 100644 index 0000000..c060b3b --- /dev/null +++ b/packages/api/src/router/project-lifecycle.ts @@ -0,0 +1,155 @@ +import { PermissionKey, ProjectStatus } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +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 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( + input.ids.map((id) => + ctx.db.project.update({ where: { id }, data: { status: input.status } }), + ), + ); + + await ctx.db.auditLog.create({ + data: { + entityType: "Project", + entityId: input.ids.join(","), + action: "UPDATE", + changes: { after: { status: input.status, ids: input.ids } }, + }, + }); + + 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 }; + }), + }; +} diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 4e2dd31..0803fd0 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -11,8 +11,9 @@ import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { projectCostReadProcedures } from "./project-cost-read.js"; import { projectCoverProcedures } from "./project-cover.js"; import { projectIdentifierReadProcedures } from "./project-identifier-read.js"; +import { createProjectLifecycleProcedures } from "./project-lifecycle.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; -import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; +import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { logger } from "../lib/logger.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; @@ -57,6 +58,10 @@ export const projectRouter = createTRPCRouter({ ...projectCostReadProcedures, ...projectCoverProcedures, ...projectIdentifierReadProcedures, + ...createProjectLifecycleProcedures({ + invalidateDashboardCacheInBackground, + dispatchProjectWebhookInBackground, + }), list: controllerProcedure .input( @@ -338,128 +343,4 @@ export const projectRouter = createTRPCRouter({ return updated; }), - updateStatus: managerProcedure - .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) - .mutation(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - const result = await ctx.db.project.update({ - where: { id: input.id }, - data: { status: input.status }, - }); - invalidateDashboardCacheInBackground(); - 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( - input.ids.map((id) => - ctx.db.project.update({ where: { id }, data: { status: input.status } }), - ), - ); - - await ctx.db.auditLog.create({ - data: { - entityType: "Project", - entityId: input.ids.join(","), - action: "UPDATE", - changes: { after: { status: input.status, ids: input.ids } }, - }, - }); - - 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" }); - - // Delete all related records in a transaction - await ctx.db.$transaction(async (tx) => { - // Delete assignments (which reference demandRequirements) - await tx.assignment.deleteMany({ where: { projectId: input.id } }); - // Delete demand requirements - await tx.demandRequirement.deleteMany({ where: { projectId: input.id } }); - // Unlink calculation rules - await tx.calculationRule.updateMany({ - where: { projectId: input.id }, - data: { projectId: null }, - }); - // Delete the project - await tx.project.delete({ where: { id: input.id } }); - // Audit log - await tx.auditLog.create({ - data: { - entityType: "Project", - entityId: input.id, - action: "DELETE", - ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), - changes: { before: { id: project.id, name: project.name, shortCode: project.shortCode } } as never, - }, - }); - }); - - 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 ctx.db.$transaction(async (tx) => { - const ids = projects.map((p) => p.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, - }, - }); - }); - - invalidateDashboardCacheInBackground(); - return { count: projects.length }; - }), - });