import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, protectedProcedure, resourceOverviewProcedure, } from "../trpc.js"; import type { OrgUnitTree } from "@capakraken/shared"; interface FlatOrgUnit { id: string; name: string; shortName: string | null; level: number; parentId: string | null; sortOrder: number; isActive: boolean; createdAt: Date; updatedAt: Date; } function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): OrgUnitTree[] { return flatItems .filter((item) => item.parentId === parentId) .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) .map((item) => ({ ...item, children: buildTree(flatItems, item.id), })); } export const orgUnitRouter = createTRPCRouter({ list: resourceOverviewProcedure .input( z.object({ level: z.number().int().min(5).max(7).optional(), parentId: z.string().optional(), isActive: z.boolean().optional(), }).optional(), ) .query(async ({ ctx, input }) => { return ctx.db.orgUnit.findMany({ where: { ...(input?.level !== undefined ? { level: input.level } : {}), ...(input?.parentId !== undefined ? { parentId: input.parentId } : {}), ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), }, orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], }); }), getTree: resourceOverviewProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { const all = await ctx.db.orgUnit.findMany({ where: { ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), }, orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); return buildTree(all); }), getById: resourceOverviewProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const unit = await findUniqueOrThrow( ctx.db.orgUnit.findUnique({ where: { id: input.id }, include: { parent: true, children: { orderBy: { sortOrder: "asc" } }, _count: { select: { resources: true } }, }, }), "Org unit", ); return unit; }), 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, shortName: true, level: true, isActive: true, } as const; let unit = await ctx.db.orgUnit.findUnique({ where: { id: identifier }, select, }); if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { name: { equals: identifier, mode: "insensitive" } }, select, }); } if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { shortName: { equals: identifier, mode: "insensitive" } }, select, }); } if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { OR: [ { name: { contains: identifier, mode: "insensitive" } }, { shortName: { contains: identifier, mode: "insensitive" } }, ], }, select, }); } if (!unit) { throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` }); } return unit; }), getByIdentifier: resourceOverviewProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { const identifier = input.identifier.trim(); let unit = await ctx.db.orgUnit.findUnique({ where: { id: identifier }, include: { _count: { select: { resources: true } } }, }); if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { name: { equals: identifier, mode: "insensitive" } }, include: { _count: { select: { resources: true } } }, }); } if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { shortName: { equals: identifier, mode: "insensitive" } }, include: { _count: { select: { resources: true } } }, }); } if (!unit) { unit = await ctx.db.orgUnit.findFirst({ where: { OR: [ { name: { contains: identifier, mode: "insensitive" } }, { shortName: { contains: identifier, mode: "insensitive" } }, ], }, include: { _count: { select: { resources: true } } }, }); } if (!unit) { throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` }); } return unit; }), create: adminProcedure .input(CreateOrgUnitSchema) .mutation(async ({ ctx, input }) => { if (input.parentId) { const parent = await findUniqueOrThrow( ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }), "Parent org unit", ); if (parent.level >= input.level) { throw new TRPCError({ code: "BAD_REQUEST", message: `Child level (${input.level}) must be greater than parent level (${parent.level})`, }); } } const created = await ctx.db.orgUnit.create({ data: { name: input.name, ...(input.shortName !== undefined ? { shortName: input.shortName } : {}), level: input.level, ...(input.parentId ? { parentId: input.parentId } : {}), sortOrder: input.sortOrder, }, }); void createAuditEntry({ db: ctx.db, entityType: "OrgUnit", entityId: created.id, entityName: created.name, action: "CREATE", userId: ctx.dbUser?.id, after: created as unknown as Record, source: "ui", }); return created; }), update: adminProcedure .input(z.object({ id: z.string(), data: UpdateOrgUnitSchema })) .mutation(async ({ ctx, input }) => { const existing = await findUniqueOrThrow( ctx.db.orgUnit.findUnique({ where: { id: input.id } }), "Org unit", ); const before = existing as unknown as Record; const updated = await ctx.db.orgUnit.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.shortName !== undefined ? { shortName: input.data.shortName } : {}), ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), ...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}), }, }); void createAuditEntry({ db: ctx.db, entityType: "OrgUnit", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before, after: updated as unknown as Record, source: "ui", }); return updated; }), deactivate: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const updated = await ctx.db.orgUnit.update({ where: { id: input.id }, data: { isActive: false }, }); void createAuditEntry({ db: ctx.db, entityType: "OrgUnit", entityId: updated.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before: { isActive: true }, after: { isActive: false }, source: "ui", summary: "Deactivated OrgUnit", }); return updated; }), });