diff --git a/packages/api/src/__tests__/org-unit-support.test.ts b/packages/api/src/__tests__/org-unit-support.test.ts new file mode 100644 index 0000000..8a0c1d0 --- /dev/null +++ b/packages/api/src/__tests__/org-unit-support.test.ts @@ -0,0 +1,121 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + assertOrgUnitParentLevel, + buildOrgUnitCreateData, + buildOrgUnitListWhere, + buildOrgUnitTree, + buildOrgUnitUpdateData, + findOrgUnitByIdentifier, +} from "../router/org-unit-support.js"; + +describe("org-unit support", () => { + it("builds list filters", () => { + expect(buildOrgUnitListWhere({ + level: 5, + parentId: "root", + isActive: true, + })).toEqual({ + level: 5, + parentId: "root", + isActive: true, + }); + }); + + it("builds a sorted org-unit tree", () => { + expect(buildOrgUnitTree([ + { + id: "child_b", + name: "Beta", + shortName: "BET", + level: 6, + parentId: "root", + sortOrder: 20, + isActive: true, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + { + id: "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: "child_a", + name: "Alpha", + shortName: "ALP", + level: 6, + parentId: "root", + sortOrder: 20, + isActive: true, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + updatedAt: new Date("2026-03-01T00:00:00.000Z"), + }, + ])).toEqual([ + expect.objectContaining({ + id: "root", + children: [ + expect.objectContaining({ id: "child_a", children: [] }), + expect.objectContaining({ id: "child_b", children: [] }), + ], + }), + ]); + }); + + it("resolves org units by short name fallback", async () => { + const db = { + orgUnit: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: "ou_1", shortName: "DEL" }), + }, + } as never; + + const result = await findOrgUnitByIdentifier<{ id: string; shortName: string }>( + db, + " del ", + { select: { id: true, shortName: true } }, + ); + + expect(result).toEqual({ id: "ou_1", shortName: "DEL" }); + expect(db.orgUnit.findFirst).toHaveBeenNthCalledWith(2, { + where: { shortName: { equals: "del", mode: "insensitive" } }, + select: { id: true, shortName: true }, + }); + }); + + it("rejects invalid parent/child level combinations", () => { + expect(() => assertOrgUnitParentLevel({ level: 6 }, 6)).toThrow(TRPCError); + }); + + it("builds create and sparse update payloads", () => { + expect(buildOrgUnitCreateData({ + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: "root", + sortOrder: 10, + })).toEqual({ + name: "Delivery", + shortName: "DEL", + level: 5, + parentId: "root", + sortOrder: 10, + }); + + expect(buildOrgUnitUpdateData({ + shortName: null, + isActive: false, + })).toEqual({ + shortName: null, + isActive: false, + }); + }); +}); diff --git a/packages/api/src/router/org-unit-support.ts b/packages/api/src/router/org-unit-support.ts new file mode 100644 index 0000000..820bcbe --- /dev/null +++ b/packages/api/src/router/org-unit-support.ts @@ -0,0 +1,138 @@ +import type { Prisma, PrismaClient } from "@capakraken/db"; +import { CreateOrgUnitSchema, UpdateOrgUnitSchema, type OrgUnitTree } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type OrgUnitDb = Pick; + +type OrgUnitListInput = { + level?: number | undefined; + parentId?: string | undefined; + isActive?: boolean | undefined; +}; + +type OrgUnitTreeNode = { + id: string; + name: string; + shortName: string | null; + level: number; + parentId: string | null; + sortOrder: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +}; + +type ParentOrgUnitRecord = { + level: number; +}; + +type CreateOrgUnitInput = z.infer; +type UpdateOrgUnitInput = z.infer; + +export function buildOrgUnitListWhere( + input: OrgUnitListInput, +): Prisma.OrgUnitWhereInput { + return { + ...(input.level !== undefined ? { level: input.level } : {}), + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export function buildOrgUnitTree( + flatItems: OrgUnitTreeNode[], + parentId: string | null = null, +): OrgUnitTree[] { + return flatItems + .filter((item) => item.parentId === parentId) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) + .map((item) => ({ + ...item, + children: buildOrgUnitTree(flatItems, item.id), + })); +} + +export async function findOrgUnitByIdentifier( + db: OrgUnitDb, + identifier: string, + extraArgs: Record, +): Promise { + const normalizedIdentifier = identifier.trim(); + + let unit = await db.orgUnit.findUnique({ + where: { id: normalizedIdentifier }, + ...extraArgs, + }) as TOrgUnit | null; + + if (!unit) { + unit = await db.orgUnit.findFirst({ + where: { name: { equals: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TOrgUnit | null; + } + + if (!unit) { + unit = await db.orgUnit.findFirst({ + where: { shortName: { equals: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TOrgUnit | null; + } + + if (!unit) { + unit = await db.orgUnit.findFirst({ + where: { + OR: [ + { name: { contains: normalizedIdentifier, mode: "insensitive" } }, + { shortName: { contains: normalizedIdentifier, mode: "insensitive" } }, + ], + }, + ...extraArgs, + }) as TOrgUnit | null; + } + + if (!unit) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Org unit not found: ${normalizedIdentifier}`, + }); + } + + return unit; +} + +export function assertOrgUnitParentLevel( + parent: ParentOrgUnitRecord, + childLevel: number, +): void { + if (parent.level >= childLevel) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Child level (${childLevel}) must be greater than parent level (${parent.level})`, + }); + } +} + +export function buildOrgUnitCreateData( + input: CreateOrgUnitInput, +): Prisma.OrgUnitUncheckedCreateInput { + return { + name: input.name, + ...(input.shortName !== undefined ? { shortName: input.shortName } : {}), + level: input.level, + ...(input.parentId ? { parentId: input.parentId } : {}), + sortOrder: input.sortOrder, + }; +} + +export function buildOrgUnitUpdateData( + input: UpdateOrgUnitInput, +): Prisma.OrgUnitUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.shortName !== undefined ? { shortName: input.shortName } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + }; +} diff --git a/packages/api/src/router/org-unit.ts b/packages/api/src/router/org-unit.ts index 341f8c7..8584bed 100644 --- a/packages/api/src/router/org-unit.ts +++ b/packages/api/src/router/org-unit.ts @@ -1,5 +1,4 @@ import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; @@ -9,30 +8,30 @@ import { protectedProcedure, resourceOverviewProcedure, } from "../trpc.js"; +import { + assertOrgUnitParentLevel, + buildOrgUnitCreateData, + buildOrgUnitListWhere, + buildOrgUnitTree, + buildOrgUnitUpdateData, + findOrgUnitByIdentifier, +} from "./org-unit-support.js"; -import type { OrgUnitTree } from "@capakraken/shared"; - -interface FlatOrgUnit { +type OrgUnitIdentifierReadModel = { id: string; name: string; shortName: string | null; level: number; + isActive: boolean; +}; + +type OrgUnitDetailReadModel = OrgUnitIdentifierReadModel & { parentId: string | null; sortOrder: number; - isActive: boolean; createdAt: Date; updatedAt: Date; -} - -function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): OrgUnitTree[] { - return flatItems - .filter((item) => item.parentId === parentId) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((item) => ({ - ...item, - children: buildTree(flatItems, item.id), - })); -} + _count: { resources: number }; +}; export const orgUnitRouter = createTRPCRouter({ list: resourceOverviewProcedure @@ -45,11 +44,7 @@ export const orgUnitRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { return ctx.db.orgUnit.findMany({ - where: { - ...(input?.level !== undefined ? { level: input.level } : {}), - ...(input?.parentId !== undefined ? { parentId: input.parentId } : {}), - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - }, + where: buildOrgUnitListWhere(input ?? {}), orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }], }); }), @@ -58,12 +53,10 @@ export const orgUnitRouter = createTRPCRouter({ .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { const all = await ctx.db.orgUnit.findMany({ - where: { - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - }, + where: buildOrgUnitListWhere({ isActive: input?.isActive }), orderBy: [{ sortOrder: "asc" }, { name: "asc" }], }); - return buildTree(all); + return buildOrgUnitTree(all); }), getById: resourceOverviewProcedure @@ -86,93 +79,23 @@ export const orgUnitRouter = createTRPCRouter({ resolveByIdentifier: protectedProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - const select = { - id: true, - name: true, - shortName: true, - level: true, - isActive: true, - } as const; - - let unit = await ctx.db.orgUnit.findUnique({ - where: { id: identifier }, - select, + return findOrgUnitByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + name: true, + shortName: true, + level: true, + isActive: true, + }, }); - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { shortName: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { - OR: [ - { name: { contains: identifier, mode: "insensitive" } }, - { shortName: { contains: identifier, mode: "insensitive" } }, - ], - }, - select, - }); - } - - if (!unit) { - throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` }); - } - - return unit; }), getByIdentifier: resourceOverviewProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - let unit = await ctx.db.orgUnit.findUnique({ - where: { id: identifier }, + return findOrgUnitByIdentifier(ctx.db, input.identifier, { include: { _count: { select: { resources: true } } }, }); - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - include: { _count: { select: { resources: true } } }, - }); - } - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { shortName: { equals: identifier, mode: "insensitive" } }, - include: { _count: { select: { resources: true } } }, - }); - } - - if (!unit) { - unit = await ctx.db.orgUnit.findFirst({ - where: { - OR: [ - { name: { contains: identifier, mode: "insensitive" } }, - { shortName: { contains: identifier, mode: "insensitive" } }, - ], - }, - include: { _count: { select: { resources: true } } }, - }); - } - - if (!unit) { - throw new TRPCError({ code: "NOT_FOUND", message: `Org unit not found: ${identifier}` }); - } - - return unit; }), create: adminProcedure @@ -183,22 +106,11 @@ export const orgUnitRouter = createTRPCRouter({ ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }), "Parent org unit", ); - if (parent.level >= input.level) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Child level (${input.level}) must be greater than parent level (${parent.level})`, - }); - } + assertOrgUnitParentLevel(parent, input.level); } const created = await ctx.db.orgUnit.create({ - data: { - name: input.name, - ...(input.shortName !== undefined ? { shortName: input.shortName } : {}), - level: input.level, - ...(input.parentId ? { parentId: input.parentId } : {}), - sortOrder: input.sortOrder, - }, + data: buildOrgUnitCreateData(input), }); void createAuditEntry({ @@ -227,13 +139,7 @@ export const orgUnitRouter = createTRPCRouter({ const updated = await ctx.db.orgUnit.update({ where: { id: input.id }, - data: { - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.shortName !== undefined ? { shortName: input.data.shortName } : {}), - ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - ...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}), - }, + data: buildOrgUnitUpdateData(input.data), }); void createAuditEntry({