import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, planningReadProcedure, protectedProcedure } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; import { buildBlueprintCreateData, buildBlueprintRolePresetsUpdateData, buildBlueprintUpdateData, expandGlobalBlueprintFieldDefs, findBlueprintByIdentifier, } from "./blueprint-support.js"; type BlueprintIdentifierReadModel = { id: string; name: string; target: BlueprintTarget; isActive: boolean; }; type BlueprintDetailReadModel = { id: string; name: string; target: BlueprintTarget; description: string | null; fieldDefs: unknown; defaults: unknown; validationRules: unknown; rolePresets: unknown; isActive: boolean; }; export const blueprintRouter = createTRPCRouter({ listSummaries: planningReadProcedure .query(async ({ ctx }) => { return ctx.db.blueprint.findMany({ select: { id: true, name: true, _count: { select: { projects: true } }, }, orderBy: { name: "asc" }, }); }), list: planningReadProcedure .input( z.object({ target: z.nativeEnum(BlueprintTarget).optional(), isActive: z.boolean().optional().default(true), }), ) .query(async ({ ctx, input }) => { return ctx.db.blueprint.findMany({ where: { ...(input.target ? { target: input.target } : {}), isActive: input.isActive, }, orderBy: { name: "asc" }, }); }), getById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const blueprint = await findUniqueOrThrow( ctx.db.blueprint.findUnique({ where: { id: input.id } }), "Blueprint", ); return blueprint; }), resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { return findBlueprintByIdentifier(ctx.db, input.identifier, { select: { id: true, name: true, target: true, isActive: true, }, }); }), getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { return findBlueprintByIdentifier(ctx.db, input.identifier, {}); }), create: adminProcedure .input(CreateBlueprintSchema) .mutation(async ({ ctx, input }) => { const blueprint = await ctx.db.blueprint.create({ data: buildBlueprintCreateData(input), }); void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: blueprint.id, entityName: blueprint.name, action: "CREATE", userId: ctx.dbUser?.id, after: { name: input.name, target: input.target, description: input.description }, source: "ui", }); return blueprint; }), update: adminProcedure .input(z.object({ id: z.string(), data: UpdateBlueprintSchema })) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.blueprint.findUnique({ where: { id: input.id } }), "Blueprint", ); const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: buildBlueprintUpdateData(input.data), }); void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: input.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before: before as unknown as Record, after: updated as unknown as Record, source: "ui", }); return updated; }), /** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */ updateRolePresets: adminProcedure .input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) })) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.blueprint.findUnique({ where: { id: input.id } }), "Blueprint", ); const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: buildBlueprintRolePresetsUpdateData(input.rolePresets), }); void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: input.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before: { rolePresets: before.rolePresets }, after: { rolePresets: input.rolePresets }, source: "ui", summary: "Updated role presets", }); return updated; }), delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Soft delete — mark as inactive const deleted = await ctx.db.blueprint.update({ where: { id: input.id }, data: { isActive: false }, }); void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: input.id, entityName: deleted.name, action: "DELETE", userId: ctx.dbUser?.id, source: "ui", }); return deleted; }), batchDelete: adminProcedure .input(z.object({ ids: z.array(z.string()).min(1).max(100) })) .mutation(async ({ ctx, input }) => { // Soft delete const updated = await ctx.db.$transaction( input.ids.map((id) => ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }), ), ); for (const bp of updated) { void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: bp.id, entityName: bp.name, action: "DELETE", userId: ctx.dbUser?.id, source: "ui", }); } return { count: updated.length }; }), getGlobalFieldDefs: planningReadProcedure .input(z.object({ target: z.nativeEnum(BlueprintTarget) })) .query(async ({ ctx, input }) => { const blueprints = await ctx.db.blueprint.findMany({ where: { target: input.target, isGlobal: true, isActive: true }, select: { id: true, name: true, fieldDefs: true }, }); return expandGlobalBlueprintFieldDefs(blueprints); }), setGlobal: adminProcedure .input(z.object({ id: z.string(), isGlobal: z.boolean() })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.blueprint.update({ where: { id: input.id }, data: { isGlobal: input.isGlobal }, }); void createAuditEntry({ db: ctx.db, entityType: "Blueprint", entityId: input.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, after: { isGlobal: input.isGlobal }, source: "ui", summary: input.isGlobal ? "Set blueprint as global" : "Removed global flag from blueprint", }); return updated; }), });