diff --git a/packages/api/src/router/holiday-calendar-catalog-read.ts b/packages/api/src/router/holiday-calendar-catalog-read.ts new file mode 100644 index 0000000..b9a6211 --- /dev/null +++ b/packages/api/src/router/holiday-calendar-catalog-read.ts @@ -0,0 +1,203 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { adminProcedure } from "../trpc.js"; +import { asHolidayCalendarDb, type HolidayReadContext } from "./holiday-calendar-shared.js"; + +const HolidayCalendarCatalogInputSchema = z.object({ + includeInactive: z.boolean().optional(), + countryCode: z.string().trim().min(1).optional(), + scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(), + stateCode: z.string().trim().min(1).optional(), + metroCity: z.string().trim().min(1).optional(), +}).optional(); + +function formatIsoDate(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function formatHolidayCalendarEntryDetail(entry: { + id: string; + date: Date; + name: string; + isRecurringAnnual?: boolean | null; + source?: string | null; +}) { + return { + id: entry.id, + date: formatIsoDate(entry.date), + name: entry.name, + isRecurringAnnual: entry.isRecurringAnnual ?? false, + source: entry.source ?? null, + }; +} + +function formatHolidayCalendarDetail(calendar: { + id: string; + name: string; + scopeType: string; + stateCode?: string | null; + isActive?: boolean | null; + priority?: number | null; + country?: { id: string; code: string; name: string } | null; + metroCity?: { id: string; name: string } | null; + _count?: { entries?: number | null } | null; + entries?: Array<{ + id: string; + date: Date; + name: string; + isRecurringAnnual?: boolean | null; + source?: string | null; + }> | null; +}) { + const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? []; + + return { + id: calendar.id, + name: calendar.name, + scopeType: calendar.scopeType, + stateCode: calendar.stateCode ?? null, + isActive: calendar.isActive ?? true, + priority: calendar.priority ?? 0, + country: calendar.country + ? { + id: calendar.country.id, + code: calendar.country.code, + name: calendar.country.name, + } + : null, + metroCity: calendar.metroCity + ? { + id: calendar.metroCity.id, + name: calendar.metroCity.name, + } + : null, + entryCount: calendar._count?.entries ?? entries.length, + entries, + }; +} + +async function readCalendarsSnapshot( + ctx: HolidayReadContext, + input?: z.infer, +) { + const db = asHolidayCalendarDb(ctx.db); + const where = { + ...(input?.includeInactive ? {} : { isActive: true }), + ...(input?.countryCode + ? { + country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } }, + } + : {}), + ...(input?.scopeType ? { scopeType: input.scopeType } : {}), + ...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}), + ...(input?.metroCity + ? { + metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } }, + } + : {}), + }; + + return db.holidayCalendar.findMany({ + where, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + _count: { select: { entries: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + orderBy: [ + { country: { name: "asc" } }, + { scopeType: "asc" }, + { priority: "desc" }, + { name: "asc" }, + ], + }); +} + +async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) { + const db = asHolidayCalendarDb(ctx.db); + const trimmedIdentifier = identifier.trim(); + + let calendar = await db.holidayCalendar.findUnique({ + where: { id: trimmedIdentifier }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + + if (!calendar) { + calendar = await db.holidayCalendar.findFirst({ + where: { name: { equals: trimmedIdentifier, mode: "insensitive" } }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + } + + if (!calendar) { + calendar = await db.holidayCalendar.findFirst({ + where: { name: { contains: trimmedIdentifier, mode: "insensitive" } }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }); + } + + if (!calendar) { + throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` }); + } + + return calendar; +} + +export const holidayCalendarCatalogReadProcedures = { + listCalendars: adminProcedure + .input(HolidayCalendarCatalogInputSchema) + .query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)), + + listCalendarsDetail: adminProcedure + .input(HolidayCalendarCatalogInputSchema) + .query(async ({ ctx, input }) => { + const calendars = await readCalendarsSnapshot(ctx, input); + return { + count: calendars.length, + calendars: calendars.map(formatHolidayCalendarDetail), + }; + }), + + getCalendarByIdentifier: adminProcedure + .input(z.object({ identifier: z.string().trim().min(1) })) + .query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)), + + getCalendarByIdentifierDetail: adminProcedure + .input(z.object({ identifier: z.string().trim().min(1) })) + .query(async ({ ctx, input }) => { + const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier); + return formatHolidayCalendarDetail(calendar); + }), + + getCalendarById: adminProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const db = asHolidayCalendarDb(ctx.db); + + return findUniqueOrThrow( + db.holidayCalendar.findUnique({ + where: { id: input.id }, + include: { + country: { select: { id: true, code: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, + }, + }), + "Holiday calendar", + ); + }), +}; diff --git a/packages/api/src/router/holiday-calendar-shared.ts b/packages/api/src/router/holiday-calendar-shared.ts new file mode 100644 index 0000000..d76ea39 --- /dev/null +++ b/packages/api/src/router/holiday-calendar-shared.ts @@ -0,0 +1,25 @@ +import type { TRPCContext } from "../trpc.js"; + +export type HolidayReadContext = Pick; + +export type HolidayCalendarDb = TRPCContext["db"] & { + holidayCalendar: { + findFirst: (args: unknown) => Promise<{ id: string } | null>; + findMany: (args: unknown) => Promise; + findUnique: (args: unknown) => Promise; + create: (args: unknown) => Promise; + update: (args: unknown) => Promise; + delete: (args: unknown) => Promise; + }; + holidayCalendarEntry: { + findFirst: (args: unknown) => Promise<{ id: string } | null>; + findUnique: (args: unknown) => Promise; + create: (args: unknown) => Promise; + update: (args: unknown) => Promise; + delete: (args: unknown) => Promise; + }; +}; + +export function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb { + return db as unknown as HolidayCalendarDb; +} diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index 89a1571..b732203 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -11,10 +11,11 @@ import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; -import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js"; +import { createTRPCRouter, adminProcedure, protectedProcedure } from "../trpc.js"; +import { holidayCalendarCatalogReadProcedures } from "./holiday-calendar-catalog-read.js"; +import { asHolidayCalendarDb, type HolidayCalendarDb, type HolidayReadContext } from "./holiday-calendar-shared.js"; type HolidayCalendarScope = HolidayCalendarScopeInput; -type HolidayReadContext = Pick; const HOLIDAY_SCOPE = { COUNTRY: "COUNTRY", @@ -22,28 +23,6 @@ const HOLIDAY_SCOPE = { CITY: "CITY", } as const satisfies Record; -type HolidayCalendarDb = TRPCContext["db"] & { - holidayCalendar: { - findFirst: (args: unknown) => Promise<{ id: string } | null>; - findMany: (args: unknown) => Promise; - findUnique: (args: unknown) => Promise; - create: (args: unknown) => Promise; - update: (args: unknown) => Promise; - delete: (args: unknown) => Promise; - }; - holidayCalendarEntry: { - findFirst: (args: unknown) => Promise<{ id: string } | null>; - findUnique: (args: unknown) => Promise; - create: (args: unknown) => Promise; - update: (args: unknown) => Promise; - delete: (args: unknown) => Promise; - }; -}; - -function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb { - return db as unknown as HolidayCalendarDb; -} - function clampDate(date: Date): Date { const value = new Date(date); value.setUTCHours(0, 0, 0, 0); @@ -54,71 +33,6 @@ function fmtDate(value: Date | null | undefined): string | null { return value ? value.toISOString().slice(0, 10) : null; } -function formatIsoDate(value: Date): string { - return value.toISOString().slice(0, 10); -} - -function formatHolidayCalendarEntryDetail(entry: { - id: string; - date: Date; - name: string; - isRecurringAnnual?: boolean | null; - source?: string | null; -}) { - return { - id: entry.id, - date: formatIsoDate(entry.date), - name: entry.name, - isRecurringAnnual: entry.isRecurringAnnual ?? false, - source: entry.source ?? null, - }; -} - -function formatHolidayCalendarDetail(calendar: { - id: string; - name: string; - scopeType: string; - stateCode?: string | null; - isActive?: boolean | null; - priority?: number | null; - country?: { id: string; code: string; name: string } | null; - metroCity?: { id: string; name: string } | null; - _count?: { entries?: number | null } | null; - entries?: Array<{ - id: string; - date: Date; - name: string; - isRecurringAnnual?: boolean | null; - source?: string | null; - }> | null; -}) { - const entries = calendar.entries?.map(formatHolidayCalendarEntryDetail) ?? []; - - return { - id: calendar.id, - name: calendar.name, - scopeType: calendar.scopeType, - stateCode: calendar.stateCode ?? null, - isActive: calendar.isActive ?? true, - priority: calendar.priority ?? 0, - country: calendar.country - ? { - id: calendar.country.id, - code: calendar.country.code, - name: calendar.country.name, - } - : null, - metroCity: calendar.metroCity - ? { - id: calendar.metroCity.id, - name: calendar.metroCity.name, - } - : null, - entryCount: calendar._count?.entries ?? entries.length, - entries, - }; -} - function canManageHolidayResourceReads(ctx: { dbUser: { systemRole: string } | null }): boolean { const role = ctx.dbUser?.systemRole; return role === "ADMIN" || role === "MANAGER"; @@ -243,92 +157,6 @@ const ResolveResourceHolidaysInputSchema = z.object({ } }); -async function readCalendarsSnapshot( - ctx: HolidayReadContext, - input?: { - includeInactive?: boolean | undefined; - countryCode?: string | undefined; - scopeType?: "COUNTRY" | "STATE" | "CITY" | undefined; - stateCode?: string | undefined; - metroCity?: string | undefined; - }, -) { - const db = asHolidayCalendarDb(ctx.db); - const where = { - ...(input?.includeInactive ? {} : { isActive: true }), - ...(input?.countryCode - ? { - country: { code: { equals: input.countryCode.trim().toUpperCase(), mode: "insensitive" as const } }, - } - : {}), - ...(input?.scopeType ? { scopeType: input.scopeType } : {}), - ...(input?.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}), - ...(input?.metroCity - ? { - metroCity: { name: { contains: input.metroCity.trim(), mode: "insensitive" as const } }, - } - : {}), - }; - - return db.holidayCalendar.findMany({ - where, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - _count: { select: { entries: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - orderBy: [ - { country: { name: "asc" } }, - { scopeType: "asc" }, - { priority: "desc" }, - { name: "asc" }, - ], - }); -} - -async function readCalendarByIdentifierSnapshot(ctx: HolidayReadContext, identifier: string) { - const db = asHolidayCalendarDb(ctx.db); - const trimmedIdentifier = identifier.trim(); - - let calendar = await db.holidayCalendar.findUnique({ - where: { id: trimmedIdentifier }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }); - - if (!calendar) { - calendar = await db.holidayCalendar.findFirst({ - where: { name: { equals: trimmedIdentifier, mode: "insensitive" } }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }); - } - - if (!calendar) { - calendar = await db.holidayCalendar.findFirst({ - where: { name: { contains: trimmedIdentifier, mode: "insensitive" } }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }); - } - - if (!calendar) { - throw new TRPCError({ code: "NOT_FOUND", message: `Holiday calendar not found: ${trimmedIdentifier}` }); - } - - return calendar; -} - async function readPreviewResolvedHolidaysSnapshot( ctx: HolidayReadContext, input: z.infer, @@ -589,60 +417,7 @@ async function assertScopeConsistency( } export const holidayCalendarRouter = createTRPCRouter({ - listCalendars: adminProcedure - .input(z.object({ - includeInactive: z.boolean().optional(), - countryCode: z.string().trim().min(1).optional(), - scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(), - stateCode: z.string().trim().min(1).optional(), - metroCity: z.string().trim().min(1).optional(), - }).optional()) - .query(async ({ ctx, input }) => readCalendarsSnapshot(ctx, input)), - - listCalendarsDetail: adminProcedure - .input(z.object({ - includeInactive: z.boolean().optional(), - countryCode: z.string().trim().min(1).optional(), - scopeType: z.enum(["COUNTRY", "STATE", "CITY"]).optional(), - stateCode: z.string().trim().min(1).optional(), - metroCity: z.string().trim().min(1).optional(), - }).optional()) - .query(async ({ ctx, input }) => { - const calendars = await readCalendarsSnapshot(ctx, input); - return { - count: calendars.length, - calendars: calendars.map(formatHolidayCalendarDetail), - }; - }), - - getCalendarByIdentifier: adminProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => readCalendarByIdentifierSnapshot(ctx, input.identifier)), - - getCalendarByIdentifierDetail: adminProcedure - .input(z.object({ identifier: z.string().trim().min(1) })) - .query(async ({ ctx, input }) => { - const calendar = await readCalendarByIdentifierSnapshot(ctx, input.identifier); - return formatHolidayCalendarDetail(calendar); - }), - - getCalendarById: adminProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const db = asHolidayCalendarDb(ctx.db); - - return findUniqueOrThrow( - db.holidayCalendar.findUnique({ - where: { id: input.id }, - include: { - country: { select: { id: true, code: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - entries: { orderBy: [{ date: "asc" }, { name: "asc" }] }, - }, - }), - "Holiday calendar", - ); - }), + ...holidayCalendarCatalogReadProcedures, createCalendar: adminProcedure .input(CreateHolidayCalendarSchema)