From 71b94d0ad1c61d334257ca3186feff0a11bc0b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 09:16:37 +0200 Subject: [PATCH] refactor(api): extract holiday calendar resolution reads --- .../holiday-calendar-resolution-read.ts | 345 +++++++++++++++++ packages/api/src/router/holiday-calendar.ts | 347 +----------------- 2 files changed, 349 insertions(+), 343 deletions(-) create mode 100644 packages/api/src/router/holiday-calendar-resolution-read.ts diff --git a/packages/api/src/router/holiday-calendar-resolution-read.ts b/packages/api/src/router/holiday-calendar-resolution-read.ts new file mode 100644 index 0000000..bce38e6 --- /dev/null +++ b/packages/api/src/router/holiday-calendar-resolution-read.ts @@ -0,0 +1,345 @@ +import { PreviewResolvedHolidaysSchema } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js"; +import { protectedProcedure } from "../trpc.js"; +import { type HolidayReadContext } from "./holiday-calendar-shared.js"; + +function canManageHolidayResourceReads(ctx: HolidayReadContext): boolean { + const role = ctx.dbUser?.systemRole; + return role === "ADMIN" || role === "MANAGER"; +} + +async function findOwnedHolidayResourceId(ctx: HolidayReadContext): Promise { + if (!ctx.dbUser?.id) { + return null; + } + + if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { + return null; + } + + const resource = await ctx.db.resource.findFirst({ + where: { userId: ctx.dbUser.id }, + select: { id: true }, + }); + + return resource?.id ?? null; +} + +async function assertCanReadHolidayResource( + ctx: HolidayReadContext, + resourceId: string, +): Promise { + if (canManageHolidayResourceReads(ctx)) { + return; + } + + const ownedResourceId = await findOwnedHolidayResourceId(ctx); + if (!ownedResourceId || ownedResourceId !== resourceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only view holiday data for your own resource", + }); + } +} + +function formatResolvedHolidayDetail(holiday: { + date: string; + name: string; + scopeType: string; + calendarName: string; + sourceType: string; +}) { + return { + date: holiday.date, + name: holiday.name, + scope: holiday.scopeType, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + }; +} + +function summarizeResolvedHolidaysDetail(holidays: Array<{ + date: string; + name: string; + scope: string; + calendarName: string; + sourceType: string; +}>) { + const byScope = new Map(); + const bySourceType = new Map(); + const byCalendar = new Map(); + + for (const holiday of holidays) { + byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); + bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); + byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); + } + + return { + byScope: [...byScope.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([scope, count]) => ({ scope, count })), + bySourceType: [...bySourceType.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([sourceType, count]) => ({ sourceType, count })), + byCalendar: [...byCalendar.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([calendarName, count]) => ({ calendarName, count })), + }; +} + +const ResolveHolidaysInputSchema = z.object({ + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + countryId: z.string().optional(), + countryCode: z.string().trim().min(1).optional(), + stateCode: z.string().trim().min(1).optional(), + metroCityId: z.string().optional(), + metroCityName: z.string().trim().min(1).optional(), +}).superRefine((input, issueCtx) => { + if (!input.countryId && !input.countryCode) { + issueCtx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either countryId or countryCode is required.", + path: ["countryId"], + }); + } + if (input.periodEnd < input.periodStart) { + issueCtx.addIssue({ + code: z.ZodIssueCode.custom, + message: "periodEnd must be on or after periodStart.", + path: ["periodEnd"], + }); + } +}); + +const ResolveResourceHolidaysInputSchema = z.object({ + resourceId: z.string(), + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), +}).superRefine((input, issueCtx) => { + if (input.periodEnd < input.periodStart) { + issueCtx.addIssue({ + code: z.ZodIssueCode.custom, + message: "periodEnd must be on or after periodStart.", + path: ["periodEnd"], + }); + } +}); + +async function readPreviewResolvedHolidaysSnapshot( + ctx: HolidayReadContext, + input: z.infer, +) { + const country = await findUniqueOrThrow( + ctx.db.country.findUnique({ + where: { id: input.countryId }, + select: { id: true, code: true, name: true }, + }), + "Country", + ); + + const metroCity = input.metroCityId + ? await ctx.db.metroCity.findUnique({ + where: { id: input.metroCityId }, + select: { id: true, name: true, countryId: true }, + }) + : null; + + const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`), + periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`), + countryId: input.countryId, + countryCode: country.code, + federalState: input.stateCode?.trim().toUpperCase() ?? null, + metroCityId: input.metroCityId ?? null, + metroCityName: metroCity?.name ?? null, + }); + + return { + locationContext: { + countryId: input.countryId, + countryCode: country.code, + stateCode: input.stateCode?.trim().toUpperCase() ?? null, + metroCityId: input.metroCityId ?? null, + metroCity: metroCity?.name ?? null, + year: input.year, + }, + holidays: resolved.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scopeType: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + })), + }; +} + +async function readResolvedHolidaysSnapshot( + ctx: HolidayReadContext, + input: z.infer, +) { + let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null; + + if (!resolvedCountryCode && input.countryId) { + const country = await findUniqueOrThrow( + ctx.db.country.findUnique({ + where: { id: input.countryId }, + select: { code: true }, + }), + "Country", + ); + resolvedCountryCode = country.code; + } + + const metroCityName = input.metroCityId + ? (await ctx.db.metroCity.findUnique({ + where: { id: input.metroCityId }, + select: { name: true }, + }))?.name ?? null + : input.metroCityName?.trim() ?? null; + + const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: input.periodStart, + periodEnd: input.periodEnd, + countryId: input.countryId ?? null, + countryCode: resolvedCountryCode, + federalState: input.stateCode?.trim().toUpperCase() ?? null, + metroCityId: input.metroCityId ?? null, + metroCityName, + }); + + return { + periodStart: input.periodStart.toISOString().slice(0, 10), + periodEnd: input.periodEnd.toISOString().slice(0, 10), + locationContext: { + countryId: input.countryId ?? null, + countryCode: resolvedCountryCode, + federalState: input.stateCode?.trim().toUpperCase() ?? null, + metroCityId: input.metroCityId ?? null, + metroCity: metroCityName, + }, + holidays: resolved.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scopeType: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + })), + }; +} + +async function readResolvedResourceHolidaysSnapshot( + ctx: HolidayReadContext, + input: z.infer, +) { + await assertCanReadHolidayResource(ctx, input.resourceId); + + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, + eid: true, + displayName: true, + federalState: true, + countryId: true, + metroCityId: true, + country: { select: { code: true, name: true } }, + metroCity: { select: { name: true } }, + }, + }), + "Resource", + ); + + const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { + periodStart: input.periodStart, + periodEnd: input.periodEnd, + countryId: resource.countryId ?? null, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCityId: resource.metroCityId ?? null, + metroCityName: resource.metroCity?.name ?? null, + }); + + return { + periodStart: input.periodStart.toISOString().slice(0, 10), + periodEnd: input.periodEnd.toISOString().slice(0, 10), + resource: { + id: resource.id, + eid: resource.eid, + name: resource.displayName, + country: resource.country?.name ?? resource.country?.code ?? null, + countryCode: resource.country?.code ?? null, + federalState: resource.federalState ?? null, + metroCity: resource.metroCity?.name ?? null, + }, + holidays: resolved.map((holiday) => ({ + date: holiday.date, + name: holiday.name, + scopeType: holiday.scope, + calendarName: holiday.calendarName, + sourceType: holiday.sourceType, + })), + }; +} + +export const holidayCalendarResolutionReadProcedures = { + previewResolvedHolidays: protectedProcedure + .input(PreviewResolvedHolidaysSchema) + .query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays), + + previewResolvedHolidaysDetail: protectedProcedure + .input(PreviewResolvedHolidaysSchema) + .query(async ({ ctx, input }) => { + const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input); + const holidays = resolved.holidays.map(formatResolvedHolidayDetail); + return { + count: holidays.length, + locationContext: resolved.locationContext, + summary: summarizeResolvedHolidaysDetail(holidays), + holidays, + }; + }), + + resolveHolidays: protectedProcedure + .input(ResolveHolidaysInputSchema) + .query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)), + + resolveHolidaysDetail: protectedProcedure + .input(ResolveHolidaysInputSchema) + .query(async ({ ctx, input }) => { + const resolved = await readResolvedHolidaysSnapshot(ctx, input); + const holidays = resolved.holidays.map(formatResolvedHolidayDetail); + return { + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + locationContext: resolved.locationContext, + count: holidays.length, + summary: summarizeResolvedHolidaysDetail(holidays), + holidays, + }; + }), + + resolveResourceHolidays: protectedProcedure + .input(ResolveResourceHolidaysInputSchema) + .query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)), + + resolveResourceHolidaysDetail: protectedProcedure + .input(ResolveResourceHolidaysInputSchema) + .query(async ({ ctx, input }) => { + const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input); + const holidays = resolved.holidays.map(formatResolvedHolidayDetail); + return { + periodStart: resolved.periodStart, + periodEnd: resolved.periodEnd, + resource: resolved.resource, + count: holidays.length, + summary: summarizeResolvedHolidaysDetail(holidays), + holidays, + }; + }), +}; diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts index b732203..20b108b 100644 --- a/packages/api/src/router/holiday-calendar.ts +++ b/packages/api/src/router/holiday-calendar.ts @@ -2,7 +2,6 @@ import { CreateHolidayCalendarEntrySchema, CreateHolidayCalendarSchema, type HolidayCalendarScopeInput, - PreviewResolvedHolidaysSchema, UpdateHolidayCalendarEntrySchema, UpdateHolidayCalendarSchema, } from "@capakraken/shared"; @@ -10,10 +9,10 @@ import { TRPCError } from "@trpc/server"; 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 } from "../trpc.js"; +import { createTRPCRouter, adminProcedure } from "../trpc.js"; import { holidayCalendarCatalogReadProcedures } from "./holiday-calendar-catalog-read.js"; -import { asHolidayCalendarDb, type HolidayCalendarDb, type HolidayReadContext } from "./holiday-calendar-shared.js"; +import { holidayCalendarResolutionReadProcedures } from "./holiday-calendar-resolution-read.js"; +import { asHolidayCalendarDb, type HolidayCalendarDb } from "./holiday-calendar-shared.js"; type HolidayCalendarScope = HolidayCalendarScopeInput; @@ -29,291 +28,6 @@ function clampDate(date: Date): Date { return value; } -function fmtDate(value: Date | null | undefined): string | null { - return value ? value.toISOString().slice(0, 10) : null; -} - -function canManageHolidayResourceReads(ctx: { dbUser: { systemRole: string } | null }): boolean { - const role = ctx.dbUser?.systemRole; - return role === "ADMIN" || role === "MANAGER"; -} - -async function findOwnedHolidayResourceId(ctx: HolidayReadContext): Promise { - if (!ctx.dbUser?.id) { - return null; - } - - if (!ctx.db.resource || typeof ctx.db.resource.findFirst !== "function") { - return null; - } - - const resource = await ctx.db.resource.findFirst({ - where: { userId: ctx.dbUser.id }, - select: { id: true }, - }); - - return resource?.id ?? null; -} - -async function assertCanReadHolidayResource( - ctx: HolidayReadContext, - resourceId: string, -): Promise { - if (canManageHolidayResourceReads(ctx)) { - return; - } - - const ownedResourceId = await findOwnedHolidayResourceId(ctx); - if (!ownedResourceId || ownedResourceId !== resourceId) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "You can only view holiday data for your own resource", - }); - } -} - -function formatResolvedHolidayDetail(holiday: { - date: string; - name: string; - scopeType: string; - calendarName: string; - sourceType: string; -}) { - return { - date: holiday.date, - name: holiday.name, - scope: holiday.scopeType, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - }; -} - -function summarizeResolvedHolidaysDetail(holidays: Array<{ - date: string; - name: string; - scope: string; - calendarName: string; - sourceType: string; -}>) { - const byScope = new Map(); - const bySourceType = new Map(); - const byCalendar = new Map(); - - for (const holiday of holidays) { - byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); - bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); - byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); - } - - return { - byScope: [...byScope.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([scope, count]) => ({ scope, count })), - bySourceType: [...bySourceType.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([sourceType, count]) => ({ sourceType, count })), - byCalendar: [...byCalendar.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([calendarName, count]) => ({ calendarName, count })), - }; -} - -const ResolveHolidaysInputSchema = z.object({ - periodStart: z.coerce.date(), - periodEnd: z.coerce.date(), - countryId: z.string().optional(), - countryCode: z.string().trim().min(1).optional(), - stateCode: z.string().trim().min(1).optional(), - metroCityId: z.string().optional(), - metroCityName: z.string().trim().min(1).optional(), -}).superRefine((input, issueCtx) => { - if (!input.countryId && !input.countryCode) { - issueCtx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Either countryId or countryCode is required.", - path: ["countryId"], - }); - } - if (input.periodEnd < input.periodStart) { - issueCtx.addIssue({ - code: z.ZodIssueCode.custom, - message: "periodEnd must be on or after periodStart.", - path: ["periodEnd"], - }); - } -}); - -const ResolveResourceHolidaysInputSchema = z.object({ - resourceId: z.string(), - periodStart: z.coerce.date(), - periodEnd: z.coerce.date(), -}).superRefine((input, issueCtx) => { - if (input.periodEnd < input.periodStart) { - issueCtx.addIssue({ - code: z.ZodIssueCode.custom, - message: "periodEnd must be on or after periodStart.", - path: ["periodEnd"], - }); - } -}); - -async function readPreviewResolvedHolidaysSnapshot( - ctx: HolidayReadContext, - input: z.infer, -) { - const country = await findUniqueOrThrow( - ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { id: true, code: true, name: true }, - }), - "Country", - ); - - const metroCity = input.metroCityId - ? await ctx.db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { id: true, name: true, countryId: true }, - }) - : null; - - const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`), - periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`), - countryId: input.countryId, - countryCode: country.code, - federalState: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, - metroCityName: metroCity?.name ?? null, - }); - - return { - locationContext: { - countryId: input.countryId, - countryCode: country.code, - stateCode: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, - metroCity: metroCity?.name ?? null, - year: input.year, - }, - holidays: resolved.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scopeType: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - })), - }; -} - -async function readResolvedHolidaysSnapshot( - ctx: HolidayReadContext, - input: z.infer, -) { - let resolvedCountryCode = input.countryCode?.trim().toUpperCase() ?? null; - - if (!resolvedCountryCode && input.countryId) { - const country = await findUniqueOrThrow( - ctx.db.country.findUnique({ - where: { id: input.countryId }, - select: { code: true }, - }), - "Country", - ); - resolvedCountryCode = country.code; - } - - const metroCityName = input.metroCityId - ? (await ctx.db.metroCity.findUnique({ - where: { id: input.metroCityId }, - select: { name: true }, - }))?.name ?? null - : input.metroCityName?.trim() ?? null; - - const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: input.periodStart, - periodEnd: input.periodEnd, - countryId: input.countryId ?? null, - countryCode: resolvedCountryCode, - federalState: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, - metroCityName, - }); - - return { - periodStart: input.periodStart.toISOString().slice(0, 10), - periodEnd: input.periodEnd.toISOString().slice(0, 10), - locationContext: { - countryId: input.countryId ?? null, - countryCode: resolvedCountryCode, - federalState: input.stateCode?.trim().toUpperCase() ?? null, - metroCityId: input.metroCityId ?? null, - metroCity: metroCityName, - }, - holidays: resolved.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scopeType: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - })), - }; -} - -async function readResolvedResourceHolidaysSnapshot( - ctx: HolidayReadContext, - input: z.infer, -) { - await assertCanReadHolidayResource(ctx, input.resourceId); - - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, - eid: true, - displayName: true, - federalState: true, - countryId: true, - metroCityId: true, - country: { select: { code: true, name: true } }, - metroCity: { select: { name: true } }, - }, - }), - "Resource", - ); - - const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { - periodStart: input.periodStart, - periodEnd: input.periodEnd, - countryId: resource.countryId ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCityId: resource.metroCityId ?? null, - metroCityName: resource.metroCity?.name ?? null, - }); - - return { - periodStart: input.periodStart.toISOString().slice(0, 10), - periodEnd: input.periodEnd.toISOString().slice(0, 10), - resource: { - id: resource.id, - eid: resource.eid, - name: resource.displayName, - country: resource.country?.name ?? resource.country?.code ?? null, - countryCode: resource.country?.code ?? null, - federalState: resource.federalState ?? null, - metroCity: resource.metroCity?.name ?? null, - }, - holidays: resolved.map((holiday) => ({ - date: holiday.date, - name: holiday.name, - scopeType: holiday.scope, - calendarName: holiday.calendarName, - sourceType: holiday.sourceType, - })), - }; -} - async function assertEntryDateAvailable( db: HolidayCalendarDb, input: { @@ -418,6 +132,7 @@ async function assertScopeConsistency( export const holidayCalendarRouter = createTRPCRouter({ ...holidayCalendarCatalogReadProcedures, + ...holidayCalendarResolutionReadProcedures, createCalendar: adminProcedure .input(CreateHolidayCalendarSchema) @@ -659,58 +374,4 @@ export const holidayCalendarRouter = createTRPCRouter({ return { success: true, id: existing.id, name: existing.name }; }), - previewResolvedHolidays: protectedProcedure - .input(PreviewResolvedHolidaysSchema) - .query(async ({ ctx, input }) => (await readPreviewResolvedHolidaysSnapshot(ctx, input)).holidays), - - previewResolvedHolidaysDetail: protectedProcedure - .input(PreviewResolvedHolidaysSchema) - .query(async ({ ctx, input }) => { - const resolved = await readPreviewResolvedHolidaysSnapshot(ctx, input); - const holidays = resolved.holidays.map(formatResolvedHolidayDetail); - return { - count: holidays.length, - locationContext: resolved.locationContext, - summary: summarizeResolvedHolidaysDetail(holidays), - holidays, - }; - }), - - resolveHolidays: protectedProcedure - .input(ResolveHolidaysInputSchema) - .query(async ({ ctx, input }) => readResolvedHolidaysSnapshot(ctx, input)), - - resolveHolidaysDetail: protectedProcedure - .input(ResolveHolidaysInputSchema) - .query(async ({ ctx, input }) => { - const resolved = await readResolvedHolidaysSnapshot(ctx, input); - const holidays = resolved.holidays.map(formatResolvedHolidayDetail); - return { - periodStart: resolved.periodStart, - periodEnd: resolved.periodEnd, - locationContext: resolved.locationContext, - count: holidays.length, - summary: summarizeResolvedHolidaysDetail(holidays), - holidays, - }; - }), - - resolveResourceHolidays: protectedProcedure - .input(ResolveResourceHolidaysInputSchema) - .query(async ({ ctx, input }) => readResolvedResourceHolidaysSnapshot(ctx, input)), - - resolveResourceHolidaysDetail: protectedProcedure - .input(ResolveResourceHolidaysInputSchema) - .query(async ({ ctx, input }) => { - const resolved = await readResolvedResourceHolidaysSnapshot(ctx, input); - const holidays = resolved.holidays.map(formatResolvedHolidayDetail); - return { - periodStart: resolved.periodStart, - periodEnd: resolved.periodEnd, - resource: resolved.resource, - count: holidays.length, - summary: summarizeResolvedHolidaysDetail(holidays), - holidays, - }; - }), });