From a13e6bdca20b6baf8d5a9545cbca24e53c24ed40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 13:45:53 +0200 Subject: [PATCH] refactor(api): extract client router support --- .../api/src/__tests__/client-support.test.ts | 157 ++++++++++++++++ packages/api/src/router/client-support.ts | 165 +++++++++++++++++ packages/api/src/router/client.ts | 174 ++++-------------- 3 files changed, 355 insertions(+), 141 deletions(-) create mode 100644 packages/api/src/__tests__/client-support.test.ts create mode 100644 packages/api/src/router/client-support.ts diff --git a/packages/api/src/__tests__/client-support.test.ts b/packages/api/src/__tests__/client-support.test.ts new file mode 100644 index 0000000..720627e --- /dev/null +++ b/packages/api/src/__tests__/client-support.test.ts @@ -0,0 +1,157 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + assertClientCodeAvailable, + assertClientDeletable, + buildClientCreateData, + buildClientListWhere, + buildClientTree, + buildClientUpdateData, + findClientByIdentifier, +} from "../router/client-support.js"; + +describe("client support", () => { + it("builds client list filters", () => { + expect(buildClientListWhere({ + parentId: "parent_1", + isActive: true, + search: "Acme", + })).toEqual({ + parentId: "parent_1", + isActive: true, + OR: [ + { name: { contains: "Acme", mode: "insensitive" } }, + { code: { contains: "Acme", mode: "insensitive" } }, + ], + }); + }); + + it("builds a sorted nested client tree", () => { + expect(buildClientTree([ + { + id: "client_child_b", + name: "Beta", + code: "BET", + parentId: "client_root", + isActive: true, + sortOrder: 20, + tags: [], + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + { + id: "client_root", + name: "Root", + code: "ROOT", + parentId: null, + isActive: true, + sortOrder: 10, + tags: [], + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + { + id: "client_child_a", + name: "Alpha", + code: "ALP", + parentId: "client_root", + isActive: true, + sortOrder: 20, + tags: [], + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ])).toEqual([ + expect.objectContaining({ + id: "client_root", + children: [ + expect.objectContaining({ id: "client_child_a", children: [] }), + expect.objectContaining({ id: "client_child_b", children: [] }), + ], + }), + ]); + }); + + it("resolves a client by code before fuzzy fallback", async () => { + const db = { + client: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "client_1", code: "ACME" }), + findFirst: vi.fn(), + }, + } as never; + + const result = await findClientByIdentifier<{ id: string; code: string }>( + db, + " ACME ", + { select: { id: true, code: true } }, + ); + + expect(result).toEqual({ id: "client_1", code: "ACME" }); + expect(db.client.findUnique).toHaveBeenNthCalledWith(2, { + where: { code: "ACME" }, + select: { id: true, code: true }, + }); + }); + + it("throws when no client matches the identifier", async () => { + const db = { + client: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + } as never; + + await expect(findClientByIdentifier(db, "missing", { select: { id: true } })).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds create and sparse update payloads", () => { + expect(buildClientCreateData({ + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 10, + tags: ["enterprise"], + })).toEqual({ + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 10, + tags: ["enterprise"], + }); + + expect(buildClientUpdateData({ + code: null, + isActive: false, + parentId: null, + })).toEqual({ + code: null, + isActive: false, + parentId: null, + }); + }); + + it("rejects duplicate client codes outside the ignored id", async () => { + const db = { + client: { + findUnique: vi.fn().mockResolvedValue({ id: "client_existing", code: "ACME" }), + }, + } as never; + + await expect(assertClientCodeAvailable(db, "ACME")).rejects.toMatchObject({ + code: "CONFLICT", + message: 'Client code "ACME" already exists', + }); + }); + + it("rejects deletion when projects or children still exist", () => { + expect(() => assertClientDeletable({ + _count: { projects: 2, children: 0 }, + })).toThrow(TRPCError); + + expect(() => assertClientDeletable({ + _count: { projects: 0, children: 1 }, + })).toThrow(TRPCError); + }); +}); diff --git a/packages/api/src/router/client-support.ts b/packages/api/src/router/client-support.ts new file mode 100644 index 0000000..57ab9f2 --- /dev/null +++ b/packages/api/src/router/client-support.ts @@ -0,0 +1,165 @@ +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { CreateClientSchema, UpdateClientSchema, type ClientTree } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type ClientTreeNode = { + id: string; + name: string; + code: string | null; + parentId: string | null; + isActive: boolean; + sortOrder: number; + tags: string[]; + createdAt: Date; + updatedAt: Date; +}; + +type ClientIdentifierDb = Pick; + +type ClientListInput = { + parentId?: string | null | undefined; + isActive?: boolean | undefined; + search?: string | undefined; +}; + +type ClientDeleteRecord = { + _count: { + projects: number; + children: number; + }; +}; + +type CreateClientInput = z.infer; +type UpdateClientInput = z.infer; + +export function buildClientListWhere(input: ClientListInput): Prisma.ClientWhereInput { + return { + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input.search + ? { + OR: [ + { name: { contains: input.search, mode: "insensitive" } }, + { code: { contains: input.search, mode: "insensitive" } }, + ], + } + : {}), + }; +} + +export function buildClientTree( + flatItems: ClientTreeNode[], + parentId: string | null = null, +): ClientTree[] { + return flatItems + .filter((item) => item.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((item) => ({ + ...item, + children: buildClientTree(flatItems, item.id), + })); +} + +export async function findClientByIdentifier( + db: ClientIdentifierDb, + identifier: string, + extraArgs: Record, +): Promise { + const normalizedIdentifier = identifier.trim(); + + let client = await db.client.findUnique({ + where: { id: normalizedIdentifier }, + ...extraArgs, + }) as TClient | null; + + if (!client) { + client = await db.client.findUnique({ + where: { code: normalizedIdentifier }, + ...extraArgs, + }) as TClient | null; + } + + if (!client) { + client = await db.client.findFirst({ + where: { name: { equals: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TClient | null; + } + + if (!client) { + client = await db.client.findFirst({ + where: { + OR: [ + { name: { contains: normalizedIdentifier, mode: "insensitive" } }, + { code: { contains: normalizedIdentifier, mode: "insensitive" } }, + ], + }, + ...extraArgs, + }) as TClient | null; + } + + if (!client) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Client not found: ${normalizedIdentifier}`, + }); + } + + return client; +} + +export function buildClientCreateData( + input: CreateClientInput, +): Prisma.ClientUncheckedCreateInput { + return { + name: input.name, + ...(input.code ? { code: input.code } : {}), + ...(input.parentId ? { parentId: input.parentId } : {}), + sortOrder: input.sortOrder, + ...(input.tags ? { tags: input.tags } : {}), + }; +} + +export function buildClientUpdateData( + input: UpdateClientInput, +): Prisma.ClientUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.code !== undefined ? { code: input.code } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + ...(input.tags !== undefined ? { tags: input.tags } : {}), + }; +} + +export async function assertClientCodeAvailable( + db: ClientIdentifierDb, + code: string, + ignoreId?: string, +): Promise { + const existing = await db.client.findUnique({ where: { code } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ + code: "CONFLICT", + message: `Client code "${code}" already exists`, + }); + } +} + +export function assertClientDeletable(client: ClientDeleteRecord): void { + if (client._count.projects > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete client with ${client._count.projects} project(s). Deactivate instead.`, + }); + } + + if (client._count.children > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete client with ${client._count.children} child client(s). Remove children first.`, + }); + } +} diff --git a/packages/api/src/router/client.ts b/packages/api/src/router/client.ts index 8524572..2720bfc 100644 --- a/packages/api/src/router/client.ts +++ b/packages/api/src/router/client.ts @@ -1,5 +1,4 @@ import { CreateClientSchema, UpdateClientSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; @@ -10,30 +9,34 @@ import { planningReadProcedure, protectedProcedure, } from "../trpc.js"; +import { + assertClientCodeAvailable, + assertClientDeletable, + buildClientCreateData, + buildClientListWhere, + buildClientTree, + buildClientUpdateData, + findClientByIdentifier, +} from "./client-support.js"; -import type { ClientTree } from "@capakraken/shared"; - -interface FlatClient { +type ClientIdentifierReadModel = { id: string; name: string; code: string | null; parentId: string | null; isActive: boolean; +}; + +type ClientDetailReadModel = ClientIdentifierReadModel & { sortOrder: number; tags: string[]; createdAt: Date; updatedAt: Date; -} - -function buildClientTree(flatItems: FlatClient[], parentId: string | null = null): ClientTree[] { - return flatItems - .filter((item) => item.parentId === parentId) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((item) => ({ - ...item, - children: buildClientTree(flatItems, item.id), - })); -} + _count: { + projects: number; + children: number; + }; +}; export const clientRouter = createTRPCRouter({ list: planningReadProcedure @@ -46,18 +49,7 @@ export const clientRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { return ctx.db.client.findMany({ - where: { - ...(input?.parentId !== undefined ? { parentId: input.parentId } : {}), - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - ...(input?.search - ? { - OR: [ - { name: { contains: input.search, mode: "insensitive" as const } }, - { code: { contains: input.search, mode: "insensitive" as const } }, - ], - } - : {}), - }, + where: buildClientListWhere(input ?? {}), include: { _count: { select: { children: true, projects: true } } }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); @@ -95,93 +87,23 @@ export const clientRouter = 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, - code: true, - parentId: true, - isActive: true, - } as const; - - let client = await ctx.db.client.findUnique({ - where: { id: identifier }, - select, + return findClientByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + code: true, + parentId: true, + isActive: true, + }, }); - - if (!client) { - client = await ctx.db.client.findUnique({ - where: { code: identifier }, - select, - }); - } - - if (!client) { - client = await ctx.db.client.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!client) { - client = await ctx.db.client.findFirst({ - where: { - OR: [ - { name: { contains: identifier, mode: "insensitive" } }, - { code: { contains: identifier, mode: "insensitive" } }, - ], - }, - select, - }); - } - - if (!client) { - throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` }); - } - - return client; }), getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - let client = await ctx.db.client.findUnique({ - where: { id: identifier }, + return findClientByIdentifier(ctx.db, input.identifier, { include: { _count: { select: { projects: true, children: true } } }, }); - - if (!client) { - client = await ctx.db.client.findUnique({ - where: { code: identifier }, - include: { _count: { select: { projects: true, children: true } } }, - }); - } - - if (!client) { - client = await ctx.db.client.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - include: { _count: { select: { projects: true, children: true } } }, - }); - } - - if (!client) { - client = await ctx.db.client.findFirst({ - where: { - OR: [ - { name: { contains: identifier, mode: "insensitive" } }, - { code: { contains: identifier, mode: "insensitive" } }, - ], - }, - include: { _count: { select: { projects: true, children: true } } }, - }); - } - - if (!client) { - throw new TRPCError({ code: "NOT_FOUND", message: `Client not found: ${identifier}` }); - } - - return client; }), create: managerProcedure @@ -195,20 +117,11 @@ export const clientRouter = createTRPCRouter({ } if (input.code) { - const codeConflict = await ctx.db.client.findUnique({ where: { code: input.code } }); - if (codeConflict) { - throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.code}" already exists` }); - } + await assertClientCodeAvailable(ctx.db, input.code); } const created = await ctx.db.client.create({ - data: { - name: input.name, - ...(input.code ? { code: input.code } : {}), - ...(input.parentId ? { parentId: input.parentId } : {}), - sortOrder: input.sortOrder, - ...(input.tags ? { tags: input.tags } : {}), - }, + data: buildClientCreateData(input), }); void createAuditEntry({ @@ -234,24 +147,14 @@ export const clientRouter = createTRPCRouter({ ); if (input.data.code && input.data.code !== existing.code) { - const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } }); - if (conflict) { - throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.data.code}" already exists` }); - } + await assertClientCodeAvailable(ctx.db, input.data.code, existing.id); } const before = existing as unknown as Record; const updated = await ctx.db.client.update({ where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.code !== undefined ? { code: input.data.code } : {}), - ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - ...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}), - ...(input.data.tags !== undefined ? { tags: input.data.tags } : {}), - }, + data: buildClientUpdateData(input.data), }); void createAuditEntry({ @@ -303,18 +206,7 @@ export const clientRouter = createTRPCRouter({ }), "Client", ); - if (client._count.projects > 0) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Cannot delete client with ${client._count.projects} project(s). Deactivate instead.`, - }); - } - if (client._count.children > 0) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Cannot delete client with ${client._count.children} child client(s). Remove children first.`, - }); - } + assertClientDeletable(client); await ctx.db.client.delete({ where: { id: input.id } }); void createAuditEntry({