diff --git a/packages/api/src/__tests__/holiday-calendar-procedure-support.test.ts b/packages/api/src/__tests__/holiday-calendar-procedure-support.test.ts new file mode 100644 index 0000000..8f75def --- /dev/null +++ b/packages/api/src/__tests__/holiday-calendar-procedure-support.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +import { + createHolidayCalendar, + createHolidayCalendarEntry, + deleteHolidayCalendarEntry, + updateHolidayCalendar, +} from "../router/holiday-calendar-procedure-support.js"; + +function createContext(db: Record) { + return { + db: db as never, + dbUser: { id: "user_admin" } as never, + }; +} + +describe("holiday calendar procedure support", () => { + beforeEach(() => { + createAuditEntry.mockReset(); + }); + + it("creates a holiday calendar with normalized scope and audit logging", async () => { + const countryFindUnique = vi.fn().mockResolvedValue({ + id: "country_de", + name: "Deutschland", + }); + const metroCityFindUnique = vi.fn().mockResolvedValue(null); + const holidayCalendarFindFirst = vi.fn().mockResolvedValue(null); + const holidayCalendarCreate = vi.fn().mockResolvedValue({ + id: "cal_by", + name: "Bayern Feiertage", + country: { id: "country_de", code: "DE", name: "Deutschland" }, + metroCity: null, + entries: [], + }); + + const result = await createHolidayCalendar( + createContext({ + country: { findUnique: countryFindUnique }, + metroCity: { findUnique: metroCityFindUnique }, + holidayCalendar: { + findFirst: holidayCalendarFindFirst, + create: holidayCalendarCreate, + }, + }), + { + name: "Bayern Feiertage", + scopeType: "STATE", + countryId: "country_de", + stateCode: " by ", + }, + ); + + expect(countryFindUnique).toHaveBeenCalledWith({ + where: { id: "country_de" }, + select: { id: true, name: true }, + }); + expect(holidayCalendarFindFirst).toHaveBeenCalledWith({ + where: { + scopeType: "STATE", + countryId: "country_de", + stateCode: "BY", + }, + select: { id: true }, + }); + expect(holidayCalendarCreate).toHaveBeenCalledWith({ + data: { + name: "Bayern Feiertage", + scopeType: "STATE", + countryId: "country_de", + stateCode: "BY", + isActive: true, + priority: 0, + }, + include: expect.any(Object), + }); + expect(result.id).toBe("cal_by"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "HolidayCalendar", + action: "CREATE", + entityId: "cal_by", + }), + ); + }); + + it("updates a holiday calendar with resolved partial scope values", async () => { + const holidayCalendarFindUnique = vi.fn().mockResolvedValue({ + id: "cal_city", + name: "Augsburg lokal", + scopeType: "CITY", + countryId: "country_de", + stateCode: null, + metroCityId: "city_augsburg", + }); + const holidayCalendarFindFirst = vi.fn().mockResolvedValue(null); + const metroCityFindUnique = vi.fn().mockResolvedValue({ + id: "city_augsburg", + countryId: "country_de", + }); + const holidayCalendarUpdate = vi.fn().mockResolvedValue({ + id: "cal_city", + name: "Augsburg lokal bevorzugt", + stateCode: null, + metroCityId: "city_augsburg", + country: { id: "country_de", code: "DE", name: "Deutschland" }, + metroCity: { id: "city_augsburg", name: "Augsburg" }, + entries: [], + }); + + const result = await updateHolidayCalendar( + createContext({ + holidayCalendar: { + findUnique: holidayCalendarFindUnique, + findFirst: holidayCalendarFindFirst, + update: holidayCalendarUpdate, + }, + metroCity: { findUnique: metroCityFindUnique }, + }), + { + id: "cal_city", + data: { + name: "Augsburg lokal bevorzugt", + metroCityId: undefined, + priority: 7, + }, + }, + ); + + expect(holidayCalendarFindFirst).toHaveBeenCalledWith({ + where: { + scopeType: "CITY", + countryId: "country_de", + metroCityId: "city_augsburg", + id: { not: "cal_city" }, + }, + select: { id: true }, + }); + expect(holidayCalendarUpdate).toHaveBeenCalledWith({ + where: { id: "cal_city" }, + data: { + name: "Augsburg lokal bevorzugt", + priority: 7, + }, + include: expect.any(Object), + }); + expect(result.name).toBe("Augsburg lokal bevorzugt"); + expect(createAuditEntry).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: "HolidayCalendar", + action: "UPDATE", + entityId: "cal_city", + }), + ); + }); + + it("creates and deletes holiday-calendar entries with clamped dates", async () => { + const holidayCalendarFindUnique = vi.fn().mockResolvedValue({ + id: "cal_de", + name: "Deutschland", + }); + const holidayCalendarEntryFindFirst = vi.fn().mockResolvedValue(null); + const holidayCalendarEntryCreate = vi.fn().mockResolvedValue({ + id: "entry_1", + holidayCalendarId: "cal_de", + name: "Neujahr", + date: new Date("2026-01-01T00:00:00.000Z"), + }); + const holidayCalendarEntryFindUnique = vi.fn().mockResolvedValue({ + id: "entry_1", + holidayCalendarId: "cal_de", + name: "Neujahr", + date: new Date("2026-01-01T00:00:00.000Z"), + }); + const holidayCalendarEntryDelete = vi.fn().mockResolvedValue({ id: "entry_1" }); + + const created = await createHolidayCalendarEntry( + createContext({ + holidayCalendar: { findUnique: holidayCalendarFindUnique }, + holidayCalendarEntry: { + findFirst: holidayCalendarEntryFindFirst, + create: holidayCalendarEntryCreate, + }, + }), + { + holidayCalendarId: "cal_de", + name: "Neujahr", + date: new Date("2026-01-01T14:30:00.000Z"), + }, + ); + const deleted = await deleteHolidayCalendarEntry( + createContext({ + holidayCalendarEntry: { + findUnique: holidayCalendarEntryFindUnique, + delete: holidayCalendarEntryDelete, + }, + }), + { id: "entry_1" }, + ); + + expect(holidayCalendarEntryFindFirst).toHaveBeenCalledWith({ + where: { + holidayCalendarId: "cal_de", + date: new Date("2026-01-01T00:00:00.000Z"), + }, + select: { id: true }, + }); + expect(holidayCalendarEntryCreate).toHaveBeenCalledWith({ + data: { + holidayCalendarId: "cal_de", + date: new Date("2026-01-01T00:00:00.000Z"), + name: "Neujahr", + isRecurringAnnual: false, + }, + }); + expect(created.id).toBe("entry_1"); + expect(holidayCalendarEntryDelete).toHaveBeenCalledWith({ + where: { id: "entry_1" }, + }); + expect(deleted).toEqual({ + success: true, + id: "entry_1", + name: "Neujahr", + }); + expect(createAuditEntry).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/api/src/router/holiday-calendar-procedure-support.ts b/packages/api/src/router/holiday-calendar-procedure-support.ts new file mode 100644 index 0000000..18c5c39 --- /dev/null +++ b/packages/api/src/router/holiday-calendar-procedure-support.ts @@ -0,0 +1,283 @@ +import { + CreateHolidayCalendarEntrySchema, + CreateHolidayCalendarSchema, + UpdateHolidayCalendarEntrySchema, + UpdateHolidayCalendarSchema, +} 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 { asHolidayCalendarDb } from "./holiday-calendar-shared.js"; +import { + buildHolidayCalendarCreateData, + buildHolidayCalendarEntryCreateData, + buildHolidayCalendarEntryUpdateData, + buildHolidayCalendarUpdateData, + holidayCalendarDetailInclude, +} from "./holiday-calendar-support.js"; +import { + assertHolidayCalendarEntryDateAvailable, + assertHolidayCalendarScopeConsistency, + clampHolidayCalendarDate, + normalizeHolidayCalendarScopeInput, + resolveHolidayCalendarUpdateScope, +} from "./holiday-calendar-write-support.js"; + +type HolidayCalendarProcedureContext = Pick; + +function withAuditUser(userId: string | undefined) { + return userId ? { userId } : {}; +} + +export const holidayCalendarIdInputSchema = z.object({ + id: z.string(), +}); + +export const holidayCalendarUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateHolidayCalendarSchema, +}); + +export const holidayCalendarEntryUpdateInputSchema = z.object({ + id: z.string(), + data: UpdateHolidayCalendarEntrySchema, +}); + +type HolidayCalendarIdInput = z.infer; +type HolidayCalendarCreateInput = z.infer; +type HolidayCalendarUpdateInput = z.infer; +type HolidayCalendarEntryCreateInput = z.infer; +type HolidayCalendarEntryUpdateInput = z.infer; + +export async function createHolidayCalendar( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarCreateInput, +) { + const db = asHolidayCalendarDb(ctx.db); + + await findUniqueOrThrow( + ctx.db.country.findUnique({ + where: { id: input.countryId }, + select: { id: true, name: true }, + }), + "Country", + ); + + const normalizedScope = normalizeHolidayCalendarScopeInput({ + stateCode: input.stateCode, + metroCityId: input.metroCityId, + }); + + await assertHolidayCalendarScopeConsistency(db, { + scopeType: input.scopeType, + countryId: input.countryId, + ...normalizedScope, + }); + + const created = await db.holidayCalendar.create({ + data: buildHolidayCalendarCreateData({ + ...input, + normalizedScope, + }), + include: holidayCalendarDetailInclude, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendar", + 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 updateHolidayCalendar( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarUpdateInput, +) { + const db = asHolidayCalendarDb(ctx.db); + const existing = await findUniqueOrThrow( + db.holidayCalendar.findUnique({ where: { id: input.id } }), + "Holiday calendar", + ); + + const { stateCode, metroCityId } = resolveHolidayCalendarUpdateScope({ + existing, + data: input.data, + }); + + await assertHolidayCalendarScopeConsistency(db, { + scopeType: existing.scopeType, + countryId: existing.countryId, + stateCode, + metroCityId, + }, existing.id); + + const updated = await db.holidayCalendar.update({ + where: { id: input.id }, + data: buildHolidayCalendarUpdateData({ + data: input.data, + resolvedScope: { + stateCode, + metroCityId, + }, + }), + include: holidayCalendarDetailInclude, + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendar", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteHolidayCalendar( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarIdInput, +) { + const db = asHolidayCalendarDb(ctx.db); + const existing = await findUniqueOrThrow( + db.holidayCalendar.findUnique({ + where: { id: input.id }, + include: { entries: true }, + }), + "Holiday calendar", + ); + + await db.holidayCalendar.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendar", + entityId: existing.id, + entityName: existing.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + source: "ui", + }); + + return { success: true, id: existing.id, name: existing.name }; +} + +export async function createHolidayCalendarEntry( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarEntryCreateInput, +) { + const db = asHolidayCalendarDb(ctx.db); + + await findUniqueOrThrow( + db.holidayCalendar.findUnique({ + where: { id: input.holidayCalendarId }, + select: { id: true, name: true }, + }), + "Holiday calendar", + ); + + await assertHolidayCalendarEntryDateAvailable(db, { + holidayCalendarId: input.holidayCalendarId, + date: input.date, + }); + + const created = await db.holidayCalendarEntry.create({ + data: buildHolidayCalendarEntryCreateData({ + data: input, + date: clampHolidayCalendarDate(input.date), + }), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendarEntry", + 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 updateHolidayCalendarEntry( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarEntryUpdateInput, +) { + const db = asHolidayCalendarDb(ctx.db); + const existing = await findUniqueOrThrow( + db.holidayCalendarEntry.findUnique({ where: { id: input.id } }), + "Holiday calendar entry", + ); + const nextDate = input.data.date !== undefined + ? clampHolidayCalendarDate(input.data.date) + : existing.date; + + await assertHolidayCalendarEntryDateAvailable(db, { + holidayCalendarId: existing.holidayCalendarId, + date: nextDate, + }, existing.id); + + const updated = await db.holidayCalendarEntry.update({ + where: { id: input.id }, + data: buildHolidayCalendarEntryUpdateData({ + data: input.data, + date: nextDate, + }), + }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendarEntry", + entityId: updated.id, + entityName: updated.name, + action: "UPDATE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + after: updated as unknown as Record, + source: "ui", + }); + + return updated; +} + +export async function deleteHolidayCalendarEntry( + ctx: HolidayCalendarProcedureContext, + input: HolidayCalendarIdInput, +) { + const db = asHolidayCalendarDb(ctx.db); + const existing = await findUniqueOrThrow( + db.holidayCalendarEntry.findUnique({ where: { id: input.id } }), + "Holiday calendar entry", + ); + + await db.holidayCalendarEntry.delete({ where: { id: input.id } }); + + void createAuditEntry({ + db: ctx.db, + entityType: "HolidayCalendarEntry", + entityId: existing.id, + entityName: existing.name, + action: "DELETE", + ...withAuditUser(ctx.dbUser?.id), + before: existing as unknown as Record, + source: "ui", + }); + + return { success: true, id: existing.id, name: existing.name }; +} diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index 17eb878..9f64a35 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -1,30 +1,21 @@ +import { createTRPCRouter, adminProcedure } from "../trpc.js"; +import { holidayCalendarCatalogReadProcedures } from "./holiday-calendar-catalog-read.js"; +import { + createHolidayCalendar, + createHolidayCalendarEntry, + deleteHolidayCalendar, + deleteHolidayCalendarEntry, + holidayCalendarEntryUpdateInputSchema, + holidayCalendarIdInputSchema, + holidayCalendarUpdateInputSchema, + updateHolidayCalendar, + updateHolidayCalendarEntry, +} from "./holiday-calendar-procedure-support.js"; +import { holidayCalendarResolutionReadProcedures } from "./holiday-calendar-resolution-read.js"; import { CreateHolidayCalendarEntrySchema, CreateHolidayCalendarSchema, - UpdateHolidayCalendarEntrySchema, - UpdateHolidayCalendarSchema, } from "@capakraken/shared"; -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 } from "./holiday-calendar-shared.js"; -import { - buildHolidayCalendarCreateData, - buildHolidayCalendarEntryCreateData, - buildHolidayCalendarEntryUpdateData, - buildHolidayCalendarUpdateData, - holidayCalendarDetailInclude, -} from "./holiday-calendar-support.js"; -import { - assertHolidayCalendarEntryDateAvailable, - assertHolidayCalendarScopeConsistency, - clampHolidayCalendarDate, - normalizeHolidayCalendarScopeInput, - resolveHolidayCalendarUpdateScope, -} from "./holiday-calendar-write-support.js"; export const holidayCalendarRouter = createTRPCRouter({ ...holidayCalendarCatalogReadProcedures, @@ -32,231 +23,26 @@ export const holidayCalendarRouter = createTRPCRouter({ createCalendar: adminProcedure .input(CreateHolidayCalendarSchema) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - - await findUniqueOrThrow( - ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { id: true, name: true }, - }), - "Country", - ); - - await assertHolidayCalendarScopeConsistency(db, { - scopeType: input.scopeType, - countryId: input.countryId, - ...normalizeHolidayCalendarScopeInput({ - stateCode: input.stateCode, - metroCityId: input.metroCityId, - }), - }); - - const normalizedScope = normalizeHolidayCalendarScopeInput({ - stateCode: input.stateCode, - metroCityId: input.metroCityId, - }); - - const created = await db.holidayCalendar.create({ - data: buildHolidayCalendarCreateData({ - ...input, - normalizedScope, - }), - include: holidayCalendarDetailInclude, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendar", - 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 }) => createHolidayCalendar(ctx, input)), updateCalendar: adminProcedure - .input(z.object({ id: z.string(), data: UpdateHolidayCalendarSchema })) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - const existing = await findUniqueOrThrow( - db.holidayCalendar.findUnique({ where: { id: input.id } }), - "Holiday calendar", - ); - - const { stateCode, metroCityId } = resolveHolidayCalendarUpdateScope({ - existing, - data: input.data, - }); - - await assertHolidayCalendarScopeConsistency(db, { - scopeType: existing.scopeType, - countryId: existing.countryId, - stateCode, - metroCityId, - }, existing.id); - - const updated = await db.holidayCalendar.update({ - where: { id: input.id }, - data: buildHolidayCalendarUpdateData({ - data: input.data, - resolvedScope: { - stateCode, - metroCityId, - }, - }), - include: holidayCalendarDetailInclude, - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendar", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(holidayCalendarUpdateInputSchema) + .mutation(({ ctx, input }) => updateHolidayCalendar(ctx, input)), deleteCalendar: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - const existing = await findUniqueOrThrow( - db.holidayCalendar.findUnique({ - where: { id: input.id }, - include: { entries: true }, - }), - "Holiday calendar", - ); - - await db.holidayCalendar.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendar", - entityId: existing.id, - entityName: existing.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - source: "ui", - }); - - return { success: true, id: existing.id, name: existing.name }; - }), + .input(holidayCalendarIdInputSchema) + .mutation(({ ctx, input }) => deleteHolidayCalendar(ctx, input)), createEntry: adminProcedure .input(CreateHolidayCalendarEntrySchema) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - - await findUniqueOrThrow( - db.holidayCalendar.findUnique({ - where: { id: input.holidayCalendarId }, - select: { id: true, name: true }, - }), - "Holiday calendar", - ); - - await assertHolidayCalendarEntryDateAvailable(db, { - holidayCalendarId: input.holidayCalendarId, - date: input.date, - }); - - const created = await db.holidayCalendarEntry.create({ - data: buildHolidayCalendarEntryCreateData({ - data: input, - date: clampHolidayCalendarDate(input.date), - }), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendarEntry", - 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 }) => createHolidayCalendarEntry(ctx, input)), updateEntry: adminProcedure - .input(z.object({ id: z.string(), data: UpdateHolidayCalendarEntrySchema })) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - const existing = await findUniqueOrThrow( - db.holidayCalendarEntry.findUnique({ where: { id: input.id } }), - "Holiday calendar entry", - ); - const nextDate = input.data.date !== undefined - ? clampHolidayCalendarDate(input.data.date) - : existing.date; - - await assertHolidayCalendarEntryDateAvailable(db, { - holidayCalendarId: existing.holidayCalendarId, - date: nextDate, - }, existing.id); - - const updated = await db.holidayCalendarEntry.update({ - where: { id: input.id }, - data: buildHolidayCalendarEntryUpdateData({ - data: input.data, - date: nextDate, - }), - }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendarEntry", - entityId: updated.id, - entityName: updated.name, - action: "UPDATE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - after: updated as unknown as Record, - source: "ui", - }); - - return updated; - }), + .input(holidayCalendarEntryUpdateInputSchema) + .mutation(({ ctx, input }) => updateHolidayCalendarEntry(ctx, input)), deleteEntry: adminProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - const existing = await findUniqueOrThrow( - db.holidayCalendarEntry.findUnique({ where: { id: input.id } }), - "Holiday calendar entry", - ); - - await db.holidayCalendarEntry.delete({ where: { id: input.id } }); - - void createAuditEntry({ - db: ctx.db, - entityType: "HolidayCalendarEntry", - entityId: existing.id, - entityName: existing.name, - action: "DELETE", - userId: ctx.dbUser?.id, - before: existing as unknown as Record, - source: "ui", - }); - - return { success: true, id: existing.id, name: existing.name }; - }), + .input(holidayCalendarIdInputSchema) + .mutation(({ ctx, input }) => deleteHolidayCalendarEntry(ctx, input)), });