diff --git a/packages/api/src/__tests__/role-support.test.ts b/packages/api/src/__tests__/role-support.test.ts new file mode 100644 index 0000000..1f2e5a2 --- /dev/null +++ b/packages/api/src/__tests__/role-support.test.ts @@ -0,0 +1,100 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + appendZeroAllocationCount, + assertRoleNameAvailable, + buildRoleCreateData, + buildRoleListWhere, + buildRoleUpdateData, + findRoleByIdentifier, +} from "../router/role-support.js"; + +describe("role support", () => { + it("builds list filters", () => { + expect(buildRoleListWhere({ + isActive: true, + search: "FX", + })).toEqual({ + isActive: true, + name: { contains: "FX", mode: "insensitive" }, + }); + }); + + it("resolves a role by case-insensitive name fallback", async () => { + const db = { + role: { + findUnique: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), + findFirst: vi.fn() + .mockResolvedValueOnce({ id: "role_fx", name: "FX" }), + }, + } as never; + + const result = await findRoleByIdentifier<{ id: string; name: string }>( + db, + " fx ", + { id: true, name: true }, + ); + + expect(result).toEqual({ id: "role_fx", name: "FX" }); + expect(db.role.findUnique).toHaveBeenNthCalledWith(1, { + where: { id: "fx" }, + select: { id: true, name: true }, + }); + }); + + it("throws when no identifier match exists", async () => { + const db = { + role: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn().mockResolvedValue(null), + }, + } as never; + + await expect(findRoleByIdentifier(db, "missing", { id: true })).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds create and sparse update payloads", () => { + expect(buildRoleCreateData({ + name: "FX", + description: undefined, + color: "#111111", + })).toEqual({ + name: "FX", + description: null, + color: "#111111", + }); + + expect(buildRoleUpdateData({ + description: "Updated", + isActive: false, + })).toEqual({ + description: "Updated", + isActive: false, + }); + }); + + it("appends a zero allocation count on create responses", () => { + expect(appendZeroAllocationCount({ + id: "role_fx", + _count: { resourceRoles: 2 }, + })).toEqual({ + id: "role_fx", + _count: { resourceRoles: 2, allocations: 0 }, + }); + }); + + it("rejects duplicate role names outside the ignored id", async () => { + const db = { + role: { + findUnique: vi.fn().mockResolvedValue({ id: "role_existing", name: "FX" }), + }, + } as never; + + await expect(assertRoleNameAvailable(db, "FX")).rejects.toMatchObject({ + code: "CONFLICT", + message: 'Role "FX" already exists', + }); + }); +}); diff --git a/packages/api/src/router/role-support.ts b/packages/api/src/router/role-support.ts new file mode 100644 index 0000000..d27d5df --- /dev/null +++ b/packages/api/src/router/role-support.ts @@ -0,0 +1,152 @@ +import { countPlanningEntries } from "@capakraken/application"; +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { CreateRoleSchema, UpdateRoleSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type RolePlanningCountsDb = Pick; +type RoleIdentifierDb = Pick; + +type RoleCountRecord = { + id: string; + _count: { resourceRoles: number }; +}; + +type RoleListInput = { + isActive?: boolean | undefined; + search?: string | undefined; +}; + +type CreateRoleInput = z.infer; +type UpdateRoleInput = z.infer; + +export function buildRoleListWhere(input: RoleListInput): Prisma.RoleWhereInput { + return { + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input.search + ? { name: { contains: input.search, mode: "insensitive" } } + : {}), + }; +} + +export async function loadRolePlanningEntryCounts( + db: RolePlanningCountsDb, + roleIds: string[], +) { + const { countsByRoleId } = await countPlanningEntries(db, { + roleIds, + }); + + return countsByRoleId; +} + +export async function attachRolePlanningEntryCounts< + TRole extends RoleCountRecord, +>( + db: RolePlanningCountsDb, + 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, + }, + })); +} + +export async function attachSingleRolePlanningEntryCount< + TRole extends RoleCountRecord, +>( + db: RolePlanningCountsDb, + role: TRole, +): Promise { + return (await attachRolePlanningEntryCounts(db, [role]))[0]!; +} + +export async function findRoleByIdentifier( + db: RoleIdentifierDb, + identifier: string, + select: Record, +): Promise { + const normalizedIdentifier = identifier.trim(); + + let role = await db.role.findUnique({ + where: { id: normalizedIdentifier }, + select, + }) as TRole | null; + if (!role) { + role = await db.role.findUnique({ + where: { name: normalizedIdentifier }, + select, + }) as TRole | null; + } + if (!role) { + role = await db.role.findFirst({ + where: { name: { equals: normalizedIdentifier, mode: "insensitive" } }, + select, + }) as TRole | null; + } + if (!role) { + role = await db.role.findFirst({ + where: { name: { contains: normalizedIdentifier, mode: "insensitive" } }, + select, + }) as TRole | null; + } + if (!role) { + throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" }); + } + + return role; +} + +export function buildRoleCreateData( + input: CreateRoleInput, +): Prisma.RoleCreateInput { + return { + name: input.name, + description: input.description ?? null, + color: input.color ?? null, + }; +} + +export function buildRoleUpdateData( + input: UpdateRoleInput, +): Prisma.RoleUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.color !== undefined ? { color: input.color } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export function appendZeroAllocationCount< + TRole extends RoleCountRecord, +>( + role: TRole, +): TRole & { _count: { resourceRoles: number; allocations: number } } { + return { + ...role, + _count: { + ...role._count, + allocations: 0, + }, + }; +} + +export async function assertRoleNameAvailable( + db: RoleIdentifierDb, + name: string, + ignoreId?: string, +): Promise { + const existing = await db.role.findUnique({ where: { name } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ code: "CONFLICT", message: `Role "${name}" already exists` }); + } +} diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index dd9b3b1..152b64a 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -1,4 +1,3 @@ -import { countPlanningEntries } from "@capakraken/application"; import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -12,52 +11,16 @@ import { 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]!; -} +import { + appendZeroAllocationCount, + assertRoleNameAvailable, + attachRolePlanningEntryCounts, + attachSingleRolePlanningEntryCount, + buildRoleCreateData, + buildRoleListWhere, + buildRoleUpdateData, + findRoleByIdentifier, +} from "./role-support.js"; export const roleRouter = createTRPCRouter({ list: planningReadProcedure @@ -69,12 +32,7 @@ export const roleRouter = createTRPCRouter({ ) .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 } } - : {}), - }, + where: buildRoleListWhere(input), include: { _count: { select: { resourceRoles: true }, @@ -83,13 +41,12 @@ export const roleRouter = createTRPCRouter({ orderBy: { name: "asc" }, }); - return attachPlanningEntryCounts(ctx.db, roles); + return attachRolePlanningEntryCounts(ctx.db, roles); }), 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, @@ -97,33 +54,12 @@ export const roleRouter = createTRPCRouter({ isActive: true, } as const; - let role = await ctx.db.role.findUnique({ - where: { id: identifier }, - select, - }); - if (!role) { - role = await ctx.db.role.findUnique({ - where: { name: identifier }, - select, - }); - } - if (!role) { - role = await ctx.db.role.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - if (!role) { - role = await ctx.db.role.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - select, - }); - } - if (!role) { - throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" }); - } - - return role; + return findRoleByIdentifier<{ + id: string; + name: string; + color: string | null; + isActive: boolean; + }>(ctx.db, input.identifier, select); }), getByIdentifier: planningReadProcedure @@ -138,33 +74,15 @@ export const roleRouter = createTRPCRouter({ _count: { select: { resourceRoles: true } }, } as const; - let role = await ctx.db.role.findUnique({ - where: { id: input.identifier }, - select, - }); - if (!role) { - role = await ctx.db.role.findUnique({ - where: { name: input.identifier }, - select, - }); - } - if (!role) { - role = await ctx.db.role.findFirst({ - where: { name: { equals: input.identifier, mode: "insensitive" } }, - select, - }); - } - if (!role) { - role = await ctx.db.role.findFirst({ - where: { name: { contains: input.identifier, mode: "insensitive" } }, - select, - }); - } - if (!role) { - throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" }); - } - - return attachSinglePlanningEntryCount(ctx.db, role); + const role = await findRoleByIdentifier<{ + id: string; + name: string; + description: string | null; + color: string | null; + isActive: boolean; + _count: { resourceRoles: number }; + }>(ctx.db, input.identifier, select); + return attachSingleRolePlanningEntryCount(ctx.db, role); }), getById: planningReadProcedure @@ -185,24 +103,17 @@ export const roleRouter = createTRPCRouter({ "Role", ); - return attachSinglePlanningEntryCount(ctx.db, role); + return attachSingleRolePlanningEntryCount(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` }); - } + await assertRoleNameAvailable(ctx.db, input.name); const role = await ctx.db.role.create({ - data: { - name: input.name, - description: input.description ?? null, - color: input.color ?? null, - }, + data: buildRoleCreateData(input), include: { _count: { select: { resourceRoles: true } } }, }); @@ -217,13 +128,7 @@ export const roleRouter = createTRPCRouter({ emitRoleCreated({ id: role.id, name: role.name }); - return { - ...role, - _count: { - ...role._count, - allocations: 0, - }, - }; + return appendZeroAllocationCount(role); }), update: managerProcedure @@ -236,20 +141,12 @@ export const roleRouter = createTRPCRouter({ ); 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` }); - } + await assertRoleNameAvailable(ctx.db, input.data.name, input.id); } 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 } : {}), - }, + data: buildRoleUpdateData(input.data), include: { _count: { select: { resourceRoles: true } } }, }); @@ -264,7 +161,7 @@ export const roleRouter = createTRPCRouter({ emitRoleUpdated({ id: updated.id, name: updated.name }); - return attachSinglePlanningEntryCount(ctx.db, updated); + return attachSingleRolePlanningEntryCount(ctx.db, updated); }), delete: managerProcedure @@ -279,7 +176,7 @@ export const roleRouter = createTRPCRouter({ "Role", ); - const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role); + const roleWithCounts = await attachSingleRolePlanningEntryCount(ctx.db, role); if ( roleWithCounts._count.resourceRoles > 0 || @@ -328,6 +225,6 @@ export const roleRouter = createTRPCRouter({ emitRoleUpdated({ id: role.id, isActive: false }); - return attachSinglePlanningEntryCount(ctx.db, role); + return attachSingleRolePlanningEntryCount(ctx.db, role); }), });