feat(org-unit): scope structural reads to resource overview
This commit is contained in:
@@ -92,14 +92,15 @@ Reasoning:
|
|||||||
|
|
||||||
### `packages/api/src/router/org-unit.ts`
|
### `packages/api/src/router/org-unit.ts`
|
||||||
|
|
||||||
- `list`, `getTree`, `resolveByIdentifier`: `authenticated-safe-lookup`
|
- `resolveByIdentifier`: `authenticated-safe-lookup`
|
||||||
- `getByIdentifier`, `getById`: `resource-overview`
|
- `list`, `getTree`, `getByIdentifier`, `getById`: `resource-overview`
|
||||||
- create, update, deactivate: `admin-only`
|
- create, update, deactivate: `admin-only`
|
||||||
|
|
||||||
Reasoning:
|
Reasoning:
|
||||||
|
|
||||||
- minimal org-unit lookups are low-risk master data
|
- `resolveByIdentifier` stays narrow enough for low-risk lookup flows
|
||||||
- detailed org-unit reads expose `_count.resources` and parent/child context that maps the staffing structure
|
- `list` and especially `getTree` expose the internal org hierarchy, parent links, sort order, and structure metadata, so they should not remain broad authenticated reads
|
||||||
|
- detailed org-unit reads also expose `_count.resources` and parent/child context that maps the staffing structure
|
||||||
|
|
||||||
## Assistant Parity Rule
|
## Assistant Parity Rule
|
||||||
|
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ describe("assistant router tool gating", () => {
|
|||||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||||
const userNames = getToolNames([], SystemRole.USER);
|
const userNames = getToolNames([], SystemRole.USER);
|
||||||
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
||||||
|
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
|
||||||
|
|
||||||
expect(adminNames).toContain("list_countries");
|
expect(adminNames).toContain("list_countries");
|
||||||
expect(adminNames).toContain("create_country");
|
expect(adminNames).toContain("create_country");
|
||||||
@@ -668,6 +669,9 @@ describe("assistant router tool gating", () => {
|
|||||||
expect(userWithResourceOverview).toContain("search_resources");
|
expect(userWithResourceOverview).toContain("search_resources");
|
||||||
expect(userWithResourceOverview).toContain("get_country");
|
expect(userWithResourceOverview).toContain("get_country");
|
||||||
expect(userWithResourceOverview).toContain("list_org_units");
|
expect(userWithResourceOverview).toContain("list_org_units");
|
||||||
|
expect(userWithManagedResources).toContain("search_resources");
|
||||||
|
expect(userWithManagedResources).toContain("get_country");
|
||||||
|
expect(userWithManagedResources).toContain("list_org_units");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
||||||
|
|||||||
@@ -23,6 +23,33 @@ function createProtectedContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("master-data router authorization", () => {
|
describe("master-data router authorization", () => {
|
||||||
|
it("keeps country lists available to authenticated users as safe lookup data", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Germany",
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
metroCities: [{ id: "city_ber", name: "Berlin", countryId: "country_de" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
||||||
|
country: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.list({ isActive: true });
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
where: { isActive: true },
|
||||||
|
include: { metroCities: { orderBy: { name: "asc" } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps minimal country lookups available to authenticated users", async () => {
|
it("keeps minimal country lookups available to authenticated users", async () => {
|
||||||
const findFirst = vi.fn().mockResolvedValue({
|
const findFirst = vi.fn().mockResolvedValue({
|
||||||
id: "country_de",
|
id: "country_de",
|
||||||
@@ -50,6 +77,31 @@ describe("master-data router authorization", () => {
|
|||||||
expect(findFirst).toHaveBeenCalled();
|
expect(findFirst).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps metro-city lookups available to authenticated users", async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: "city_muc",
|
||||||
|
name: "Munich",
|
||||||
|
countryId: "country_de",
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
||||||
|
metroCity: {
|
||||||
|
findUnique,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.getCityById({ id: "city_muc" });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: "city_muc",
|
||||||
|
name: "Munich",
|
||||||
|
countryId: "country_de",
|
||||||
|
});
|
||||||
|
expect(findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: "city_muc" },
|
||||||
|
select: { id: true, name: true, countryId: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("requires resource overview access for detailed country reads with resource counts", async () => {
|
it("requires resource overview access for detailed country reads with resource counts", async () => {
|
||||||
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
||||||
country: {
|
country: {
|
||||||
@@ -99,6 +151,31 @@ describe("master-data router authorization", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows detailed country reads for users with manage-resources access", async () => {
|
||||||
|
const findUnique = vi.fn().mockResolvedValue({
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Germany",
|
||||||
|
isActive: true,
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
metroCities: [],
|
||||||
|
_count: { resources: 4 },
|
||||||
|
});
|
||||||
|
const caller = createCallerFactory(countryRouter)(createProtectedContext({
|
||||||
|
country: {
|
||||||
|
findUnique,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
granted: [PermissionKey.MANAGE_RESOURCES],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await caller.getById({ id: "country_de" });
|
||||||
|
|
||||||
|
expect(result._count.resources).toBe(4);
|
||||||
|
expect(findUnique).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps minimal org-unit lookups available to authenticated users", async () => {
|
it("keeps minimal org-unit lookups available to authenticated users", async () => {
|
||||||
const findFirst = vi.fn()
|
const findFirst = vi.fn()
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
@@ -127,6 +204,26 @@ describe("master-data router authorization", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires resource overview access for org-unit list and tree reads", async () => {
|
||||||
|
const findMany = vi.fn();
|
||||||
|
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
||||||
|
orgUnit: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(caller.list({ level: 5, isActive: true })).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Resource overview access required",
|
||||||
|
});
|
||||||
|
await expect(caller.getTree({ isActive: true })).rejects.toMatchObject({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Resource overview access required",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("requires resource overview access for detailed org-unit reads with staffing counts", async () => {
|
it("requires resource overview access for detailed org-unit reads with staffing counts", async () => {
|
||||||
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
||||||
orgUnit: {
|
orgUnit: {
|
||||||
@@ -172,4 +269,62 @@ describe("master-data router authorization", () => {
|
|||||||
include: { _count: { select: { resources: true } } },
|
include: { _count: { select: { resources: true } } },
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows org-unit list and tree reads for users with resource overview access", async () => {
|
||||||
|
const listFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "ou_1",
|
||||||
|
name: "Delivery",
|
||||||
|
shortName: "DEL",
|
||||||
|
level: 5,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const treeFindMany = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "ou_1",
|
||||||
|
name: "Delivery",
|
||||||
|
shortName: "DEL",
|
||||||
|
level: 5,
|
||||||
|
parentId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const caller = createCallerFactory(orgUnitRouter)(createProtectedContext({
|
||||||
|
orgUnit: {
|
||||||
|
findMany: vi.fn()
|
||||||
|
.mockImplementationOnce(listFindMany)
|
||||||
|
.mockImplementationOnce(treeFindMany),
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
granted: [PermissionKey.MANAGE_RESOURCES],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const listResult = await caller.list({ level: 5, isActive: true });
|
||||||
|
const treeResult = await caller.getTree({ isActive: true });
|
||||||
|
|
||||||
|
expect(listResult).toHaveLength(1);
|
||||||
|
expect(treeResult).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "ou_1",
|
||||||
|
name: "Delivery",
|
||||||
|
children: [],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(listFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { level: 5, isActive: true },
|
||||||
|
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
expect(treeFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): Or
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const orgUnitRouter = createTRPCRouter({
|
export const orgUnitRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: resourceOverviewProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
level: z.number().int().min(5).max(7).optional(),
|
level: z.number().int().min(5).max(7).optional(),
|
||||||
@@ -54,7 +54,7 @@ export const orgUnitRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getTree: protectedProcedure
|
getTree: resourceOverviewProcedure
|
||||||
.input(z.object({ isActive: z.boolean().optional() }).optional())
|
.input(z.object({ isActive: z.boolean().optional() }).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const all = await ctx.db.orgUnit.findMany({
|
const all = await ctx.db.orgUnit.findMany({
|
||||||
|
|||||||
Reference in New Issue
Block a user