refactor(api): extract role router support

This commit is contained in:
2026-03-31 13:40:55 +02:00
parent b57f7e6d2e
commit 5e74d61902
3 changed files with 288 additions and 139 deletions
@@ -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',
});
});
});
+152
View File
@@ -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<PrismaClient, "demandRequirement" | "assignment">;
type RoleIdentifierDb = Pick<PrismaClient, "role">;
type RoleCountRecord = {
id: string;
_count: { resourceRoles: number };
};
type RoleListInput = {
isActive?: boolean | undefined;
search?: string | undefined;
};
type CreateRoleInput = z.infer<typeof CreateRoleSchema>;
type UpdateRoleInput = z.infer<typeof UpdateRoleSchema>;
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<Array<TRole & { _count: { resourceRoles: number; allocations: number } }>> {
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<TRole & { _count: { resourceRoles: number; allocations: number } }> {
return (await attachRolePlanningEntryCounts(db, [role]))[0]!;
}
export async function findRoleByIdentifier<TRole>(
db: RoleIdentifierDb,
identifier: string,
select: Record<string, unknown>,
): Promise<TRole> {
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<void> {
const existing = await db.role.findUnique({ where: { name } });
if (existing && existing.id !== ignoreId) {
throw new TRPCError({ code: "CONFLICT", message: `Role "${name}" already exists` });
}
}
+36 -139
View File
@@ -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<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
roleIds: string[],
) {
const { countsByRoleId } = await countPlanningEntries(db, {
roleIds,
});
return countsByRoleId;
}
async function attachPlanningEntryCounts<
TRole extends {
id: string;
_count: { resourceRoles: number };
},
>(
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
roles: TRole[],
): Promise<Array<TRole & { _count: { resourceRoles: number; allocations: number } }>> {
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<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
role: TRole,
): Promise<TRole & { _count: { resourceRoles: number; allocations: number } }> {
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);
}),
});