diff --git a/packages/api/src/__tests__/org-unit-procedure-support.test.ts b/packages/api/src/__tests__/org-unit-procedure-support.test.ts new file mode 100644 index 0000000..3dde3aa --- /dev/null +++ b/packages/api/src/__tests__/org-unit-procedure-support.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + createOrgUnit, + deactivateOrgUnit, + updateOrgUnit, +} from "../router/org-unit-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" } as never, + }; +} + +describe("org-unit procedure support", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("creates an org unit after validating the parent level", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "ou_parent", + level: 5, + name: "Delivery", + }); + const create = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Germany", + level: 6, + parentId: "ou_parent", + sortOrder: 20, + }); + + const result = await createOrgUnit( + createContext({ + orgUnit: { findUnique, create }, + }), + { + name: "Delivery Germany", + level: 6, + parentId: "ou_parent", + sortOrder: 20, + }, + ); + + expect(findUnique).toHaveBeenCalledWith({ + where: { id: "ou_parent" }, + }); + expect(create).toHaveBeenCalledWith({ + data: { + name: "Delivery Germany", + level: 6, + parentId: "ou_parent", + sortOrder: 20, + }, + }); + expect(result.id).toBe("ou_child"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "OrgUnit", + action: "CREATE", + entityId: "ou_child", + }), + ); + }); + + it("updates an org unit and preserves the before snapshot", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Germany", + shortName: "DE", + isActive: true, + }); + const update = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Europe", + shortName: "EU", + isActive: true, + }); + + const result = await updateOrgUnit( + createContext({ + orgUnit: { findUnique, update }, + }), + { + id: "ou_child", + data: { + name: "Delivery Europe", + shortName: "EU", + }, + }, + ); + + expect(update).toHaveBeenCalledWith({ + where: { id: "ou_child" }, + data: { + name: "Delivery Europe", + shortName: "EU", + }, + }); + expect(result.name).toBe("Delivery Europe"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "OrgUnit", + action: "UPDATE", + before: expect.objectContaining({ name: "Delivery Germany" }), + after: expect.objectContaining({ name: "Delivery Europe" }), + }), + ); + }); + + it("deactivates an org unit with the dedicated audit summary", async () => { + const update = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Europe", + isActive: false, + }); + + const result = await deactivateOrgUnit( + createContext({ + orgUnit: { update }, + }), + { id: "ou_child" }, + ); + + expect(update).toHaveBeenCalledWith({ + where: { id: "ou_child" }, + data: { isActive: false }, + }); + expect(result.isActive).toBe(false); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "OrgUnit", + summary: "Deactivated OrgUnit", + before: { isActive: true }, + after: { isActive: false }, + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/org-unit-router.test.ts b/packages/api/src/__tests__/org-unit-router.test.ts new file mode 100644 index 0000000..146bf94 --- /dev/null +++ b/packages/api/src/__tests__/org-unit-router.test.ts @@ -0,0 +1,158 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { orgUnitRouter } from "../router/org-unit.js"; +import { createCallerFactory } from "../trpc.js"; + +const createCaller = createCallerFactory(orgUnitRouter); + +function createProtectedCaller( + db: Record, + options: { + role?: SystemRole; + granted?: PermissionKey[]; + } = {}, +) { + const { role = SystemRole.USER, granted = [] } = options; + + return createCaller({ + session: { + user: { email: "user@example.com", name: "User", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: role === SystemRole.ADMIN ? "user_admin" : "user_1", + systemRole: role, + permissionOverrides: granted.length > 0 ? { granted } : null, + }, + }); +} + +describe("org-unit router", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("lists org units through the resource-overview router", async () => { + const findMany = vi.fn().mockResolvedValue([ + { id: "ou_1", name: "Delivery", level: 5, sortOrder: 10, isActive: true }, + ]); + + const caller = createProtectedCaller({ + orgUnit: { findMany }, + }, { + granted: [PermissionKey.VIEW_ALL_RESOURCES], + }); + const result = await caller.list({ level: 5, isActive: true }); + + expect(findMany).toHaveBeenCalledWith({ + where: { level: 5, isActive: true }, + orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); + expect(result).toHaveLength(1); + }); + + it("builds an org-unit tree through the router", async () => { + const findMany = vi.fn().mockResolvedValue([ + { + id: "ou_root", + 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"), + }, + { + id: "ou_child", + name: "Delivery Germany", + shortName: "DE", + level: 6, + parentId: "ou_root", + sortOrder: 20, + isActive: true, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ]); + + const caller = createProtectedCaller({ + orgUnit: { findMany }, + }, { + granted: [PermissionKey.MANAGE_RESOURCES], + }); + const result = await caller.getTree({ isActive: true }); + + expect(findMany).toHaveBeenCalledWith({ + where: { isActive: true }, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + expect(result).toEqual([ + expect.objectContaining({ + id: "ou_root", + children: [expect.objectContaining({ id: "ou_child" })], + }), + ]); + }); + + it("creates and deactivates org units through the admin router", async () => { + const findUnique = vi.fn().mockResolvedValue({ + id: "ou_root", + level: 5, + name: "Delivery", + }); + const create = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Germany", + level: 6, + parentId: "ou_root", + sortOrder: 20, + }); + const update = vi.fn().mockResolvedValue({ + id: "ou_child", + name: "Delivery Germany", + isActive: false, + }); + + const caller = createProtectedCaller({ + orgUnit: { findUnique, create, update }, + }, { + role: SystemRole.ADMIN, + }); + + const created = await caller.create({ + name: "Delivery Germany", + level: 6, + parentId: "ou_root", + sortOrder: 20, + }); + const deactivated = await caller.deactivate({ id: "ou_child" }); + + expect(create).toHaveBeenCalledWith({ + data: { + name: "Delivery Germany", + level: 6, + parentId: "ou_root", + sortOrder: 20, + }, + }); + expect(update).toHaveBeenCalledWith({ + where: { id: "ou_child" }, + data: { isActive: false }, + }); + expect(created.id).toBe("ou_child"); + expect(deactivated.isActive).toBe(false); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/router/org-unit-procedure-support.ts b/packages/api/src/router/org-unit-procedure-support.ts new file mode 100644 index 0000000..650d7ac --- /dev/null +++ b/packages/api/src/router/org-unit-procedure-support.ts @@ -0,0 +1,196 @@ +import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@capakraken/shared"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { createAuditEntry } from "../lib/audit.js"; +import type { TRPCContext } from "../trpc.js"; +import { + assertOrgUnitParentLevel, + buildOrgUnitCreateData, + buildOrgUnitListWhere, + buildOrgUnitTree, + buildOrgUnitUpdateData, + findOrgUnitByIdentifier, +} from "./org-unit-support.js"; + +type OrgUnitProcedureContext = Pick; + +type OrgUnitIdentifierReadModel = { + id: string; + name: string; + shortName: string | null; + level: number; + isActive: boolean; +}; + +type OrgUnitDetailReadModel = OrgUnitIdentifierReadModel & { + parentId: string | null; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + _count: { resources: number }; +}; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const orgUnitListInputSchema = z.object({ + level: z.number().int().min(5).max(7).optional(), + parentId: z.string().optional(), + isActive: z.boolean().optional(), +}).optional(); + +export const orgUnitTreeInputSchema = z.object({ + isActive: z.boolean().optional(), +}).optional(); + +export const orgUnitIdInputSchema = z.object({ + id: z.string(), +}); + +export const orgUnitIdentifierInputSchema = z.object({ + identifier: z.string().trim().min(1), +}); + +export const orgUnitUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateOrgUnitSchema, +}); + +type OrgUnitListInput = z.infer; +type OrgUnitTreeInput = z.infer; +type OrgUnitIdInput = z.infer; +type OrgUnitIdentifierInput = z.infer; +type CreateOrgUnitInput = z.infer; +type UpdateOrgUnitInput = z.infer; + +export async function listOrgUnits(ctx: OrgUnitProcedureContext, input: OrgUnitListInput) { + return ctx.db.orgUnit.findMany({ + where: buildOrgUnitListWhere(input ?? {}), + orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], + }); +} + +export async function getOrgUnitTree(ctx: OrgUnitProcedureContext, input: OrgUnitTreeInput) { + const all = await ctx.db.orgUnit.findMany({ + where: buildOrgUnitListWhere({ isActive: input?.isActive }), + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); + + return buildOrgUnitTree(all); +} + +export async function getOrgUnitById(ctx: OrgUnitProcedureContext, input: OrgUnitIdInput) { + return findUniqueOrThrow( + ctx.db.orgUnit.findUnique({ + where: { id: input.id }, + include: { + parent: true, + children: { orderBy: { sortOrder: "asc" } }, + _count: { select: { resources: true } }, + }, + }), + "Org unit", + ); +} + +export async function resolveOrgUnitByIdentifier( + ctx: OrgUnitProcedureContext, + input: OrgUnitIdentifierInput, +) { + return findOrgUnitByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + shortName: true, + level: true, + isActive: true, + }, + }); +} + +export async function getOrgUnitByIdentifier( + ctx: OrgUnitProcedureContext, + input: OrgUnitIdentifierInput, +) { + return findOrgUnitByIdentifier(ctx.db, input.identifier, { + include: { _count: { select: { resources: true } } }, + }); +} + +export async function createOrgUnit(ctx: OrgUnitProcedureContext, input: CreateOrgUnitInput) { + if (input.parentId) { + const parent = await findUniqueOrThrow( + ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }), + "Parent org unit", + ); + assertOrgUnitParentLevel(parent, input.level); + } + + const created = await ctx.db.orgUnit.create({ + data: buildOrgUnitCreateData(input), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: created.id, + entityName: created.name, + action: "CREATE", + ...withAuditUser(ctx.dbUser?.id), + after: created as unknown as Record, + source: "ui", + }); + + return created; +} + +export async function updateOrgUnit(ctx: OrgUnitProcedureContext, input: UpdateOrgUnitInput) { + const existing = await findUniqueOrThrow( + ctx.db.orgUnit.findUnique({ where: { id: input.id } }), + "Org unit", + ); + + const before = existing as unknown as Record; + + const updated = await ctx.db.orgUnit.update({ + where: { id: input.id }, + data: buildOrgUnitUpdateData(input.data), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deactivateOrgUnit(ctx: OrgUnitProcedureContext, input: OrgUnitIdInput) { + const updated = await ctx.db.orgUnit.update({ + where: { id: input.id }, + data: { isActive: false }, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "OrgUnit", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: { isActive: true }, + after: { isActive: false }, + source: "ui", + summary: "Deactivated OrgUnit", + }); + + return updated; +} diff --git a/packages/api/src/router/org-unit.ts b/packages/api/src/router/org-unit.ts index 8584bed..bbf9cbb 100644 --- a/packages/api/src/router/org-unit.ts +++ b/packages/api/src/router/org-unit.ts @@ -1,7 +1,3 @@ -import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@capakraken/shared"; -import { z } from "zod"; -import { findUniqueOrThrow } from "../db/helpers.js"; -import { createAuditEntry } from "../lib/audit.js"; import { adminProcedure, createTRPCRouter, @@ -9,175 +5,52 @@ import { resourceOverviewProcedure, } from "../trpc.js"; import { - assertOrgUnitParentLevel, - buildOrgUnitCreateData, - buildOrgUnitListWhere, - buildOrgUnitTree, - buildOrgUnitUpdateData, - findOrgUnitByIdentifier, -} from "./org-unit-support.js"; - -type OrgUnitIdentifierReadModel = { - id: string; - name: string; - shortName: string | null; - level: number; - isActive: boolean; -}; - -type OrgUnitDetailReadModel = OrgUnitIdentifierReadModel & { - parentId: string | null; - sortOrder: number; - createdAt: Date; - updatedAt: Date; - _count: { resources: number }; -}; + createOrgUnit, + deactivateOrgUnit, + getOrgUnitById, + getOrgUnitByIdentifier, + getOrgUnitTree, + listOrgUnits, + orgUnitIdInputSchema, + orgUnitIdentifierInputSchema, + orgUnitListInputSchema, + orgUnitTreeInputSchema, + orgUnitUpdateInputSchema, + resolveOrgUnitByIdentifier, + updateOrgUnit, +} from "./org-unit-procedure-support.js"; +import { CreateOrgUnitSchema } from "@capakraken/shared"; export const orgUnitRouter = createTRPCRouter({ list: resourceOverviewProcedure - .input( - z.object({ - level: z.number().int().min(5).max(7).optional(), - parentId: z.string().optional(), - isActive: z.boolean().optional(), - }).optional(), - ) - .query(async ({ ctx, input }) => { - return ctx.db.orgUnit.findMany({ - where: buildOrgUnitListWhere(input ?? {}), - orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], - }); - }), + .input(orgUnitListInputSchema) + .query(({ ctx, input }) => listOrgUnits(ctx, input)), getTree: resourceOverviewProcedure - .input(z.object({ isActive: z.boolean().optional() }).optional()) - .query(async ({ ctx, input }) => { - const all = await ctx.db.orgUnit.findMany({ - where: buildOrgUnitListWhere({ isActive: input?.isActive }), - orderBy: [{ sortOrder: "asc" }, { name: "asc" }], - }); - return buildOrgUnitTree(all); - }), + .input(orgUnitTreeInputSchema) + .query(({ ctx, input }) => getOrgUnitTree(ctx, input)), getById: resourceOverviewProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const unit = await findUniqueOrThrow( - ctx.db.orgUnit.findUnique({ - where: { id: input.id }, - include: { - parent: true, - children: { orderBy: { sortOrder: "asc" } }, - _count: { select: { resources: true } }, - }, - }), - "Org unit", - ); - return unit; - }), + .input(orgUnitIdInputSchema) + .query(({ ctx, input }) => getOrgUnitById(ctx, input)), resolveByIdentifier: protectedProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - return findOrgUnitByIdentifier(ctx.db, input.identifier, { - select: { - id: true, - name: true, - shortName: true, - level: true, - isActive: true, - }, - }); - }), + .input(orgUnitIdentifierInputSchema) + .query(({ ctx, input }) => resolveOrgUnitByIdentifier(ctx, input)), getByIdentifier: resourceOverviewProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - return findOrgUnitByIdentifier(ctx.db, input.identifier, { - include: { _count: { select: { resources: true } } }, - }); - }), + .input(orgUnitIdentifierInputSchema) + .query(({ ctx, input }) => getOrgUnitByIdentifier(ctx, input)), create: adminProcedure .input(CreateOrgUnitSchema) - .mutation(async ({ ctx, input }) => { - if (input.parentId) { - const parent = await findUniqueOrThrow( - ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }), - "Parent org unit", - ); - assertOrgUnitParentLevel(parent, input.level); - } - - const created = await ctx.db.orgUnit.create({ - data: buildOrgUnitCreateData(input), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "OrgUnit", - entityId: created.id, - entityName: created.name, - action: "CREATE", - userId: ctx.dbUser?.id, - after: created as unknown as Record, - source: "ui", - }); - - return created; - }), + .mutation(({ ctx, input }) => createOrgUnit(ctx, input)), update: adminProcedure - .input(z.object({ id: z.string(), data: UpdateOrgUnitSchema })) - .mutation(async ({ ctx, input }) => { - const existing = await findUniqueOrThrow( - ctx.db.orgUnit.findUnique({ where: { id: input.id } }), - "Org unit", - ); - - const before = existing as unknown as Record; - - const updated = await ctx.db.orgUnit.update({ - where: { id: input.id }, - data: buildOrgUnitUpdateData(input.data), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "OrgUnit", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(orgUnitUpdateInputSchema) + .mutation(({ ctx, input }) => updateOrgUnit(ctx, input)), deactivate: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const updated = await ctx.db.orgUnit.update({ - where: { id: input.id }, - data: { isActive: false }, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "OrgUnit", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: { isActive: true }, - after: { isActive: false }, - source: "ui", - summary: "Deactivated OrgUnit", - }); - - return updated; - }), + .input(orgUnitIdInputSchema) + .mutation(({ ctx, input }) => deactivateOrgUnit(ctx, input)), });