From 0a10a440eefa725d68205ad8160d02eb0b3c0742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 13:25:27 +0200 Subject: [PATCH] refactor(api): extract holiday calendar write support --- .../holiday-calendar-write-support.test.ts | 119 +++++++++++++ .../router/holiday-calendar-write-support.ts | 158 +++++++++++++++++ packages/api/src/router/holiday-calendar.ts | 166 ++++-------------- 3 files changed, 308 insertions(+), 135 deletions(-) create mode 100644 packages/api/src/__tests__/holiday-calendar-write-support.test.ts create mode 100644 packages/api/src/router/holiday-calendar-write-support.ts diff --git a/packages/api/src/__tests__/holiday-calendar-write-support.test.ts b/packages/api/src/__tests__/holiday-calendar-write-support.test.ts new file mode 100644 index 0000000..4143062 --- /dev/null +++ b/packages/api/src/__tests__/holiday-calendar-write-support.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { + assertHolidayCalendarEntryDateAvailable, + assertHolidayCalendarScopeConsistency, + clampHolidayCalendarDate, + normalizeHolidayCalendarScopeInput, + normalizeHolidayCalendarStateCode, + resolveHolidayCalendarUpdateScope, +} from "../router/holiday-calendar-write-support.js"; + +describe("holiday calendar write support", () => { + it("normalizes state codes and optional scope fields", () => { + expect(normalizeHolidayCalendarStateCode(" by ")).toBe("BY"); + expect(normalizeHolidayCalendarStateCode(undefined)).toBeNull(); + expect(normalizeHolidayCalendarScopeInput({ + stateCode: " ny ", + metroCityId: undefined, + })).toEqual({ + stateCode: "NY", + metroCityId: null, + }); + }); + + it("reuses existing scope values for partial updates", () => { + expect(resolveHolidayCalendarUpdateScope({ + existing: { + stateCode: "BY", + metroCityId: "city_1", + }, + data: { + stateCode: undefined, + metroCityId: null, + }, + })).toEqual({ + stateCode: "BY", + metroCityId: null, + }); + }); + + it("clamps dates to UTC midnight", () => { + expect(clampHolidayCalendarDate(new Date("2026-12-24T18:45:00.000Z")).toISOString()) + .toBe("2026-12-24T00:00:00.000Z"); + }); + + it("rejects invalid state and city scope combinations before hitting uniqueness checks", async () => { + const db = { + metroCity: { findUnique: vi.fn() }, + holidayCalendar: { findFirst: vi.fn() }, + } as never; + + await expect(assertHolidayCalendarScopeConsistency(db, { + scopeType: "STATE", + countryId: "country_de", + stateCode: null, + metroCityId: null, + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "State calendars require a state code", + }); + + await expect(assertHolidayCalendarScopeConsistency(db, { + scopeType: "CITY", + countryId: "country_de", + stateCode: null, + metroCityId: null, + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "City calendars require a metro city", + }); + + expect(db.holidayCalendar.findFirst).not.toHaveBeenCalled(); + }); + + it("rejects city scopes whose metro city belongs to a different country", async () => { + const db = { + metroCity: { + findUnique: vi.fn().mockResolvedValue({ + id: "city_augsburg", + countryId: "country_us", + }), + }, + holidayCalendar: { findFirst: vi.fn() }, + } as never; + + await expect(assertHolidayCalendarScopeConsistency(db, { + scopeType: "CITY", + countryId: "country_de", + stateCode: null, + metroCityId: "city_augsburg", + })).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "Metro city must belong to the selected country", + }); + }); + + it("rejects duplicate holiday entry dates using the clamped date", async () => { + const db = { + holidayCalendarEntry: { + findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }), + }, + } as never; + + await expect(assertHolidayCalendarEntryDateAvailable(db, { + holidayCalendarId: "cal_1", + date: new Date("2026-12-24T18:45:00.000Z"), + })).rejects.toMatchObject({ + code: "CONFLICT", + message: "A holiday entry for this calendar and date already exists", + }); + + expect(db.holidayCalendarEntry.findFirst).toHaveBeenCalledWith({ + where: { + holidayCalendarId: "cal_1", + date: new Date("2026-12-24T00:00:00.000Z"), + }, + select: { id: true }, + }); + }); +}); diff --git a/packages/api/src/router/holiday-calendar-write-support.ts b/packages/api/src/router/holiday-calendar-write-support.ts new file mode 100644 index 0000000..7038f99 --- /dev/null +++ b/packages/api/src/router/holiday-calendar-write-support.ts @@ -0,0 +1,158 @@ +import { TRPCError } from "@trpc/server"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { + type HolidayCalendarDb, +} from "./holiday-calendar-shared.js"; +import type { HolidayCalendarScopeInput } from "@capakraken/shared"; + +type HolidayCalendarScope = HolidayCalendarScopeInput; + +const HOLIDAY_SCOPE = { + COUNTRY: "COUNTRY", + STATE: "STATE", + CITY: "CITY", +} as const satisfies Record; + +export function clampHolidayCalendarDate(date: Date): Date { + const value = new Date(date); + value.setUTCHours(0, 0, 0, 0); + return value; +} + +export function normalizeHolidayCalendarStateCode( + stateCode: string | null | undefined, +): string | null { + return stateCode?.trim().toUpperCase() ?? null; +} + +export function normalizeHolidayCalendarScopeInput(input: { + stateCode?: string | null | undefined; + metroCityId?: string | null | undefined; +}) { + return { + stateCode: normalizeHolidayCalendarStateCode(input.stateCode), + metroCityId: input.metroCityId ?? null, + }; +} + +export function resolveHolidayCalendarUpdateScope(input: { + existing: { + stateCode: string | null; + metroCityId: string | null; + }; + data: { + stateCode?: string | null | undefined; + metroCityId?: string | null | undefined; + }; +}) { + return { + stateCode: input.data.stateCode === undefined + ? input.existing.stateCode + : normalizeHolidayCalendarStateCode(input.data.stateCode), + metroCityId: input.data.metroCityId === undefined + ? input.existing.metroCityId + : input.data.metroCityId ?? null, + }; +} + +export async function assertHolidayCalendarEntryDateAvailable( + db: HolidayCalendarDb, + input: { + holidayCalendarId: string; + date: Date; + }, + ignoreId?: string, +) { + const existing = await db.holidayCalendarEntry.findFirst({ + where: { + holidayCalendarId: input.holidayCalendarId, + date: clampHolidayCalendarDate(input.date), + ...(ignoreId ? { id: { not: ignoreId } } : {}), + }, + select: { id: true }, + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "A holiday entry for this calendar and date already exists", + }); + } +} + +export async function assertHolidayCalendarScopeConsistency( + db: HolidayCalendarDb, + input: { + scopeType: HolidayCalendarScope; + countryId: string; + stateCode?: string | null; + metroCityId?: string | null; + }, + ignoreId?: string, +) { + if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) { + if (input.stateCode || input.metroCityId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Country calendars may not define a state or metro city", + }); + } + } + + if (input.scopeType === HOLIDAY_SCOPE.STATE) { + if (!input.stateCode) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "State calendars require a state code", + }); + } + if (input.metroCityId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "State calendars may not define a metro city", + }); + } + } + + if (input.scopeType === HOLIDAY_SCOPE.CITY) { + if (!input.metroCityId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "City calendars require a metro city", + }); + } + + const metroCity = await findUniqueOrThrow( + db.metroCity.findUnique({ + where: { id: input.metroCityId }, + select: { id: true, countryId: true }, + }), + "Metro city", + ); + + if (metroCity.countryId !== input.countryId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Metro city must belong to the selected country", + }); + } + } + + const existing = await db.holidayCalendar.findFirst({ + where: { + countryId: input.countryId, + scopeType: input.scopeType, + ...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}), + ...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}), + ...(ignoreId ? { id: { not: ignoreId } } : {}), + }, + select: { id: true }, + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "A holiday calendar for this exact scope already exists", + }); + } +} diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index 20b108b..556fecb 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -1,134 +1,23 @@ import { CreateHolidayCalendarEntrySchema, CreateHolidayCalendarSchema, - type HolidayCalendarScopeInput, UpdateHolidayCalendarEntrySchema, UpdateHolidayCalendarSchema, } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { createTRPCRouter, adminProcedure } from "../trpc.js"; import { holidayCalendarCatalogReadProcedures } from "./holiday-calendar-catalog-read.js"; import { holidayCalendarResolutionReadProcedures } from "./holiday-calendar-resolution-read.js"; -import { asHolidayCalendarDb, type HolidayCalendarDb } from "./holiday-calendar-shared.js"; - -type HolidayCalendarScope = HolidayCalendarScopeInput; - -const HOLIDAY_SCOPE = { - COUNTRY: "COUNTRY", - STATE: "STATE", - CITY: "CITY", -} as const satisfies Record; - -function clampDate(date: Date): Date { - const value = new Date(date); - value.setUTCHours(0, 0, 0, 0); - return value; -} - -async function assertEntryDateAvailable( - db: HolidayCalendarDb, - input: { - holidayCalendarId: string; - date: Date; - }, - ignoreId?: string, -) { - const existing = await db.holidayCalendarEntry.findFirst({ - where: { - holidayCalendarId: input.holidayCalendarId, - date: clampDate(input.date), - ...(ignoreId ? { id: { not: ignoreId } } : {}), - }, - select: { id: true }, - }); - - if (existing) { - throw new TRPCError({ - code: "CONFLICT", - message: "A holiday entry for this calendar and date already exists", - }); - } -} - -async function assertScopeConsistency( - db: HolidayCalendarDb, - input: { - scopeType: HolidayCalendarScope; - countryId: string; - stateCode?: string | null; - metroCityId?: string | null; - }, - ignoreId?: string, -) { - if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) { - if (input.stateCode || input.metroCityId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Country calendars may not define a state or metro city", - }); - } - } - - if (input.scopeType === HOLIDAY_SCOPE.STATE) { - if (!input.stateCode) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "State calendars require a state code", - }); - } - if (input.metroCityId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "State calendars may not define a metro city", - }); - } - } - - if (input.scopeType === HOLIDAY_SCOPE.CITY) { - if (!input.metroCityId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "City calendars require a metro city", - }); - } - - const metroCity = await findUniqueOrThrow( - db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { id: true, countryId: true }, - }), - "Metro city", - ); - - if (metroCity.countryId !== input.countryId) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Metro city must belong to the selected country", - }); - } - } - - const existing = await db.holidayCalendar.findFirst({ - where: { - countryId: input.countryId, - scopeType: input.scopeType, - ...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}), - ...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}), - ...(ignoreId ? { id: { not: ignoreId } } : {}), - }, - select: { id: true }, - }); - - if (existing) { - throw new TRPCError({ - code: "CONFLICT", - message: "A holiday calendar for this exact scope already exists", - }); - } -} +import { asHolidayCalendarDb } from "./holiday-calendar-shared.js"; +import { + assertHolidayCalendarEntryDateAvailable, + assertHolidayCalendarScopeConsistency, + clampHolidayCalendarDate, + normalizeHolidayCalendarScopeInput, + resolveHolidayCalendarUpdateScope, +} from "./holiday-calendar-write-support.js"; export const holidayCalendarRouter = createTRPCRouter({ ...holidayCalendarCatalogReadProcedures, @@ -147,11 +36,18 @@ export const holidayCalendarRouter = createTRPCRouter({ "Country", ); - await assertScopeConsistency(db, { + await assertHolidayCalendarScopeConsistency(db, { scopeType: input.scopeType, countryId: input.countryId, - stateCode: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, + ...normalizeHolidayCalendarScopeInput({ + stateCode: input.stateCode, + metroCityId: input.metroCityId, + }), + }); + + const normalizedScope = normalizeHolidayCalendarScopeInput({ + stateCode: input.stateCode, + metroCityId: input.metroCityId, }); const created = await db.holidayCalendar.create({ @@ -159,8 +55,8 @@ export const holidayCalendarRouter = createTRPCRouter({ name: input.name, scopeType: input.scopeType, countryId: input.countryId, - ...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}), - ...(input.metroCityId ? { metroCityId: input.metroCityId } : {}), + ...(normalizedScope.stateCode ? { stateCode: normalizedScope.stateCode } : {}), + ...(normalizedScope.metroCityId ? { metroCityId: normalizedScope.metroCityId } : {}), isActive: input.isActive ?? true, priority: input.priority ?? 0, }, @@ -194,14 +90,12 @@ export const holidayCalendarRouter = createTRPCRouter({ "Holiday calendar", ); - const stateCode = input.data.stateCode === undefined - ? existing.stateCode - : input.data.stateCode?.trim().toUpperCase() ?? null; - const metroCityId = input.data.metroCityId === undefined - ? existing.metroCityId - : input.data.metroCityId ?? null; + const { stateCode, metroCityId } = resolveHolidayCalendarUpdateScope({ + existing, + data: input.data, + }); - await assertScopeConsistency(db, { + await assertHolidayCalendarScopeConsistency(db, { scopeType: existing.scopeType, countryId: existing.countryId, stateCode, @@ -280,7 +174,7 @@ export const holidayCalendarRouter = createTRPCRouter({ "Holiday calendar", ); - await assertEntryDateAvailable(db, { + await assertHolidayCalendarEntryDateAvailable(db, { holidayCalendarId: input.holidayCalendarId, date: input.date, }); @@ -288,7 +182,7 @@ export const holidayCalendarRouter = createTRPCRouter({ const created = await db.holidayCalendarEntry.create({ data: { holidayCalendarId: input.holidayCalendarId, - date: clampDate(input.date), + date: clampHolidayCalendarDate(input.date), name: input.name, isRecurringAnnual: input.isRecurringAnnual ?? false, ...(input.source ? { source: input.source } : {}), @@ -317,9 +211,11 @@ export const holidayCalendarRouter = createTRPCRouter({ db.holidayCalendarEntry.findUnique({ where: { id: input.id } }), "Holiday calendar entry", ); - const nextDate = input.data.date !== undefined ? clampDate(input.data.date) : existing.date; + const nextDate = input.data.date !== undefined + ? clampHolidayCalendarDate(input.data.date) + : existing.date; - await assertEntryDateAvailable(db, { + await assertHolidayCalendarEntryDateAvailable(db, { holidayCalendarId: existing.holidayCalendarId, date: nextDate, }, existing.id);