diff --git a/packages/api/src/__tests__/blueprint-support.test.ts b/packages/api/src/__tests__/blueprint-support.test.ts new file mode 100644 index 0000000..8d4be85 --- /dev/null +++ b/packages/api/src/__tests__/blueprint-support.test.ts @@ -0,0 +1,108 @@ +import { BlueprintTarget, type BlueprintFieldDefinition, FieldType } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + buildBlueprintCreateData, + buildBlueprintRolePresetsUpdateData, + buildBlueprintUpdateData, + expandGlobalBlueprintFieldDefs, + findBlueprintByIdentifier, +} from "../router/blueprint-support.js"; + +describe("blueprint support", () => { + it("resolves blueprints by exact then fuzzy name", async () => { + const db = { + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "bp_1", name: "Consulting Blueprint" }), + }, + } as never; + + const result = await findBlueprintByIdentifier<{ id: string; name: string }>( + db, + " consulting ", + { select: { id: true, name: true } }, + ); + + expect(result).toEqual({ id: "bp_1", name: "Consulting Blueprint" }); + expect(db.blueprint.findFirst).toHaveBeenNthCalledWith(2, { + where: { name: { contains: "consulting", mode: "insensitive" } }, + select: { id: true, name: true }, + }); + }); + + it("throws when the blueprint cannot be resolved", async () => { + const db = { + blueprint: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + } as never; + + await expect(findBlueprintByIdentifier(db, "missing", { select: { id: true } })).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds create, update, and role preset payloads", () => { + expect(buildBlueprintCreateData({ + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + })).toEqual({ + name: "Consulting Blueprint", + target: BlueprintTarget.PROJECT, + description: "Default setup", + fieldDefs: [], + defaults: { market: "EU" }, + validationRules: [], + }); + + expect(buildBlueprintUpdateData({ + description: "Updated", + fieldDefs: [{ key: "market", type: FieldType.TEXT }], + })).toEqual({ + description: "Updated", + fieldDefs: [{ key: "market", type: FieldType.TEXT }], + }); + + expect(buildBlueprintRolePresetsUpdateData([{ roleId: "role_1" }])).toEqual({ + rolePresets: [{ roleId: "role_1" }], + }); + }); + + it("expands global field definitions with blueprint metadata", () => { + const fieldDefs: BlueprintFieldDefinition[] = [ + { + id: "field_market", + key: "market", + label: "Market", + order: 0, + type: FieldType.TEXT, + required: false, + }, + ]; + + expect(expandGlobalBlueprintFieldDefs([ + { + id: "bp_project_global", + name: "Global Project Blueprint", + fieldDefs, + }, + ])).toEqual([ + { + id: "field_market", + key: "market", + label: "Market", + order: 0, + type: FieldType.TEXT, + required: false, + blueprintId: "bp_project_global", + blueprintName: "Global Project Blueprint", + }, + ]); + }); +}); diff --git a/packages/api/src/router/blueprint-support.ts b/packages/api/src/router/blueprint-support.ts new file mode 100644 index 0000000..ed9f592 --- /dev/null +++ b/packages/api/src/router/blueprint-support.ts @@ -0,0 +1,111 @@ +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { + CreateBlueprintSchema, + UpdateBlueprintSchema, + type BlueprintFieldDefinition, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type BlueprintIdentifierDb = Pick; + +type BlueprintGlobalFieldDefinition = BlueprintFieldDefinition & { + blueprintId: string; + blueprintName: string; +}; + +type BlueprintFieldDefinitionSource = { + id: string; + name: string; + fieldDefs: unknown; +}; + +type CreateBlueprintInput = z.infer; +type UpdateBlueprintInput = z.infer; + +export async function findBlueprintByIdentifier( + db: BlueprintIdentifierDb, + identifier: string, + extraArgs: Record, +): Promise { + const normalizedIdentifier = identifier.trim(); + + let blueprint = await db.blueprint.findUnique({ + where: { id: normalizedIdentifier }, + ...extraArgs, + }) as TBlueprint | null; + + if (!blueprint) { + blueprint = await db.blueprint.findFirst({ + where: { name: { equals: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TBlueprint | null; + } + + if (!blueprint) { + blueprint = await db.blueprint.findFirst({ + where: { name: { contains: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TBlueprint | null; + } + + if (!blueprint) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Blueprint not found: ${normalizedIdentifier}`, + }); + } + + return blueprint; +} + +export function buildBlueprintCreateData( + input: CreateBlueprintInput, +): Prisma.BlueprintUncheckedCreateInput { + return { + name: input.name, + target: input.target, + description: input.description ?? null, + fieldDefs: input.fieldDefs as unknown as Prisma.InputJsonValue, + defaults: input.defaults as unknown as Prisma.InputJsonValue, + validationRules: input.validationRules as unknown as Prisma.InputJsonValue, + }; +} + +export function buildBlueprintUpdateData( + input: UpdateBlueprintInput, +): Prisma.BlueprintUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.fieldDefs !== undefined + ? { fieldDefs: input.fieldDefs as unknown as Prisma.InputJsonValue } + : {}), + ...(input.defaults !== undefined + ? { defaults: input.defaults as unknown as Prisma.InputJsonValue } + : {}), + ...(input.validationRules !== undefined + ? { validationRules: input.validationRules as unknown as Prisma.InputJsonValue } + : {}), + }; +} + +export function buildBlueprintRolePresetsUpdateData( + rolePresets: unknown[], +): Prisma.BlueprintUncheckedUpdateInput { + return { + rolePresets: rolePresets as unknown as Prisma.InputJsonValue, + }; +} + +export function expandGlobalBlueprintFieldDefs( + blueprints: BlueprintFieldDefinitionSource[], +): BlueprintGlobalFieldDefinition[] { + return blueprints.flatMap((blueprint) => + (blueprint.fieldDefs as BlueprintFieldDefinition[]).map((fieldDef) => ({ + ...fieldDef, + blueprintId: blueprint.id, + blueprintName: blueprint.name, + })), + ); +} diff --git a/packages/api/src/router/blueprint.ts b/packages/api/src/router/blueprint.ts index ce00c68..1f38119 100644 --- a/packages/api/src/router/blueprint.ts +++ b/packages/api/src/router/blueprint.ts @@ -1,9 +1,34 @@ -import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; +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 @@ -48,79 +73,27 @@ export const blueprintRouter = createTRPCRouter({ resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - const select = { - id: true, - name: true, - target: true, - isActive: true, - } as const; - - let blueprint = await ctx.db.blueprint.findUnique({ - where: { id: identifier }, - select, + return findBlueprintByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + target: true, + isActive: true, + }, }); - - if (!blueprint) { - blueprint = await ctx.db.blueprint.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!blueprint) { - blueprint = await ctx.db.blueprint.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!blueprint) { - throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` }); - } - - return blueprint; }), getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - let blueprint = await ctx.db.blueprint.findUnique({ - where: { id: identifier }, - }); - - if (!blueprint) { - blueprint = await ctx.db.blueprint.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - }); - } - - if (!blueprint) { - blueprint = await ctx.db.blueprint.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - }); - } - - if (!blueprint) { - throw new TRPCError({ code: "NOT_FOUND", message: `Blueprint not found: ${identifier}` }); - } - - return blueprint; + return findBlueprintByIdentifier(ctx.db, input.identifier, {}); }), create: adminProcedure .input(CreateBlueprintSchema) .mutation(async ({ ctx, input }) => { const blueprint = await ctx.db.blueprint.create({ - data: { - name: input.name, - target: input.target, - description: input.description, - fieldDefs: input.fieldDefs as unknown as import("@capakraken/db").Prisma.InputJsonValue, - defaults: input.defaults as unknown as import("@capakraken/db").Prisma.InputJsonValue, - validationRules: input.validationRules as unknown as import("@capakraken/db").Prisma.InputJsonValue, - } as unknown as Parameters[0]["data"], + data: buildBlueprintCreateData(input), }); void createAuditEntry({ @@ -147,13 +120,7 @@ export const blueprintRouter = createTRPCRouter({ const updated = await ctx.db.blueprint.update({ where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.description !== undefined ? { description: input.data.description } : {}), - ...(input.data.fieldDefs !== undefined ? { fieldDefs: input.data.fieldDefs as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.defaults !== undefined ? { defaults: input.data.defaults as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - ...(input.data.validationRules !== undefined ? { validationRules: input.data.validationRules as unknown as import("@capakraken/db").Prisma.InputJsonValue } : {}), - } as unknown as Parameters[0]["data"], + data: buildBlueprintUpdateData(input.data), }); void createAuditEntry({ @@ -181,7 +148,7 @@ export const blueprintRouter = createTRPCRouter({ ); const updated = await ctx.db.blueprint.update({ where: { id: input.id }, - data: { rolePresets: input.rolePresets as unknown as import("@capakraken/db").Prisma.InputJsonValue }, + data: buildBlueprintRolePresetsUpdateData(input.rolePresets), }); void createAuditEntry({ @@ -254,13 +221,7 @@ export const blueprintRouter = createTRPCRouter({ where: { target: input.target, isGlobal: true, isActive: true }, select: { id: true, name: true, fieldDefs: true }, }); - return blueprints.flatMap((b) => - (b.fieldDefs as unknown as BlueprintFieldDefinition[]).map((f) => ({ - ...f, - blueprintId: b.id, - blueprintName: b.name, - })), - ); + return expandGlobalBlueprintFieldDefs(blueprints); }), setGlobal: adminProcedure