refactor(api): extract project lifecycle procedures
This commit is contained in:
@@ -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<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 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 };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,8 +11,9 @@ import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
|||||||
import { projectCostReadProcedures } from "./project-cost-read.js";
|
import { projectCostReadProcedures } from "./project-cost-read.js";
|
||||||
import { projectCoverProcedures } from "./project-cover.js";
|
import { projectCoverProcedures } from "./project-cover.js";
|
||||||
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
import { projectIdentifierReadProcedures } from "./project-identifier-read.js";
|
||||||
|
import { createProjectLifecycleProcedures } from "./project-lifecycle.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.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 { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
import { logger } from "../lib/logger.js";
|
import { logger } from "../lib/logger.js";
|
||||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
@@ -57,6 +58,10 @@ export const projectRouter = createTRPCRouter({
|
|||||||
...projectCostReadProcedures,
|
...projectCostReadProcedures,
|
||||||
...projectCoverProcedures,
|
...projectCoverProcedures,
|
||||||
...projectIdentifierReadProcedures,
|
...projectIdentifierReadProcedures,
|
||||||
|
...createProjectLifecycleProcedures({
|
||||||
|
invalidateDashboardCacheInBackground,
|
||||||
|
dispatchProjectWebhookInBackground,
|
||||||
|
}),
|
||||||
|
|
||||||
list: controllerProcedure
|
list: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -338,128 +343,4 @@ export const projectRouter = createTRPCRouter({
|
|||||||
return updated;
|
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 };
|
|
||||||
}),
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user