diff --git a/packages/api/src/__tests__/client-procedure-support.test.ts b/packages/api/src/__tests__/client-procedure-support.test.ts new file mode 100644 index 0000000..88ef54e --- /dev/null +++ b/packages/api/src/__tests__/client-procedure-support.test.ts @@ -0,0 +1,318 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + batchUpdateClientSortOrder, + createClient, + deleteClient, + updateClient, +} from "../router/client-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_1" } as never, + }; +} + +describe("client procedure support", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("creates a client after validating parent and code uniqueness", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce({ id: "parent_1", name: "Parent" }) + .mockResolvedValueOnce(null); + const create = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 12, + tags: ["enterprise"], + isActive: true, + }); + + const result = await createClient( + createContext({ + client: { + findUnique, + create, + }, + }), + { + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 12, + tags: ["enterprise"], + }, + ); + + expect(findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "parent_1" }, + }); + expect(findUnique).toHaveBeenNthCalledWith(2, { + where: { code: "ACME" }, + }); + expect(create).toHaveBeenCalledWith({ + data: { + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 12, + tags: ["enterprise"], + }, + }); + expect(result.id).toBe("client_1"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Client", + action: "CREATE", + entityId: "client_1", + userId: "user_1", + }), + ); + }); + + it("updates a client without rechecking an unchanged code", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME", + isActive: true, + }); + const update = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme Updated", + code: "ACME", + isActive: true, + }); + + const result = await updateClient( + createContext({ + client: { + findUnique, + update, + }, + }), + { + id: "client_1", + data: { + name: "Acme Updated", + code: "ACME", + }, + }, + ); + + expect(findUnique).toHaveBeenCalledTimes(1); + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "client_1" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "client_1" }, + data: { + name: "Acme Updated", + code: "ACME", + }, + }); + expect(result.name).toBe("Acme Updated"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Client", + action: "UPDATE", + entityId: "client_1", + before: expect.objectContaining({ code: "ACME" }), + after: expect.objectContaining({ name: "Acme Updated" }), + }), + ); + }); + + it("checks code availability when the code changes", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce({ + id: "client_1", + name: "Acme", + code: "ACME", + isActive: true, + }) + .mockResolvedValueOnce(null); + const update = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME-2", + isActive: true, + }); + + await updateClient( + createContext({ + client: { + findUnique, + update, + }, + }), + { + id: "client_1", + data: { + code: "ACME-2", + }, + }, + ); + + expect(findUnique).toHaveBeenNthCalledWith(2, { + where: { code: "ACME-2" }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "client_1" }, + data: { code: "ACME-2" }, + }); + }); + + it("rejects deletion while projects or child clients still exist", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + _count: { projects: 1, children: 0 }, + }); + const remove = vi.fn(); + + await expect( + deleteClient( + createContext({ + client: { + findUnique, + delete: remove, + }, + }), + { + id: "client_1", + }, + ), + ).rejects.toMatchObject({ + code: "PRECONDITION_FAILED", + message: "Cannot delete client with 1 project(s). Deactivate instead.", + }); + + expect(remove).not.toHaveBeenCalled(); + expect(createAuditEntry).not.toHaveBeenCalled(); + }); + + it("deletes a valid client and audits the deletion", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + _count: { projects: 0, children: 0 }, + }); + const remove = vi.fn().mockResolvedValue({ id: "client_1" }); + + const result = await deleteClient( + createContext({ + client: { + findUnique, + delete: remove, + }, + }), + { + id: "client_1", + }, + ); + + expect(remove).toHaveBeenCalledWith({ + where: { id: "client_1" }, + }); + expect(result).toEqual({ + id: "client_1", + name: "Acme", + _count: { projects: 0, children: 0 }, + }); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "Client", + action: "DELETE", + entityId: "client_1", + before: expect.objectContaining({ name: "Acme" }), + }), + ); + }); + + it("batch-updates sort order and writes one audit per item", async () => { + const update = vi.fn( + ({ where, data }: { where: { id: string }; data: { sortOrder: number } }) => + Promise.resolve({ id: where.id, sortOrder: data.sortOrder }), + ); + const $transaction = vi.fn().mockResolvedValue([]); + + const result = await batchUpdateClientSortOrder( + createContext({ + $transaction, + client: { + update, + }, + }), + [ + { id: "client_1", sortOrder: 10 }, + { id: "client_2", sortOrder: 20 }, + ], + ); + + expect(update).toHaveBeenNthCalledWith(1, { + where: { id: "client_1" }, + data: { sortOrder: 10 }, + }); + expect(update).toHaveBeenNthCalledWith(2, { + where: { id: "client_2" }, + data: { sortOrder: 20 }, + }); + expect($transaction).toHaveBeenCalledTimes(1); + expect($transaction.mock.calls[0]?.[0]).toHaveLength(2); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + expect(createAuditEntry).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + entityId: "client_1", + summary: "Updated sort order", + }), + ); + expect(createAuditEntry).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + entityId: "client_2", + summary: "Updated sort order", + }), + ); + expect(result).toEqual({ ok: true }); + }); + + it("surfaces precondition failures as TRPC errors", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + _count: { projects: 0, children: 2 }, + }); + + await expect( + deleteClient( + createContext({ + client: { + findUnique, + delete: vi.fn(), + }, + }), + { + id: "client_1", + }, + ), + ).rejects.toBeInstanceOf(TRPCError); + }); +}); diff --git a/packages/api/src/__tests__/client-router.test.ts b/packages/api/src/__tests__/client-router.test.ts new file mode 100644 index 0000000..a70d90f --- /dev/null +++ b/packages/api/src/__tests__/client-router.test.ts @@ -0,0 +1,282 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { clientRouter } from "../router/client.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(clientRouter); + +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 createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }); +} + +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, + }, + }); +} + +describe("client router", () => { + it("lists clients with filters and count includes", async () => { + const findMany = vi.fn().mockResolvedValue([{ id: "client_1", name: "Acme" }]); + + const caller = createPlanningCaller({ + client: { findMany }, + }); + const result = await caller.list({ parentId: "parent_1", isActive: true, search: "Acme" }); + + expect(findMany).toHaveBeenCalledWith({ + where: { + parentId: "parent_1", + isActive: true, + OR: [ + { name: { contains: "Acme", mode: "insensitive" } }, + { code: { contains: "Acme", mode: "insensitive" } }, + ], + }, + include: { _count: { select: { children: true, projects: true } } }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + expect(result).toHaveLength(1); + }); + + it("returns a nested tree from ordered flat records", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + 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", + name: "Child", + code: "CHILD", + 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"), + }, + ]); + + const caller = createPlanningCaller({ + client: { findMany }, + }); + const result = await caller.getTree({ isActive: true }); + + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + expect(result).toEqual([ + expect.objectContaining({ + id: "client_root", + children: [expect.objectContaining({ id: "client_child" })], + }), + ]); + }); + + it("resolves a client by identifier via the protected query", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "client_1", + code: "ACME", + name: "Acme", + parentId: null, + isActive: true, + }); + + const caller = createPlanningCaller({ + client: { + findUnique, + findFirst: vi.fn(), + }, + }); + const result = await caller.resolveByIdentifier({ identifier: " ACME " }); + + expect(findUnique).toHaveBeenNthCalledWith(2, { + where: { code: "ACME" }, + select: { + id: true, + name: true, + code: true, + parentId: true, + isActive: true, + }, + }); + expect(result).toEqual({ + id: "client_1", + code: "ACME", + name: "Acme", + parentId: null, + isActive: true, + }); + }); + + it("creates and updates a client through the router", async () => { + const findUnique = vi + .fn() + .mockResolvedValueOnce({ id: "parent_1", name: "Parent" }) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "client_1", + name: "Acme", + code: "ACME", + isActive: true, + }); + const create = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 10, + tags: ["enterprise"], + isActive: true, + }); + const update = vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme Updated", + code: "ACME", + isActive: true, + }); + + const caller = createManagerCaller({ + client: { findUnique, create, update }, + }); + + const created = await caller.create({ + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 10, + tags: ["enterprise"], + }); + const updated = await caller.update({ + id: "client_1", + data: { name: "Acme Updated", code: "ACME" }, + }); + + expect(create).toHaveBeenCalledWith({ + data: { + name: "Acme", + code: "ACME", + parentId: "parent_1", + sortOrder: 10, + tags: ["enterprise"], + }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "client_1" }, + data: { name: "Acme Updated", code: "ACME" }, + }); + expect(created.id).toBe("client_1"); + expect(updated.name).toBe("Acme Updated"); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + }); + + it("deletes a deletable client through the admin router", async () => { + const remove = vi.fn().mockResolvedValue({ id: "client_1" }); + + const caller = createAdminCaller({ + client: { + findUnique: vi.fn().mockResolvedValue({ + id: "client_1", + name: "Acme", + _count: { projects: 0, children: 0 }, + }), + delete: remove, + }, + }); + const result = await caller.delete({ id: "client_1" }); + + expect(remove).toHaveBeenCalledWith({ + where: { id: "client_1" }, + }); + expect(result).toEqual({ + id: "client_1", + name: "Acme", + _count: { projects: 0, children: 0 }, + }); + }); + + it("batch-updates sort order through the manager router", async () => { + const update = vi.fn( + ({ where, data }: { where: { id: string }; data: { sortOrder: number } }) => + Promise.resolve({ id: where.id, sortOrder: data.sortOrder }), + ); + const $transaction = vi.fn().mockResolvedValue([]); + + const caller = createManagerCaller({ + $transaction, + client: { update }, + }); + const result = await caller.batchUpdateSortOrder([ + { id: "client_1", sortOrder: 10 }, + { id: "client_2", sortOrder: 20 }, + ]); + + expect($transaction).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenNthCalledWith(1, { + where: { id: "client_1" }, + data: { sortOrder: 10 }, + }); + expect(update).toHaveBeenNthCalledWith(2, { + where: { id: "client_2" }, + data: { sortOrder: 20 }, + }); + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/packages/api/src/router/client-procedure-support.ts b/packages/api/src/router/client-procedure-support.ts new file mode 100644 index 0000000..a354612 --- /dev/null +++ b/packages/api/src/router/client-procedure-support.ts @@ -0,0 +1,274 @@ +import { CreateClientSchema, UpdateClientSchema } 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 { + assertClientCodeAvailable, + assertClientDeletable, + buildClientCreateData, + buildClientListWhere, + buildClientTree, + buildClientUpdateData, + findClientByIdentifier, +} from "./client-support.js"; + +type ClientProcedureContext = Pick; + +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; + }; +}; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const clientIdInputSchema = z.object({ id: z.string() }); + +export const clientIdentifierInputSchema = z.object({ + identifier: z.string().trim().min(1), +}); + +export const clientListInputSchema = z + .object({ + parentId: z.string().nullable().optional(), + isActive: z.boolean().optional(), + search: z.string().optional(), + }) + .optional(); + +export const clientTreeInputSchema = z + .object({ + isActive: z.boolean().optional(), + }) + .optional(); + +export const clientUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateClientSchema, +}); + +export const clientBatchSortOrderInputSchema = z.array( + z.object({ + id: z.string(), + sortOrder: z.number().int(), + }), +); + +type ClientIdInput = z.infer; +type ClientIdentifierInput = z.infer; +type ClientListInput = z.infer; +type ClientTreeInput = z.infer; +type ClientCreateInput = z.infer; +type ClientUpdateInput = z.infer; +type ClientBatchSortOrderInput = z.infer; + +export async function listClients(ctx: ClientProcedureContext, input: ClientListInput) { + return ctx.db.client.findMany({ + where: buildClientListWhere(input ?? {}), + include: { _count: { select: { children: true, projects: true } } }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); +} + +export async function getClientTree(ctx: ClientProcedureContext, input: ClientTreeInput) { + const all = await ctx.db.client.findMany({ + where: { + ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), + }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + + return buildClientTree(all); +} + +export async function getClientById(ctx: ClientProcedureContext, input: ClientIdInput) { + return findUniqueOrThrow( + ctx.db.client.findUnique({ + where: { id: input.id }, + include: { + parent: true, + children: { orderBy: { sortOrder: "asc" } }, + _count: { select: { projects: true, children: true } }, + }, + }), + "Client", + ); +} + +export async function resolveClientByIdentifier( + ctx: ClientProcedureContext, + input: ClientIdentifierInput, +) { + return findClientByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + code: true, + parentId: true, + isActive: true, + }, + }); +} + +export async function getClientByIdentifier( + ctx: ClientProcedureContext, + input: ClientIdentifierInput, +) { + return findClientByIdentifier(ctx.db, input.identifier, { + include: { _count: { select: { projects: true, children: true } } }, + }); +} + +export async function createClient(ctx: ClientProcedureContext, input: ClientCreateInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateClient(ctx: ClientProcedureContext, input: ClientUpdateInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deactivateClient(ctx: ClientProcedureContext, input: ClientIdInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + before: { isActive: true }, + after: { isActive: false }, + source: "ui", + summary: "Deactivated Client", + }); + + return updated; +} + +export async function deleteClient(ctx: ClientProcedureContext, input: ClientIdInput) { + 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", + ...withAuditUser(ctx.dbUser?.id), + before: client as unknown as Record, + source: "ui", + }); + + return client; +} + +export async function batchUpdateClientSortOrder( + ctx: ClientProcedureContext, + input: ClientBatchSortOrderInput, +) { + 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", + ...withAuditUser(ctx.dbUser?.id), + after: { sortOrder: item.sortOrder }, + source: "ui", + summary: "Updated sort order", + }); + } + + return { ok: true }; +} diff --git a/packages/api/src/router/client.ts b/packages/api/src/router/client.ts index 2720bfc..35f221e 100644 --- a/packages/api/src/router/client.ts +++ b/packages/api/src/router/client.ts @@ -1,7 +1,4 @@ -import { CreateClientSchema, UpdateClientSchema } from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { createAuditEntry } from "../lib/audit.js"; +import { CreateClientSchema } from "@capakraken/shared"; import { adminProcedure, createTRPCRouter, @@ -10,244 +7,62 @@ import { 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; - }; -}; + batchUpdateClientSortOrder, + clientBatchSortOrderInputSchema, + clientIdInputSchema, + clientIdentifierInputSchema, + clientListInputSchema, + clientTreeInputSchema, + clientUpdateInputSchema, + createClient, + deactivateClient, + deleteClient, + getClientById, + getClientByIdentifier, + getClientTree, + listClients, + resolveClientByIdentifier, + updateClient, +} from "./client-procedure-support.js"; 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" }], - }); - }), + .input(clientListInputSchema) + .query(({ ctx, input }) => listClients(ctx, input)), 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); - }), + .input(clientTreeInputSchema) + .query(({ ctx, input }) => getClientTree(ctx, input)), 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; - }), + .input(clientIdInputSchema) + .query(({ ctx, input }) => getClientById(ctx, input)), 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, - }, - }); - }), + .input(clientIdentifierInputSchema) + .query(({ ctx, input }) => resolveClientByIdentifier(ctx, input)), 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 } } }, - }); - }), + .input(clientIdentifierInputSchema) + .query(({ ctx, input }) => getClientByIdentifier(ctx, input)), 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; - }), + .mutation(({ ctx, input }) => createClient(ctx, input)), 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; - }), + .input(clientUpdateInputSchema) + .mutation(({ ctx, input }) => updateClient(ctx, input)), 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; - }), + .input(clientIdInputSchema) + .mutation(({ ctx, input }) => deactivateClient(ctx, input)), 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; - }), + .input(clientIdInputSchema) + .mutation(({ ctx, input }) => deleteClient(ctx, input)), 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 }; - }), + .input(clientBatchSortOrderInputSchema) + .mutation(({ ctx, input }) => batchUpdateClientSortOrder(ctx, input)), });