refactor(api): extract role read procedures

This commit is contained in:
2026-03-31 21:22:44 +02:00
parent cba4d44f16
commit 884f1012c9
5 changed files with 345 additions and 84 deletions
@@ -26,6 +26,7 @@ Done
- `audit-log` - `audit-log`
- `calculation-rules` - `calculation-rules`
- `webhook` - `webhook`
- `role`
Ready next Ready next
- none in the conflict-safe backlog - none in the conflict-safe backlog
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PermissionKey, SystemRole } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }),
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
};
});
import { countPlanningEntries } from "@capakraken/application";
import { executeTool } from "../router/assistant-tools.js";
import { createToolContext } from "./assistant-tools-master-data-read-test-helpers.js";
describe("assistant master data roles read tool", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(countPlanningEntries).mockResolvedValue({ countsByRoleId: new Map() });
});
it("routes role reads through their backing router", async () => {
const db = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: "role_anim",
name: "Animation",
color: "#112233",
_count: { resourceRoles: 2 },
},
]),
},
};
const ctx = createToolContext(db, {
userRole: SystemRole.CONTROLLER,
permissions: [PermissionKey.VIEW_PLANNING],
});
const rolesResult = await executeTool("list_roles", "{}", ctx);
expect(db.role.findMany).toHaveBeenCalledWith({
where: {},
include: {
_count: {
select: { resourceRoles: true },
},
},
orderBy: { name: "asc" },
});
expect(JSON.parse(rolesResult.content)).toEqual([
{
id: "role_anim",
name: "Animation",
color: "#112233",
},
]);
});
});
@@ -4,10 +4,18 @@ import {
createRole, createRole,
deactivateRole, deactivateRole,
deleteRole, deleteRole,
getRoleById,
getRoleByIdentifier,
listRoles,
resolveRoleByIdentifier,
RoleIdInputSchema, RoleIdInputSchema,
RoleIdentifierInputSchema,
RoleListInputSchema,
ResolveRoleIdentifierInputSchema,
UpdateRoleProcedureInputSchema, UpdateRoleProcedureInputSchema,
updateRole, updateRole,
} from "../router/role-procedure-support.js"; } from "../router/role-procedure-support.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
const { countPlanningEntries } = vi.hoisted(() => ({ const { countPlanningEntries } = vi.hoisted(() => ({
countPlanningEntries: vi.fn(), countPlanningEntries: vi.fn(),
@@ -49,6 +57,163 @@ describe("role procedure support", () => {
emitRoleUpdated.mockReset(); emitRoleUpdated.mockReset();
}); });
it("lists roles with planning entry counts", async () => {
countPlanningEntries.mockResolvedValue({
countsByRoleId: new Map([["role_fx", 2]]),
});
const db = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: "role_fx",
name: "FX",
_count: { resourceRoles: 1 },
},
]),
},
demandRequirement: {},
assignment: {},
};
const result = await listRoles(
createContext(db),
RoleListInputSchema.parse({ search: "FX" }),
);
expect(result).toEqual([
{
id: "role_fx",
name: "FX",
_count: { resourceRoles: 1, allocations: 2 },
},
]);
expect(db.role.findMany).toHaveBeenCalledWith({
where: { name: { contains: "FX", mode: "insensitive" } },
include: { _count: { select: { resourceRoles: true } } },
orderBy: { name: "asc" },
});
});
it("resolves roles by identifier for protected read paths", async () => {
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
color: "#111111",
isActive: true,
}),
},
};
const result = await resolveRoleByIdentifier(
createContext(db),
ResolveRoleIdentifierInputSchema.parse({ identifier: "role_fx" }),
);
expect(result).toEqual({
id: "role_fx",
name: "FX",
color: "#111111",
isActive: true,
});
expect(db.role.findUnique).toHaveBeenCalledWith({
where: { id: "role_fx" },
select: {
id: true,
name: true,
color: true,
isActive: true,
},
});
});
it("loads a role by identifier and attaches planning counts", async () => {
countPlanningEntries.mockResolvedValue({
countsByRoleId: new Map([["role_fx", 3]]),
});
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
_count: { resourceRoles: 2 },
}),
},
demandRequirement: {},
assignment: {},
};
const result = await getRoleByIdentifier(
createContext(db),
RoleIdentifierInputSchema.parse({ identifier: "role_fx" }),
);
expect(result).toEqual({
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
_count: { resourceRoles: 2, allocations: 3 },
});
expect(db.role.findUnique).toHaveBeenCalledWith({
where: { id: "role_fx" },
select: {
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: { select: { resourceRoles: true } },
},
});
});
it("loads a role by id with resource role details and planning counts", async () => {
countPlanningEntries.mockResolvedValue({
countsByRoleId: new Map([["role_fx", 1]]),
});
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
_count: { resourceRoles: 1 },
resourceRoles: [],
}),
},
demandRequirement: {},
assignment: {},
};
const result = await getRoleById(
createContext(db),
RoleIdInputSchema.parse({ id: "role_fx" }),
);
expect(result).toEqual({
id: "role_fx",
name: "FX",
_count: { resourceRoles: 1, allocations: 1 },
resourceRoles: [],
});
expect(db.role.findUnique).toHaveBeenCalledWith({
where: { id: "role_fx" },
include: {
_count: { select: { resourceRoles: true } },
resourceRoles: {
include: {
resource: { select: RESOURCE_BRIEF_SELECT },
},
},
},
});
});
it("creates roles with audit and zero allocation counts", async () => { it("creates roles with audit and zero allocation counts", async () => {
const role = { const role = {
id: "role_fx", id: "role_fx",
@@ -2,17 +2,34 @@ import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@capakraken/s
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import type { TRPCContext } from "../trpc.js"; import type { TRPCContext } from "../trpc.js";
import { requirePermission } from "../trpc.js"; import { requirePermission } from "../trpc.js";
import { import {
appendZeroAllocationCount, appendZeroAllocationCount,
assertRoleNameAvailable, assertRoleNameAvailable,
attachRolePlanningEntryCounts,
attachSingleRolePlanningEntryCount, attachSingleRolePlanningEntryCount,
buildRoleCreateData, buildRoleCreateData,
buildRoleUpdateData, buildRoleUpdateData,
buildRoleListWhere,
findRoleByIdentifier,
} from "./role-support.js"; } from "./role-support.js";
export const RoleListInputSchema = z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
});
export const RoleIdentifierInputSchema = z.object({
identifier: z.string(),
});
export const ResolveRoleIdentifierInputSchema = z.object({
identifier: z.string().trim().min(1),
});
export const RoleIdInputSchema = z.object({ export const RoleIdInputSchema = z.object({
id: z.string(), id: z.string(),
}); });
@@ -26,6 +43,91 @@ type RoleMutationContext = Pick<TRPCContext, "db" | "dbUser"> & {
permissions: Set<PermissionKey>; permissions: Set<PermissionKey>;
}; };
type RoleReadContext = Pick<TRPCContext, "db">;
export async function listRoles(
ctx: RoleReadContext,
input: z.infer<typeof RoleListInputSchema>,
) {
const roles = await ctx.db.role.findMany({
where: buildRoleListWhere(input),
include: {
_count: {
select: { resourceRoles: true },
},
},
orderBy: { name: "asc" },
});
return attachRolePlanningEntryCounts(ctx.db, roles);
}
export async function resolveRoleByIdentifier(
ctx: RoleReadContext,
input: z.infer<typeof ResolveRoleIdentifierInputSchema>,
) {
const select = {
id: true,
name: true,
color: true,
isActive: true,
} as const;
return findRoleByIdentifier<{
id: string;
name: string;
color: string | null;
isActive: boolean;
}>(ctx.db, input.identifier, select);
}
export async function getRoleByIdentifier(
ctx: RoleReadContext,
input: z.infer<typeof RoleIdentifierInputSchema>,
) {
const select = {
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: { select: { resourceRoles: true } },
} as const;
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);
}
export async function getRoleById(
ctx: RoleReadContext,
input: z.infer<typeof RoleIdInputSchema>,
) {
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 attachSingleRolePlanningEntryCount(ctx.db, role);
}
export async function createRole( export async function createRole(
ctx: RoleMutationContext, ctx: RoleMutationContext,
input: z.infer<typeof CreateRoleSchema>, input: z.infer<typeof CreateRoleSchema>,
+15 -84
View File
@@ -1,7 +1,4 @@
import { CreateRoleSchema } from "@capakraken/shared"; import { CreateRoleSchema } from "@capakraken/shared";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { import {
createTRPCRouter, createTRPCRouter,
managerProcedure, managerProcedure,
@@ -12,100 +9,34 @@ import {
createRole, createRole,
deactivateRole, deactivateRole,
deleteRole, deleteRole,
getRoleById,
getRoleByIdentifier,
listRoles,
ResolveRoleIdentifierInputSchema,
resolveRoleByIdentifier,
RoleIdentifierInputSchema,
RoleIdInputSchema, RoleIdInputSchema,
RoleListInputSchema,
UpdateRoleProcedureInputSchema, UpdateRoleProcedureInputSchema,
updateRole, updateRole,
} from "./role-procedure-support.js"; } from "./role-procedure-support.js";
import {
attachRolePlanningEntryCounts,
attachSingleRolePlanningEntryCount,
buildRoleListWhere,
findRoleByIdentifier,
} from "./role-support.js";
export const roleRouter = createTRPCRouter({ export const roleRouter = createTRPCRouter({
list: planningReadProcedure list: planningReadProcedure
.input( .input(RoleListInputSchema)
z.object({ .query(({ ctx, input }) => listRoles(ctx, input)),
isActive: z.boolean().optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const roles = await ctx.db.role.findMany({
where: buildRoleListWhere(input),
include: {
_count: {
select: { resourceRoles: true },
},
},
orderBy: { name: "asc" },
});
return attachRolePlanningEntryCounts(ctx.db, roles);
}),
resolveByIdentifier: protectedProcedure resolveByIdentifier: protectedProcedure
.input(z.object({ identifier: z.string().trim().min(1) })) .input(ResolveRoleIdentifierInputSchema)
.query(async ({ ctx, input }) => { .query(({ ctx, input }) => resolveRoleByIdentifier(ctx, input)),
const select = {
id: true,
name: true,
color: true,
isActive: true,
} as const;
return findRoleByIdentifier<{
id: string;
name: string;
color: string | null;
isActive: boolean;
}>(ctx.db, input.identifier, select);
}),
getByIdentifier: planningReadProcedure getByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string() })) .input(RoleIdentifierInputSchema)
.query(async ({ ctx, input }) => { .query(({ ctx, input }) => getRoleByIdentifier(ctx, input)),
const select = {
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: { select: { resourceRoles: true } },
} as const;
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 getById: planningReadProcedure
.input(z.object({ id: z.string() })) .input(RoleIdInputSchema)
.query(async ({ ctx, input }) => { .query(({ ctx, input }) => getRoleById(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 attachSingleRolePlanningEntryCount(ctx.db, role);
}),
create: managerProcedure create: managerProcedure
.input(CreateRoleSchema) .input(CreateRoleSchema)