feat(role): scope planning-linked role reads to planning audience
This commit is contained in:
@@ -385,12 +385,14 @@ describe("assistant router tool gating", () => {
|
||||
|
||||
expect(userWithoutPlanning).not.toContain("list_allocations");
|
||||
expect(userWithoutPlanning).not.toContain("list_demands");
|
||||
expect(userWithoutPlanning).not.toContain("list_roles");
|
||||
expect(userWithoutPlanning).not.toContain("check_resource_availability");
|
||||
expect(userWithoutPlanning).not.toContain("find_capacity");
|
||||
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||
expect(userWithPlanning).toContain("list_allocations");
|
||||
expect(userWithPlanning).toContain("list_demands");
|
||||
expect(userWithPlanning).toContain("list_roles");
|
||||
expect(userWithPlanning).toContain("check_resource_availability");
|
||||
expect(userWithPlanning).toContain("find_capacity");
|
||||
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 { describe, expect, it, vi } from "vitest";
|
||||
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", () => {
|
||||
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 () => {
|
||||
const db = {
|
||||
role: {
|
||||
@@ -116,6 +164,64 @@ describe("role router planning counts", () => {
|
||||
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 () => {
|
||||
const db = {
|
||||
role: {
|
||||
|
||||
@@ -229,6 +229,7 @@ const COST_TOOLS = new Set([
|
||||
const PLANNING_READ_TOOLS = new Set([
|
||||
"list_allocations",
|
||||
"list_demands",
|
||||
"list_roles",
|
||||
"check_resource_availability",
|
||||
"get_staffing_suggestions",
|
||||
"find_capacity",
|
||||
|
||||
@@ -5,7 +5,13 @@ 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";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
managerProcedure,
|
||||
planningReadProcedure,
|
||||
protectedProcedure,
|
||||
requirePermission,
|
||||
} from "../trpc.js";
|
||||
|
||||
async function loadRolePlanningEntryCounts(
|
||||
db: Pick<import("@capakraken/db").PrismaClient, "demandRequirement" | "assignment">,
|
||||
@@ -54,7 +60,7 @@ async function attachSinglePlanningEntryCount<
|
||||
}
|
||||
|
||||
export const roleRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
list: planningReadProcedure
|
||||
.input(
|
||||
z.object({
|
||||
isActive: z.boolean().optional(),
|
||||
@@ -120,7 +126,7 @@ export const roleRouter = createTRPCRouter({
|
||||
return role;
|
||||
}),
|
||||
|
||||
getByIdentifier: protectedProcedure
|
||||
getByIdentifier: planningReadProcedure
|
||||
.input(z.object({ identifier: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const select = {
|
||||
@@ -161,7 +167,7 @@ export const roleRouter = createTRPCRouter({
|
||||
return attachSinglePlanningEntryCount(ctx.db, role);
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
getById: planningReadProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const role = await findUniqueOrThrow(
|
||||
|
||||
Reference in New Issue
Block a user