Files
CapaKraken/packages/api/src/router/project-lifecycle.ts
T
Hartmut 3c0179fcec fix(api): wrap audit log writes inside their parent transactions
Prevents mutations from committing without an audit trail if the
auditLog.create call fails after the main write already succeeded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 16:40:10 +02:00

187 lines
6.5 KiB
TypeScript

import { PermissionKey, ProjectStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
// ─── Allowed status transitions ───────────────────────────────────────────────
const ALLOWED_TRANSITIONS: Record<ProjectStatus, ProjectStatus[]> = {
[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<TRPCContext, "db" | "dbUser"> & {
permissions: Set<PermissionKey>;
};
type ProjectLifecycleDependencies = {
invalidateDashboardCacheInBackground: () => void;
dispatchProjectWebhookInBackground: (
db: TRPCContext["db"],
event: string,
payload: Record<string, unknown>,
) => void;
};
async function deleteProjectCascade(
ctx: Pick<ProjectLifecycleContext, "db" | "dbUser">,
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<ProjectLifecycleContext, "db" | "dbUser">,
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 };
}),
};
}