import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import type { ClientTree } from "@planarchy/shared"; interface FlatClient { id: string; name: string; code: string | null; parentId: string | null; isActive: boolean; 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), })); } export const clientRouter = createTRPCRouter({ list: protectedProcedure .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: { ...(input?.parentId !== undefined ? { parentId: input.parentId } : {}), ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), ...(input?.search ? { name: { contains: input.search, mode: "insensitive" as const } } : {}), }, include: { _count: { select: { children: true, projects: true } } }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); }), getTree: protectedProcedure .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: protectedProcedure .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; }), 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) { 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` }); } } return 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 } : {}), }, }); }), 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) { 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` }); } } return 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 } : {}), }, }); }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { return ctx.db.client.update({ where: { id: input.id }, data: { isActive: false }, }); }), 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", ); 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.`, }); } return ctx.db.client.delete({ where: { id: input.id } }); }), 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 }, }), ), ); return { ok: true }; }), });