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", }); } } export const holidayCalendarRouter = createTRPCRouter({ ...holidayCalendarCatalogReadProcedures, ...holidayCalendarResolutionReadProcedures, 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 assertScopeConsistency(db, { scopeType: input.scopeType, countryId: input.countryId, stateCode: input.stateCode?.trim().toUpperCase() ?? null, metroCityId: input.metroCityId ?? null, }); const created = await db.holidayCalendar.create({ data: { name: input.name, scopeType: input.scopeType, countryId: input.countryId, ...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}), ...(input.metroCityId ? { metroCityId: input.metroCityId } : {}), isActive: input.isActive ?? true, priority: input.priority ?? 0, }, include: { country: { select: { id: true, code: true, name: true } }, metroCity: { select: { id: true, name: true } }, entries: true, }, }); 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; }), 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 = input.data.stateCode === undefined ? existing.stateCode : input.data.stateCode?.trim().toUpperCase() ?? null; const metroCityId = input.data.metroCityId === undefined ? existing.metroCityId : input.data.metroCityId ?? null; await assertScopeConsistency(db, { scopeType: existing.scopeType, countryId: existing.countryId, stateCode, metroCityId, }, existing.id); const updated = await db.holidayCalendar.update({ where: { id: input.id }, data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.stateCode !== undefined ? { stateCode } : {}), ...(input.data.metroCityId !== undefined ? { metroCityId } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), ...(input.data.priority !== undefined ? { priority: input.data.priority } : {}), }, include: { country: { select: { id: true, code: true, name: true } }, metroCity: { select: { id: true, name: true } }, entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, }, }); 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; }), 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 }; }), 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 assertEntryDateAvailable(db, { holidayCalendarId: input.holidayCalendarId, date: input.date, }); const created = await db.holidayCalendarEntry.create({ data: { holidayCalendarId: input.holidayCalendarId, date: clampDate(input.date), name: input.name, isRecurringAnnual: input.isRecurringAnnual ?? false, ...(input.source ? { source: input.source } : {}), }, }); 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; }), 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 ? clampDate(input.data.date) : existing.date; await assertEntryDateAvailable(db, { holidayCalendarId: existing.holidayCalendarId, date: nextDate, }, existing.id); const updated = await db.holidayCalendarEntry.update({ where: { id: input.id }, data: { ...(input.data.date !== undefined ? { date: nextDate } : {}), ...(input.data.name !== undefined ? { name: input.data.name } : {}), ...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}), ...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}), }, }); 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; }), 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 }; }), });