feat(role): scope planning-linked role reads to planning audience

This commit is contained in:
2026-03-30 09:58:39 +02:00
parent 16cf1bcb50
commit 3a30fecc13
4 changed files with 120 additions and 5 deletions
@@ -385,12 +385,14 @@ describe("assistant router tool gating", () => {
expect(userWithoutPlanning).not.toContain("list_allocations"); expect(userWithoutPlanning).not.toContain("list_allocations");
expect(userWithoutPlanning).not.toContain("list_demands"); expect(userWithoutPlanning).not.toContain("list_demands");
expect(userWithoutPlanning).not.toContain("list_roles");
expect(userWithoutPlanning).not.toContain("check_resource_availability"); expect(userWithoutPlanning).not.toContain("check_resource_availability");
expect(userWithoutPlanning).not.toContain("find_capacity"); expect(userWithoutPlanning).not.toContain("find_capacity");
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions"); expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
expect(userWithoutPlanning).not.toContain("find_best_project_resource"); expect(userWithoutPlanning).not.toContain("find_best_project_resource");
expect(userWithPlanning).toContain("list_allocations"); expect(userWithPlanning).toContain("list_allocations");
expect(userWithPlanning).toContain("list_demands"); expect(userWithPlanning).toContain("list_demands");
expect(userWithPlanning).toContain("list_roles");
expect(userWithPlanning).toContain("check_resource_availability"); expect(userWithPlanning).toContain("check_resource_availability");
expect(userWithPlanning).toContain("find_capacity"); expect(userWithPlanning).toContain("find_capacity");
expect(userWithPlanning).not.toContain("get_staffing_suggestions"); expect(userWithPlanning).not.toContain("get_staffing_suggestions");
@@ -1,4 +1,4 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared"; import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { roleRouter } from "../router/role.js"; import { roleRouter } from "../router/role.js";
@@ -27,7 +27,55 @@ function createManagerCaller(db: Record<string, unknown>) {
}); });
} }
function createProtectedCallerWithOverrides(
db: Record<string, unknown>,
overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null,
) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_2",
systemRole: SystemRole.USER,
permissionOverrides: overrides,
},
});
}
describe("role router planning counts", () => { describe("role router planning counts", () => {
it("requires planning read access for role list views with planning counts", async () => {
const caller = createProtectedCallerWithOverrides({}, null);
await expect(caller.list({})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
await expect(caller.getByIdentifier({ identifier: "role_fx" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
await expect(caller.getById({ id: "role_fx" })).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it("does not treat viewCosts as a substitute for viewPlanning on role list views", async () => {
const caller = createProtectedCallerWithOverrides({}, {
granted: [PermissionKey.VIEW_COSTS],
});
await expect(caller.list({})).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Planning read access required",
});
});
it("reports planning entry counts for roles", async () => { it("reports planning entry counts for roles", async () => {
const db = { const db = {
role: { role: {
@@ -116,6 +164,64 @@ describe("role router planning counts", () => {
expect(result[0]?._count.allocations).toBe(2); expect(result[0]?._count.allocations).toBe(2);
}); });
it("allows users with viewPlanning to load a role by identifier with planning counts", async () => {
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
_count: { resourceRoles: 2 },
}),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
};
const caller = createProtectedCallerWithOverrides(db, {
granted: [PermissionKey.VIEW_PLANNING],
});
const result = await caller.getByIdentifier({ identifier: "role_fx" });
expect(result._count.resourceRoles).toBe(2);
expect(result._count.allocations).toBe(1);
expect(db.role.findUnique).toHaveBeenCalledWith(expect.objectContaining({
where: { id: "role_fx" },
select: expect.objectContaining({
id: true,
name: true,
description: true,
color: true,
isActive: true,
_count: expect.any(Object),
}),
}));
});
it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => { it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => {
const db = { const db = {
role: { role: {
+1
View File
@@ -229,6 +229,7 @@ const COST_TOOLS = new Set([
const PLANNING_READ_TOOLS = new Set([ const PLANNING_READ_TOOLS = new Set([
"list_allocations", "list_allocations",
"list_demands", "list_demands",
"list_roles",
"check_resource_availability", "check_resource_availability",
"get_staffing_suggestions", "get_staffing_suggestions",
"find_capacity", "find_capacity",
+10 -4
View File
@@ -5,7 +5,13 @@ 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 { 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 { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import {
createTRPCRouter,
managerProcedure,
planningReadProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
async function loadRolePlanningEntryCounts( async function loadRolePlanningEntryCounts(
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">, db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
@@ -54,7 +60,7 @@ async function attachSinglePlanningEntryCount<
} }
export const roleRouter = createTRPCRouter({ export const roleRouter = createTRPCRouter({
list: protectedProcedure list: planningReadProcedure
.input( .input(
z.object({ z.object({
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
@@ -120,7 +126,7 @@ export const roleRouter = createTRPCRouter({
return role; return role;
}), }),
getByIdentifier: protectedProcedure getByIdentifier: planningReadProcedure
.input(z.object({ identifier: z.string() })) .input(z.object({ identifier: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const select = { const select = {
@@ -161,7 +167,7 @@ export const roleRouter = createTRPCRouter({
return attachSinglePlanningEntryCount(ctx.db, role); return attachSinglePlanningEntryCount(ctx.db, role);
}), }),
getById: protectedProcedure getById: planningReadProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const role = await findUniqueOrThrow( const role = await findUniqueOrThrow(