The rolling twelve-month window is fetched from openholidaysapi.org each night at 03:00 local, and can be triggered from the page, from POST /api/admin/holidays/sync, or from `npm run holidays:sync` for the first run after a deployment. The calendar is fetched twice, once per language, and the two answers joined on the entry id. Holiday names are proper nouns with established English forms — "Jeûne genevois" is not something a translation model should be improvising, and this costs one extra HTTP call. Two properties are load-bearing and tested against a real database. The sync is idempotent: running it twice leaves exactly what running it once did, verified live as well as against a mock. And it never touches `isAutoClosed` on an existing row — that is the shop's decision, not the API's, and a nightly job quietly reopening a day the owner had closed would be invisible until someone found the door locked. When the API is down the local cache is left untouched and the failure is recorded with its timestamp, so the page can say how stale the calendar is rather than showing nothing. Retries widen the gap between attempts; the nightly job can afford to wait, the shop cannot afford a stale calendar for a day. node-cron runs inside the application process rather than an external cron hitting an endpoint: one container, one shop, no second instance to coordinate with, and no trigger endpoint to protect and document. The reasoning is recorded next to the schedule. Verified against the live API: nine Geneva holidays, both languages, including the cantonal Restauration de la République. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
133 lines
4.3 KiB
TypeScript
133 lines
4.3 KiB
TypeScript
/**
|
|
* 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<ParsedHoliday[]>;
|
|
now?: Date;
|
|
};
|
|
|
|
export async function syncHolidays(deps: SyncDeps = {}): Promise<SyncOutcome> {
|
|
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`);
|
|
}
|