/** * Keeps a rolling twelve-month window of public holidays in the local cache. * * Idempotent by construction: running it twice leaves exactly what running it * once did. The one field it never touches on an existing row is * `isAutoClosed` — that is the shop's decision, not the API's, and a nightly * job quietly reopening a day the owner had closed would be the worst kind of * bug: invisible until someone finds the door locked. */ import { prisma } from '@/lib/db'; import { addCivilDays, toCivilDate, type CivilDate } from '@/lib/schedule/civil'; import { loadSettings } from '@/lib/schedule/context'; import { fetchHolidays, withRetries, type ParsedHoliday } from './openholidays'; export const SYNC_KEY = 'holidays'; export type SyncOutcome = | { ok: true; created: number; updated: number; removed: number; from: CivilDate; to: CivilDate } | { ok: false; error: string }; export type SyncDeps = { fetcher?: (options: { baseUrl: string; countryIsoCode: string; subdivisionCode: string; validFrom: CivilDate; validTo: CivilDate; }) => Promise; now?: Date; }; export async function syncHolidays(deps: SyncDeps = {}): Promise { const now = deps.now ?? new Date(); const settings = await loadSettings(); const today = toCivilDate(now, settings.timezone); const from = today; const to = addCivilDays(today, 365); const options = { baseUrl: process.env.HOLIDAYS_API_URL ?? 'https://openholidaysapi.org', countryIsoCode: settings.countryIsoCode, subdivisionCode: settings.subdivisionCode, validFrom: from, validTo: to, }; let holidays: ParsedHoliday[]; try { const fetcher = deps.fetcher ?? ((input) => fetchHolidays(input)); holidays = await withRetries(() => fetcher(options)); } catch (error) { const message = error instanceof Error ? error.message : String(error); await prisma.syncState.upsert({ where: { key: SYNC_KEY }, update: { lastErrorAt: now, lastError: message.slice(0, 500) }, create: { key: SYNC_KEY, lastErrorAt: now, lastError: message.slice(0, 500) }, }); // The local cache is left exactly as it was: a stale calendar beats none. return { ok: false, error: message }; } const existing = await prisma.publicHoliday.findMany({ where: { subdivisionCode: settings.subdivisionCode, date: { gte: asDate(from), lte: asDate(to) }, }, }); const existingByDate = new Map(existing.map((row) => [asCivil(row.date), row])); let created = 0; let updated = 0; for (const holiday of holidays) { const current = existingByDate.get(holiday.date); if (current) { const changed = current.nameFr !== holiday.nameFr || current.nameEn !== holiday.nameEn || current.nationwide !== holiday.nationwide; if (changed) { updated += 1; } await prisma.publicHoliday.update({ where: { id: current.id }, data: { nameFr: holiday.nameFr, nameEn: holiday.nameEn, nationwide: holiday.nationwide, syncedAt: now, // isAutoClosed deliberately absent: the shop owns that choice. }, }); } else { created += 1; await prisma.publicHoliday.create({ data: { date: asDate(holiday.date), nameFr: holiday.nameFr, nameEn: holiday.nameEn, nationwide: holiday.nationwide, subdivisionCode: settings.subdivisionCode, syncedAt: now, }, }); } } // Days the API no longer lists — a cancelled or corrected holiday. const fetchedDates = new Set(holidays.map((holiday) => holiday.date)); const stale = existing.filter((row) => !fetchedDates.has(asCivil(row.date))); if (stale.length > 0) { await prisma.publicHoliday.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } }); } await prisma.syncState.upsert({ where: { key: SYNC_KEY }, update: { lastSuccessAt: now, lastErrorAt: null, lastError: null }, create: { key: SYNC_KEY, lastSuccessAt: now }, }); return { ok: true, created, updated, removed: stale.length, from, to }; } function asCivil(value: Date): CivilDate { return value.toISOString().slice(0, 10); } function asDate(value: CivilDate): Date { return new Date(`${value}T00:00:00.000Z`); }