import { CreateClientSchema, UpdateClientSchema } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, managerProcedure, planningReadProcedure, protectedProcedure, } from "../trpc.js"; import { assertClientCodeAvailable, assertClientDeletable, buildClientCreateData, buildClientListWhere, buildClientTree, buildClientUpdateData, findClientByIdentifier, } from "./client-support.js"; 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; _count: { projects: number; children: number; }; }; export const clientRouter = createTRPCRouter({ list: planningReadProcedure .input( z.object({ parentId: z.string().nullable().optional(), isActive: z.boolean().optional(), search: z.string().optional(), }).optional(), ) .query(async ({ ctx, input }) => { return ctx.db.client.findMany({ where: buildClientListWhere(input ?? {}), include: { _count: { select: { children: true, projects: true } } }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); }), getTree: planningReadProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { const all = await ctx.db.client.findMany({ where: { ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); return buildClientTree(all); }), getById: planningReadProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const client = await findUniqueOrThrow( ctx.db.client.findUnique({ where: { id: input.id }, include: { parent: true, children: { orderBy: { sortOrder: "asc" } }, _count: { select: { projects: true, children: true } }, }, }), "Client", ); return client; }), resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { return findClientByIdentifier(ctx.db, input.identifier, { select: { id: true, name: true, code: true, parentId: true, isActive: true, }, }); }), getByIdentifier: planningReadProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { return findClientByIdentifier(ctx.db, input.identifier, { include: { _count: { select: { projects: true, children: true } } }, }); }), create: managerProcedure .input(CreateClientSchema) .mutation(async ({ ctx, input }) => { if (input.parentId) { await findUniqueOrThrow( ctx.db.client.findUnique({ where: { id: input.parentId } }), "Parent client", ); } if (input.code) { await assertClientCodeAvailable(ctx.db, input.code); } const created = await ctx.db.client.create({ data: buildClientCreateData(input), }); void createAuditEntry({ db: ctx.db, entityType: "Client", entityId: created.id, entityName: created.name, action: "CREATE", userId: ctx.dbUser?.id, after: created as unknown as Record, source: "ui", }); return created; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateClientSchema })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.client.findUnique({ where: { id: input.id } }), "Client", ); if (input.data.code && input.data.code !== existing.code) { 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: buildClientUpdateData(input.data), }); void createAuditEntry({ db: ctx.db, entityType: "Client", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before, after: updated as unknown as Record, source: "ui", }); return updated; }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.client.update({ where: { id: input.id }, data: { isActive: false }, }); void createAuditEntry({ db: ctx.db, entityType: "Client", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before: { isActive: true }, after: { isActive: false }, source: "ui", summary: "Deactivated Client", }); return updated; }), delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const client = await findUniqueOrThrow( ctx.db.client.findUnique({ where: { id: input.id }, include: { _count: { select: { projects: true, children: true } } }, }), "Client", ); assertClientDeletable(client); await ctx.db.client.delete({ where: { id: input.id } }); void createAuditEntry({ db: ctx.db, entityType: "Client", entityId: client.id, entityName: client.name, action: "DELETE", userId: ctx.dbUser?.id, before: client as unknown as Record, source: "ui", }); return client; }), batchUpdateSortOrder: managerProcedure .input(z.array(z.object({ id: z.string(), sortOrder: z.number().int() }))) .mutation(async ({ ctx, input }) => { await ctx.db.$transaction( input.map((item) => ctx.db.client.update({ where: { id: item.id }, data: { sortOrder: item.sortOrder }, }), ), ); for (const item of input) { void createAuditEntry({ db: ctx.db, entityType: "Client", entityId: item.id, action: "UPDATE", userId: ctx.dbUser?.id, after: { sortOrder: item.sortOrder }, source: "ui", summary: "Updated sort order", }); } return { ok: true }; }), });