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_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: {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user