From e2ba131926ed57007863e892ba1beeecab4f1f84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:15:19 +0200 Subject: [PATCH] refactor(api): extract blueprint procedures --- .../blueprint-procedure-support.test.ts | 189 ++++++++++++ .../src/__tests__/blueprint-router.test.ts | 277 +++++++++++++++++ .../src/router/blueprint-procedure-support.ts | 282 ++++++++++++++++++ packages/api/src/router/blueprint.ts | 271 ++++------------- 4 files changed, 798 insertions(+), 221 deletions(-) create mode 100644 packages/api/src/__tests__/blueprint-procedure-support.test.ts create mode 100644 packages/api/src/__tests__/blueprint-router.test.ts create mode 100644 packages/api/src/router/blueprint-procedure-support.ts diff --git a/packages/api/src/__tests__/blueprint-procedure-support.test.ts b/packages/api/src/__tests__/blueprint-procedure-support.test.ts new file mode 100644 index 0000000..97d69bf --- /dev/null +++ b/packages/api/src/__tests__/blueprint-procedure-support.test.ts @@ -0,0 +1,189 @@ +import { BlueprintTarget, FieldType } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + batchDeleteBlueprints, + createBlueprint, + getGlobalBlueprintFieldDefs, + updateBlueprintRolePresets, +} from "../router/blueprint-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" } as never, + }; +} + +describe("blueprint procedure support", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("creates a blueprint and audits it", async () => { + const create = vi.fn().mockResolvedValue({ + id: "bp_1", + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + }); + + const result = await createBlueprint( + createContext({ + blueprint: { create }, + }), + { + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + }, + ); + + expect(create).toHaveBeenCalledWith({ + data: { + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + }, + }); + expect(result).toEqual({ + id: "bp_1", + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + }); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Blueprint", + action: "CREATE", + entityId: "bp_1", + userId: "user_admin", + }), + ); + }); + + it("updates only role presets with the dedicated audit summary", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "bp_1", + name: "Consulting Blueprint", + rolePresets: [], + }); + const update = vi.fn().mockResolvedValue({ + id: "bp_1", + name: "Consulting Blueprint", + rolePresets: [{ roleId: "role_1", allocation: 0.5 }], + }); + + const rolePresets = [{ roleId: "role_1", allocation: 0.5 }]; + const result = await updateBlueprintRolePresets( + createContext({ + blueprint: { findUnique, update }, + }), + { + id: "bp_1", + rolePresets, + }, + ); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + data: { rolePresets }, + }); + expect(result.rolePresets).toEqual(rolePresets); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Blueprint", + summary: "Updated role presets", + before: { rolePresets: [] }, + after: { rolePresets }, + }), + ); + }); + + it("soft deletes multiple blueprints inside one transaction and audits each record", async () => { + const update = vi + .fn() + .mockResolvedValueOnce({ id: "bp_1", name: "Consulting Blueprint", isActive: false }) + .mockResolvedValueOnce({ id: "bp_2", name: "Animation Blueprint", isActive: false }); + const $transaction = vi + .fn() + .mockImplementation(async (operations: Promise[]) => Promise.all(operations)); + + const result = await batchDeleteBlueprints( + createContext({ + $transaction, + blueprint: { update }, + }), + { + ids: ["bp_1", "bp_2"], + }, + ); + + expect($transaction).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenNthCalledWith(1, { + where: { id: "bp_1" }, + data: { isActive: false }, + }); + expect(update).toHaveBeenNthCalledWith(2, { + where: { id: "bp_2" }, + data: { isActive: false }, + }); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + expect(result).toEqual({ count: 2 }); + }); + + it("expands global field definitions from active global blueprints", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "bp_global", + name: "Global Project Blueprint", + fieldDefs: [ + { + id: "field_market", + key: "market", + label: "Market", + order: 0, + type: FieldType.TEXT, + required: false, + }, + ], + }, + ]); + + const result = await getGlobalBlueprintFieldDefs( + createContext({ + blueprint: { findMany }, + }), + { + target: BlueprintTarget.PROJECT, + }, + ); + + expect(findMany).toHaveBeenCalledWith({ + where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true }, + select: { id: true, name: true, fieldDefs: true }, + }); + expect(result).toEqual([ + expect.objectContaining({ + blueprintId: "bp_global", + blueprintName: "Global Project Blueprint", + key: "market", + }), + ]); + }); +}); diff --git a/packages/api/src/__tests__/blueprint-router.test.ts b/packages/api/src/__tests__/blueprint-router.test.ts new file mode 100644 index 0000000..b684a84 --- /dev/null +++ b/packages/api/src/__tests__/blueprint-router.test.ts @@ -0,0 +1,277 @@ +import { BlueprintTarget, FieldType, SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; +import { blueprintRouter } from "../router/blueprint.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(blueprintRouter); + +function createPlanningCaller(db: Record) { + return createCaller({ + session: { + user: { email: "planning@example.com", name: "Planning", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_planning", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + permissions: new Set(["view:planning"]), + }); +} + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + }); +} + +function sampleBlueprint(overrides: Record = {}) { + return { + id: "bp_1", + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: {}, + validationRules: [], + rolePresets: [], + isActive: true, + isGlobal: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe("blueprint router", () => { + it("lists active blueprints with the expected filters", async () => { + const findMany = vi.fn().mockResolvedValue([sampleBlueprint()]); + + const caller = createPlanningCaller({ + blueprint: { findMany }, + }); + const result = await caller.list({ target: BlueprintTarget.PROJECT }); + + expect(findMany).toHaveBeenCalledWith({ + where: { + target: BlueprintTarget.PROJECT, + isActive: true, + }, + orderBy: { name: "asc" }, + }); + expect(result).toHaveLength(1); + }); + + it("resolves a blueprint by identifier through the protected router query", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const findFirst = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: "bp_1", + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + isActive: true, + }); + + const caller = createPlanningCaller({ + blueprint: { findUnique, findFirst }, + }); + const result = await caller.resolveByIdentifier({ identifier: " consulting " }); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "consulting" }, + select: { + id: true, + name: true, + target: true, + isActive: true, + }, + }); + expect(findFirst).toHaveBeenNthCalledWith(2, { + where: { name: { contains: "consulting", mode: "insensitive" } }, + select: { + id: true, + name: true, + target: true, + isActive: true, + }, + }); + expect(result.name).toBe("Consulting Blueprint"); + }); + + it("creates a blueprint and writes an audit entry", async () => { + const create = vi.fn().mockResolvedValue(sampleBlueprint()); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { create }, + auditLog: { create: auditCreate }, + }); + const result = await caller.create({ + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + }); + + expect(create).toHaveBeenCalledWith({ + data: { + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + }, + }); + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(result.id).toBe("bp_1"); + }); + + it("updates a blueprint through the router and preserves the before snapshot", async () => { + const update = vi.fn().mockResolvedValue( + sampleBlueprint({ + name: "Updated Blueprint", + description: "Updated setup", + }), + ); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { + findUnique: vi.fn().mockResolvedValue(sampleBlueprint()), + update, + }, + auditLog: { create: auditCreate }, + }); + const result = await caller.update({ + id: "bp_1", + data: { + name: "Updated Blueprint", + description: "Updated setup", + }, + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + data: { + name: "Updated Blueprint", + description: "Updated setup", + }, + }); + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(result.name).toBe("Updated Blueprint"); + }); + + it("updates role presets with the dedicated mutation payload", async () => { + const update = vi.fn().mockResolvedValue( + sampleBlueprint({ + rolePresets: [{ roleId: "role_1" }], + }), + ); + const auditCreate = vi.fn().mockResolvedValue({}); + + const caller = createAdminCaller({ + blueprint: { + findUnique: vi.fn().mockResolvedValue(sampleBlueprint({ rolePresets: [] })), + update, + }, + auditLog: { create: auditCreate }, + }); + const rolePresets = [{ roleId: "role_1", allocation: 0.5 }]; + const result = await caller.updateRolePresets({ + id: "bp_1", + rolePresets, + }); + + expect(update).toHaveBeenCalledWith({ + where: { id: "bp_1" }, + data: { rolePresets }, + }); + expect(auditCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + summary: "Updated role presets", + }), + }), + ); + expect(result.rolePresets).toEqual([{ roleId: "role_1" }]); + }); + + it("soft deletes blueprints in batch and audits each deleted blueprint", async () => { + const update = vi + .fn() + .mockResolvedValueOnce(sampleBlueprint({ id: "bp_1" })) + .mockResolvedValueOnce(sampleBlueprint({ id: "bp_2", name: "Animation Blueprint" })); + const auditCreate = vi.fn().mockResolvedValue({}); + const transaction = vi + .fn() + .mockImplementation(async (operations: Promise[]) => Promise.all(operations)); + + const caller = createAdminCaller({ + blueprint: { update }, + auditLog: { create: auditCreate }, + $transaction: transaction, + }); + const result = await caller.batchDelete({ ids: ["bp_1", "bp_2"] }); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenNthCalledWith(1, { + where: { id: "bp_1" }, + data: { isActive: false }, + }); + expect(update).toHaveBeenNthCalledWith(2, { + where: { id: "bp_2" }, + data: { isActive: false }, + }); + expect(auditCreate).toHaveBeenCalledTimes(2); + expect(result).toEqual({ count: 2 }); + }); + + it("expands global field definitions with blueprint metadata", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "bp_global", + name: "Global Project Blueprint", + fieldDefs: [ + { + id: "field_market", + key: "market", + label: "Market", + order: 0, + type: FieldType.TEXT, + required: false, + }, + ], + }, + ]); + + const caller = createPlanningCaller({ + blueprint: { findMany }, + }); + const result = await caller.getGlobalFieldDefs({ target: BlueprintTarget.PROJECT }); + + expect(findMany).toHaveBeenCalledWith({ + where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true }, + select: { id: true, name: true, fieldDefs: true }, + }); + expect(result).toEqual([ + expect.objectContaining({ + blueprintId: "bp_global", + blueprintName: "Global Project Blueprint", + key: "market", + }), + ]); + }); +}); diff --git a/packages/api/src/router/blueprint-procedure-support.ts b/packages/api/src/router/blueprint-procedure-support.ts new file mode 100644 index 0000000..9ccf642 --- /dev/null +++ b/packages/api/src/router/blueprint-procedure-support.ts @@ -0,0 +1,282 @@ +import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema } from "@capakraken/shared"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + buildBlueprintCreateData, + buildBlueprintRolePresetsUpdateData, + buildBlueprintUpdateData, + expandGlobalBlueprintFieldDefs, + findBlueprintByIdentifier, +} from "./blueprint-support.js"; + +type BlueprintProcedureContext = Pick; + +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; +}; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +async function getBlueprintOrThrow(ctx: BlueprintProcedureContext, id: string) { + return findUniqueOrThrow(ctx.db.blueprint.findUnique({ where: { id } }), "Blueprint"); +} + +export const blueprintIdInputSchema = z.object({ id: z.string() }); + +export const blueprintIdentifierInputSchema = z.object({ + identifier: z.string().trim().min(1), +}); + +export const blueprintListInputSchema = z.object({ + target: z.nativeEnum(BlueprintTarget).optional(), + isActive: z.boolean().optional().default(true), +}); + +export const blueprintUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateBlueprintSchema, +}); + +export const blueprintRolePresetsInputSchema = z.object({ + id: z.string(), + rolePresets: z.array(z.unknown()), +}); + +export const blueprintBatchDeleteInputSchema = z.object({ + ids: z.array(z.string()).min(1).max(100), +}); + +export const blueprintGlobalFieldDefsInputSchema = z.object({ + target: z.nativeEnum(BlueprintTarget), +}); + +export const blueprintSetGlobalInputSchema = z.object({ + id: z.string(), + isGlobal: z.boolean(), +}); + +type BlueprintIdInput = z.infer; +type BlueprintIdentifierInput = z.infer; +type BlueprintListInput = z.infer; +type BlueprintCreateInput = z.infer; +type BlueprintUpdateInput = z.infer; +type BlueprintRolePresetsInput = z.infer; +type BlueprintBatchDeleteInput = z.infer; +type BlueprintGlobalFieldDefsInput = z.infer; +type BlueprintSetGlobalInput = z.infer; + +export async function listBlueprintSummaries(ctx: BlueprintProcedureContext) { + return ctx.db.blueprint.findMany({ + select: { + id: true, + name: true, + _count: { select: { projects: true } }, + }, + orderBy: { name: "asc" }, + }); +} + +export async function listBlueprints(ctx: BlueprintProcedureContext, input: BlueprintListInput) { + return ctx.db.blueprint.findMany({ + where: { + ...(input.target ? { target: input.target } : {}), + isActive: input.isActive, + }, + orderBy: { name: "asc" }, + }); +} + +export async function getBlueprintById(ctx: BlueprintProcedureContext, input: BlueprintIdInput) { + return getBlueprintOrThrow(ctx, input.id); +} + +export async function resolveBlueprintByIdentifier( + ctx: BlueprintProcedureContext, + input: BlueprintIdentifierInput, +) { + return findBlueprintByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + target: true, + isActive: true, + }, + }); +} + +export async function getBlueprintDetailByIdentifier( + ctx: BlueprintProcedureContext, + input: BlueprintIdentifierInput, +) { + return findBlueprintByIdentifier(ctx.db, input.identifier, {}); +} + +export async function createBlueprint(ctx: BlueprintProcedureContext, input: BlueprintCreateInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + after: { name: input.name, target: input.target, description: input.description }, + source: "ui", + }); + + return blueprint; +} + +export async function updateBlueprint(ctx: BlueprintProcedureContext, input: BlueprintUpdateInput) { + const before = await getBlueprintOrThrow(ctx, input.id); + + 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", + ...withAuditUser(ctx.dbUser?.id), + before: before as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function updateBlueprintRolePresets( + ctx: BlueprintProcedureContext, + input: BlueprintRolePresetsInput, +) { + const before = await getBlueprintOrThrow(ctx, input.id); + 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", + ...withAuditUser(ctx.dbUser?.id), + before: { rolePresets: before.rolePresets }, + after: { rolePresets: input.rolePresets }, + source: "ui", + summary: "Updated role presets", + }); + + return updated; +} + +export async function deleteBlueprint(ctx: BlueprintProcedureContext, input: BlueprintIdInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + source: "ui", + }); + + return deleted; +} + +export async function batchDeleteBlueprints( + ctx: BlueprintProcedureContext, + input: BlueprintBatchDeleteInput, +) { + const updated = await ctx.db.$transaction( + input.ids.map((id) => + ctx.db.blueprint.update({ + where: { id }, + data: { isActive: false }, + }), + ), + ); + + for (const blueprint of updated) { + void createAuditEntry({ + db: ctx.db, + entityType: "Blueprint", + entityId: blueprint.id, + entityName: blueprint.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + source: "ui", + }); + } + + return { count: updated.length }; +} + +export async function getGlobalBlueprintFieldDefs( + ctx: BlueprintProcedureContext, + input: BlueprintGlobalFieldDefsInput, +) { + 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); +} + +export async function setBlueprintGlobal( + ctx: BlueprintProcedureContext, + input: BlueprintSetGlobalInput, +) { + 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", + ...withAuditUser(ctx.dbUser?.id), + after: { isGlobal: input.isGlobal }, + source: "ui", + summary: input.isGlobal ? "Set blueprint as global" : "Removed global flag from blueprint", + }); + + return updated; +} diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index 1f38119..b93c763 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -1,249 +1,78 @@ -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; -}; + adminProcedure, + createTRPCRouter, + planningReadProcedure, + protectedProcedure, +} from "../trpc.js"; +import { + batchDeleteBlueprints, + blueprintBatchDeleteInputSchema, + blueprintGlobalFieldDefsInputSchema, + blueprintIdentifierInputSchema, + blueprintIdInputSchema, + blueprintListInputSchema, + blueprintRolePresetsInputSchema, + blueprintSetGlobalInputSchema, + blueprintUpdateInputSchema, + createBlueprint, + deleteBlueprint, + getBlueprintById, + getBlueprintDetailByIdentifier, + getGlobalBlueprintFieldDefs, + listBlueprintSummaries, + listBlueprints, + resolveBlueprintByIdentifier, + setBlueprintGlobal, + updateBlueprint, + updateBlueprintRolePresets, +} from "./blueprint-procedure-support.js"; +import { CreateBlueprintSchema } from "@capakraken/shared"; 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" }, - }); - }), + listSummaries: planningReadProcedure.query(({ ctx }) => listBlueprintSummaries(ctx)), 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" }, - }); - }), + .input(blueprintListInputSchema) + .query(({ ctx, input }) => listBlueprints(ctx, input)), 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; - }), + .input(blueprintIdInputSchema) + .query(({ ctx, input }) => getBlueprintById(ctx, input)), 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, - }, - }); - }), + .input(blueprintIdentifierInputSchema) + .query(({ ctx, input }) => resolveBlueprintByIdentifier(ctx, input)), getByIdentifier: planningReadProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - return findBlueprintByIdentifier(ctx.db, input.identifier, {}); - }), + .input(blueprintIdentifierInputSchema) + .query(({ ctx, input }) => getBlueprintDetailByIdentifier(ctx, input)), 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; - }), + .mutation(({ ctx, input }) => createBlueprint(ctx, input)), 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; - }), + .input(blueprintUpdateInputSchema) + .mutation(({ ctx, input }) => updateBlueprint(ctx, input)), /** 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; - }), + .input(blueprintRolePresetsInputSchema) + .mutation(({ ctx, input }) => updateBlueprintRolePresets(ctx, input)), 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; - }), + .input(blueprintIdInputSchema) + .mutation(({ ctx, input }) => deleteBlueprint(ctx, input)), 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 }; - }), + .input(blueprintBatchDeleteInputSchema) + .mutation(({ ctx, input }) => batchDeleteBlueprints(ctx, input)), 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); - }), + .input(blueprintGlobalFieldDefsInputSchema) + .query(({ ctx, input }) => getGlobalBlueprintFieldDefs(ctx, input)), 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; - }), + .input(blueprintSetGlobalInputSchema) + .mutation(({ ctx, input }) => setBlueprintGlobal(ctx, input)), });