From 740f2c00aa5c3634fc4e82effd022b81589655cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 13:54:22 +0200 Subject: [PATCH] refactor(api): extract country router support --- .../api/src/__tests__/country-support.test.ts | 102 +++++++++++ packages/api/src/router/country-support.ts | 151 +++++++++++++++++ packages/api/src/router/country.ts | 158 +++++------------- 3 files changed, 294 insertions(+), 117 deletions(-) create mode 100644 packages/api/src/__tests__/country-support.test.ts create mode 100644 packages/api/src/router/country-support.ts diff --git a/packages/api/src/__tests__/country-support.test.ts b/packages/api/src/__tests__/country-support.test.ts new file mode 100644 index 0000000..58d38f2 --- /dev/null +++ b/packages/api/src/__tests__/country-support.test.ts @@ -0,0 +1,102 @@ +import { TRPCError } from "@trpc/server"; +import { describe, expect, it, vi } from "vitest"; +import { + assertCountryCodeAvailable, + assertMetroCityDeletable, + buildCountryCreateData, + buildCountryListWhere, + buildCountryUpdateData, + buildMetroCityCreateData, + buildMetroCityUpdateData, + findCountryByIdentifier, + jsonOrNull, +} from "../router/country-support.js"; + +describe("country support", () => { + it("builds list filters", () => { + expect(buildCountryListWhere({ isActive: true })).toEqual({ + isActive: true, + }); + }); + + it("resolves countries by code before fuzzy name fallback", async () => { + const db = { + country: { + findUnique: vi.fn().mockResolvedValue(null), + findFirst: vi.fn() + .mockResolvedValueOnce({ id: "country_de", code: "DE" }), + }, + } as never; + + const result = await findCountryByIdentifier<{ id: string; code: string }>( + db, + " de ", + { select: { id: true, code: true } }, + ); + + expect(result).toEqual({ id: "country_de", code: "DE" }); + expect(db.country.findFirst).toHaveBeenNthCalledWith(1, { + where: { code: { equals: "DE", mode: "insensitive" } }, + select: { id: true, code: true }, + }); + }); + + it("rejects duplicate country codes outside the ignored id", async () => { + const db = { + country: { + findUnique: vi.fn().mockResolvedValue({ id: "country_existing", code: "DE" }), + }, + } as never; + + await expect(assertCountryCodeAvailable(db, "DE")).rejects.toBeInstanceOf(TRPCError); + }); + + it("builds create and sparse update payloads", () => { + expect(buildCountryCreateData({ + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: [{ weekday: 1 }], + })).toEqual({ + code: "DE", + name: "Germany", + dailyWorkingHours: 8, + scheduleRules: [{ weekday: 1 }], + }); + + expect(buildCountryUpdateData({ + dailyWorkingHours: 7.5, + scheduleRules: null, + isActive: false, + })).toEqual({ + dailyWorkingHours: 7.5, + scheduleRules: expect.anything(), + isActive: false, + }); + + expect(buildMetroCityCreateData({ + name: "Berlin", + countryId: "country_de", + })).toEqual({ + name: "Berlin", + countryId: "country_de", + }); + + expect(buildMetroCityUpdateData({ + name: "Munich", + })).toEqual({ + name: "Munich", + }); + }); + + it("maps nullish schedule rules to Prisma.JsonNull", () => { + expect(jsonOrNull(null)).toBeTypeOf("object"); + expect(jsonOrNull(undefined)).toBeTypeOf("object"); + }); + + it("rejects metro-city deletion while resources are assigned", () => { + expect(() => assertMetroCityDeletable({ + _count: { resources: 1 }, + })).toThrow(TRPCError); + }); +}); diff --git a/packages/api/src/router/country-support.ts b/packages/api/src/router/country-support.ts new file mode 100644 index 0000000..7f7b804 --- /dev/null +++ b/packages/api/src/router/country-support.ts @@ -0,0 +1,151 @@ +import { Prisma, type PrismaClient } from "@capakraken/db"; +import { + CreateCountrySchema, + CreateMetroCitySchema, + UpdateCountrySchema, + UpdateMetroCitySchema, +} from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +type CountryDb = Pick; +type MetroCityDb = Pick; + +type CountryListInput = { + isActive?: boolean | undefined; +}; + +type MetroCityDeleteRecord = { + _count: { + resources: number; + }; +}; + +type CreateCountryInput = z.infer; +type UpdateCountryInput = z.infer; +type CreateMetroCityInput = z.infer; +type UpdateMetroCityInput = z.infer; + +export function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { + if (val === null || val === undefined) return Prisma.JsonNull; + return val as Prisma.InputJsonValue; +} + +export function buildCountryListWhere( + input: CountryListInput, +): Prisma.CountryWhereInput { + return { + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export async function findCountryByIdentifier( + db: CountryDb, + identifier: string, + extraArgs: Record, +): Promise { + const normalizedIdentifier = identifier.trim(); + const upperIdentifier = normalizedIdentifier.toUpperCase(); + + let country = await db.country.findUnique({ + where: { id: normalizedIdentifier }, + ...extraArgs, + }) as TCountry | null; + + if (!country) { + country = await db.country.findFirst({ + where: { code: { equals: upperIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TCountry | null; + } + + if (!country) { + country = await db.country.findFirst({ + where: { name: { equals: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TCountry | null; + } + + if (!country) { + country = await db.country.findFirst({ + where: { name: { contains: normalizedIdentifier, mode: "insensitive" } }, + ...extraArgs, + }) as TCountry | null; + } + + if (!country) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Country not found: ${normalizedIdentifier}`, + }); + } + + return country; +} + +export async function assertCountryCodeAvailable( + db: CountryDb, + code: string, + ignoreId?: string, +): Promise { + const existing = await db.country.findUnique({ where: { code } }); + if (existing && existing.id !== ignoreId) { + throw new TRPCError({ + code: "CONFLICT", + message: `Country code "${code}" already exists`, + }); + } +} + +export function buildCountryCreateData( + input: CreateCountryInput, +): Prisma.CountryUncheckedCreateInput { + return { + code: input.code, + name: input.name, + dailyWorkingHours: input.dailyWorkingHours, + ...(input.scheduleRules !== undefined + ? { scheduleRules: jsonOrNull(input.scheduleRules) } + : {}), + }; +} + +export function buildCountryUpdateData( + input: UpdateCountryInput, +): Prisma.CountryUncheckedUpdateInput { + return { + ...(input.code !== undefined ? { code: input.code } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.dailyWorkingHours } : {}), + ...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}), + ...(input.isActive !== undefined ? { isActive: input.isActive } : {}), + }; +} + +export function buildMetroCityCreateData( + input: CreateMetroCityInput, +): Prisma.MetroCityUncheckedCreateInput { + return { + name: input.name, + countryId: input.countryId, + }; +} + +export function buildMetroCityUpdateData( + input: UpdateMetroCityInput, +): Prisma.MetroCityUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + }; +} + +export function assertMetroCityDeletable( + city: MetroCityDeleteRecord, +): void { + if (city._count.resources > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete metro city assigned to ${city._count.resources} resource(s)`, + }); + } +} diff --git a/packages/api/src/router/country.ts b/packages/api/src/router/country.ts index 06f8cc8..6ec6847 100644 --- a/packages/api/src/router/country.ts +++ b/packages/api/src/router/country.ts @@ -4,8 +4,7 @@ import { UpdateCountrySchema, UpdateMetroCitySchema, } from "@capakraken/shared"; -import { Prisma } from "@capakraken/db"; -import { TRPCError } from "@trpc/server"; +import type { Prisma } from "@capakraken/db"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; @@ -15,21 +14,37 @@ import { protectedProcedure, resourceOverviewProcedure, } from "../trpc.js"; +import { + assertCountryCodeAvailable, + assertMetroCityDeletable, + buildCountryCreateData, + buildCountryListWhere, + buildCountryUpdateData, + buildMetroCityCreateData, + buildMetroCityUpdateData, + findCountryByIdentifier, +} from "./country-support.js"; -/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */ -function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { - if (val === null || val === undefined) return Prisma.JsonNull; - return val as Prisma.InputJsonValue; -} +type CountryIdentifierReadModel = { + id: string; + code: string; + name: string; + isActive: boolean; + dailyWorkingHours: number; +}; + +type CountryDetailReadModel = CountryIdentifierReadModel & { + scheduleRules?: Prisma.JsonValue | null; + metroCities: Array<{ id: string; name: string; countryId: string }>; + _count: { resources: number }; +}; export const countryRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ isActive: z.boolean().optional() }).optional()) .query(async ({ ctx, input }) => { return ctx.db.country.findMany({ - where: { - ...(input?.isActive !== undefined ? { isActive: input.isActive } : {}), - }, + where: buildCountryListWhere(input ?? {}), include: { metroCities: { orderBy: { name: "asc" } } }, orderBy: { name: "asc" }, }); @@ -38,95 +53,26 @@ export const countryRouter = 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, - code: true, - name: true, - isActive: true, - dailyWorkingHours: true, - } as const; - - let country = await ctx.db.country.findUnique({ - where: { id: identifier }, - select, + return findCountryByIdentifier(ctx.db, input.identifier, { + select: { + id: true, + code: true, + name: true, + isActive: true, + dailyWorkingHours: true, + }, }); - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } }, - select, - }); - } - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - select, - }); - } - - if (!country) { - throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` }); - } - - return country; }), getByIdentifier: resourceOverviewProcedure .input(z.object({ identifier: z.string().trim().min(1) })) .query(async ({ ctx, input }) => { - const identifier = input.identifier.trim(); - let country = await ctx.db.country.findUnique({ - where: { id: identifier }, + return findCountryByIdentifier(ctx.db, input.identifier, { include: { metroCities: { orderBy: { name: "asc" } }, _count: { select: { resources: true } }, }, }); - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { code: { equals: identifier.toUpperCase(), mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { name: { equals: identifier, mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - - if (!country) { - country = await ctx.db.country.findFirst({ - where: { name: { contains: identifier, mode: "insensitive" } }, - include: { - metroCities: { orderBy: { name: "asc" } }, - _count: { select: { resources: true } }, - }, - }); - } - - if (!country) { - throw new TRPCError({ code: "NOT_FOUND", message: `Country not found: ${identifier}` }); - } - - return country; }), getById: resourceOverviewProcedure @@ -161,17 +107,9 @@ export const countryRouter = createTRPCRouter({ create: adminProcedure .input(CreateCountrySchema) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.country.findUnique({ where: { code: input.code } }); - if (existing) { - throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.code}" already exists` }); - } + await assertCountryCodeAvailable(ctx.db, input.code); const created = await ctx.db.country.create({ - data: { - code: input.code, - name: input.name, - dailyWorkingHours: input.dailyWorkingHours, - ...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}), - }, + data: buildCountryCreateData(input), include: { metroCities: true }, }); @@ -198,23 +136,14 @@ export const countryRouter = createTRPCRouter({ ); if (input.data.code && input.data.code !== existing.code) { - const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } }); - if (conflict) { - throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.data.code}" already exists` }); - } + await assertCountryCodeAvailable(ctx.db, input.data.code, existing.id); } const before = existing as unknown as Record; const updated = await ctx.db.country.update({ where: { id: input.id }, - data: { - ...(input.data.code !== undefined ? { code: input.data.code } : {}), - ...(input.data.name !== undefined ? { name: input.data.name } : {}), - ...(input.data.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.data.dailyWorkingHours } : {}), - ...(input.data.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.data.scheduleRules) } : {}), - ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), - }, + data: buildCountryUpdateData(input.data), include: { metroCities: true }, }); @@ -244,7 +173,7 @@ export const countryRouter = createTRPCRouter({ ); const created = await ctx.db.metroCity.create({ - data: { name: input.name, countryId: input.countryId }, + data: buildMetroCityCreateData(input), }); void createAuditEntry({ @@ -272,7 +201,7 @@ export const countryRouter = createTRPCRouter({ const updated = await ctx.db.metroCity.update({ where: { id: input.id }, - data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}) }, + data: buildMetroCityUpdateData(input.data), }); void createAuditEntry({ @@ -300,12 +229,7 @@ export const countryRouter = createTRPCRouter({ }), "Metro city", ); - if (city._count.resources > 0) { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: `Cannot delete metro city assigned to ${city._count.resources} resource(s)`, - }); - } + assertMetroCityDeletable(city); await ctx.db.metroCity.delete({ where: { id: input.id } }); void createAuditEntry({