import { countPlanningEntries } from "@planarchy/application"; import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; async function loadRolePlanningEntryCounts( db: Pick, roleIds: string[], ) { const { countsByRoleId } = await countPlanningEntries(db, { roleIds, }); return countsByRoleId; } async function attachPlanningEntryCounts< TRole extends { id: string; _count: { resourceRoles: number }; }, >( db: Pick, roles: TRole[], ): Promise> { const countsByRoleId = await loadRolePlanningEntryCounts( db, roles.map((role) => role.id), ); return roles.map((role) => ({ ...role, _count: { ...role._count, allocations: countsByRoleId.get(role.id) ?? 0, }, })); } async function attachSinglePlanningEntryCount< TRole extends { id: string; _count: { resourceRoles: number }; }, >( db: Pick, role: TRole, ): Promise { return (await attachPlanningEntryCounts(db, [role]))[0]!; } export const roleRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ isActive: z.boolean().optional(), search: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const roles = await ctx.db.role.findMany({ where: { ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), ...(input.search ? { name: { contains: input.search, mode: "insensitive" as const } } : {}), }, include: { _count: { select: { resourceRoles: true }, }, }, orderBy: { name: "asc" }, }); return attachPlanningEntryCounts(ctx.db, roles); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const role = await findUniqueOrThrow( ctx.db.role.findUnique({ where: { id: input.id }, include: { _count: { select: { resourceRoles: true } }, resourceRoles: { include: { resource: { select: RESOURCE_BRIEF_SELECT }, }, }, }, }), "Role", ); return attachSinglePlanningEntryCount(ctx.db, role); }), create: managerProcedure .input(CreateRoleSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ROLES); const existing = await ctx.db.role.findUnique({ where: { name: input.name } }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: `Role "${input.name}" already exists` }); } const role = await ctx.db.role.create({ data: { name: input.name, description: input.description ?? null, color: input.color ?? null, }, include: { _count: { select: { resourceRoles: true } } }, }); await ctx.db.auditLog.create({ data: { entityType: "Role", entityId: role.id, action: "CREATE", changes: { after: role }, }, }); emitRoleCreated({ id: role.id, name: role.name }); return { ...role, _count: { ...role._count, allocations: 0, }, }; }), update: managerProcedure .input(z.object({ id: z.string(), data: UpdateRoleSchema })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ROLES); const existing = await findUniqueOrThrow( ctx.db.role.findUnique({ where: { id: input.id } }), "Role", ); if (input.data.name && input.data.name !== existing.name) { const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } }); if (nameConflict) { throw new TRPCError({ code: "CONFLICT", message: `Role "${input.data.name}" already exists` }); } } const updated = await ctx.db.role.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.description !== undefined ? { description: input.data.description } : {}), ...(input.data.color !== undefined ? { color: input.data.color } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), }, include: { _count: { select: { resourceRoles: true } } }, }); await ctx.db.auditLog.create({ data: { entityType: "Role", entityId: input.id, action: "UPDATE", changes: { before: existing, after: updated }, }, }); emitRoleUpdated({ id: updated.id, name: updated.name }); return attachSinglePlanningEntryCount(ctx.db, updated); }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ROLES); const role = await findUniqueOrThrow( ctx.db.role.findUnique({ where: { id: input.id }, include: { _count: { select: { resourceRoles: true } } }, }), "Role", ); const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role); if ( roleWithCounts._count.resourceRoles > 0 || roleWithCounts._count.allocations > 0 ) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: `Cannot delete role assigned to ${roleWithCounts._count.resourceRoles} resource(s) and ${roleWithCounts._count.allocations} allocation(s). Deactivate it instead.`, }); } await ctx.db.role.delete({ where: { id: input.id } }); await ctx.db.auditLog.create({ data: { entityType: "Role", entityId: input.id, action: "DELETE", changes: { before: role }, }, }); emitRoleDeleted(input.id); return { success: true }; }), deactivate: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ROLES); const role = await ctx.db.role.update({ where: { id: input.id }, data: { isActive: false }, include: { _count: { select: { resourceRoles: true } } }, }); await ctx.db.auditLog.create({ data: { entityType: "Role", entityId: input.id, action: "UPDATE", changes: { after: { isActive: false } }, }, }); emitRoleUpdated({ id: role.id, isActive: false }); return attachSinglePlanningEntryCount(ctx.db, role); }), });