import type { TaskConfig } from 'payload' import { combineDateAndTime, generateOccurrenceDates, type ScheduleEntry, } from '@/jobs/lib/scheduleOccurrences' /** * Payload Jobs Queue task that materializes each Church's * `recurringSchedule` into real `Worship` documents for the coming weeks. * * Rationale: the rest of the app (MassTimesBlock, gemeinde pages, * /gottesdienst/{id} detail pages) all read from the Worship collection * via `fetchWorship`. By generating real documents instead of rendering * from the schedule on the fly, we keep a single source of truth and * avoid duplicating rendering logic. Generated docs are normal Worship * documents and can be edited by hand (cancel, change celebrant, etc.). * * Triggers: * - `afterChange` hook on Church (see collections/Churches.ts) queues * a run whenever a schedule is saved * - Own `schedule` entry below runs weekly to keep the rolling window * populated * - Manual: call `payload.jobs.queue({ task: 'generateRecurringMasses' })` */ // How far into the future we materialize documents on each run. // The weekly cron keeps this rolling window populated. const DEFAULT_WEEKS_AHEAD = 2 const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000 type GenerateRecurringMassesInput = { weeksAhead?: number churchId?: string } type GenerateRecurringMassesOutput = { created: number skipped: number } export const generateRecurringMassesTask: TaskConfig<{ input: GenerateRecurringMassesInput output: GenerateRecurringMassesOutput }> = { slug: 'generateRecurringMasses', label: 'Wiederkehrende Messzeiten erzeugen', inputSchema: [ { name: 'weeksAhead', type: 'number', required: false, }, { name: 'churchId', type: 'text', required: false, }, ], outputSchema: [ { name: 'created', type: 'number' }, { name: 'skipped', type: 'number' }, ], // Weekly cron to keep the rolling window populated even if nobody // edits a schedule. Payload's 6-field cron format starts with seconds. // → every Monday at 03:00 server time. schedule: [ { cron: '0 0 3 * * 1', queue: 'default', }, ], handler: async ({ input, req }) => { const { payload } = req const weeksAhead = input?.weeksAhead ?? DEFAULT_WEEKS_AHEAD const now = new Date() const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) // Scope to a single church when invoked from the Church afterChange // hook, otherwise process every church in one run. const churchesResult = await payload.find({ collection: 'church', depth: 0, limit: 1000, pagination: false, where: input?.churchId ? { id: { equals: input.churchId } } : undefined, }) let created = 0 let skipped = 0 for (const church of churchesResult.docs) { // Cast needed because payload-types only exposes recurringSchedule // on the full Church interface and payload.find returns a looser // shape at depth: 0. const schedule = (church as { recurringSchedule?: unknown[] }) .recurringSchedule if (!Array.isArray(schedule) || schedule.length === 0) continue for (const rawEntry of schedule) { const entry = rawEntry as ScheduleEntry & { time?: string | Date | null type?: 'MASS' | 'FAMILY' | 'WORD' defaultCelebrant?: string | null defaultTitle?: string | null defaultDescription?: string | null } // Guard: required fields may be missing if an editor saved a // half-filled row. Skip rather than crash the whole run. if (!entry.time || !entry.type) { skipped += 1 continue } // Resolve the entry's recurrence pattern (weekly / biweekly / // monthly Nth weekday) into concrete calendar dates in the // [now, horizon] window. Returns dates at midnight only — we // combine with the time-of-day below. const occurrenceDates = generateOccurrenceDates(entry, now, horizon) if (occurrenceDates.length === 0) { skipped += 1 continue } const timeSource = new Date(entry.time) for (const occurrenceDate of occurrenceDates) { // Build the real Worship.date as (calendar date) + (HH:mm from // the schedule) using local-time components. This is the step // that keeps DST transitions from shifting the wall-clock hour. const date = combineDateAndTime(occurrenceDate, timeSource) // `now` could land mid-week: skip any occurrence that already // passed earlier today. if (date.getTime() < now.getTime()) { skipped += 1 continue } // Append-only: if *any* Worship doc already exists at this // exact (church, timestamp) slot — whether generated by a // previous run or entered manually — leave it alone. This is // what protects admin edits (cancellations, celebrant changes, // etc.) from being overwritten. const existing = await payload.find({ collection: 'worship', depth: 0, limit: 1, pagination: false, where: { and: [ { location: { equals: church.id } }, { date: { equals: date.toISOString() } }, ], }, }) if (existing.docs.length > 0) { skipped += 1 continue } // `generated: true` marks this as auto-created so future // cleanup tooling can target it without touching manual rows. await payload.create({ collection: 'worship', data: { date: date.toISOString(), location: church.id, type: entry.type, cancelled: false, title: entry.defaultTitle || undefined, celebrant: entry.defaultCelebrant || undefined, description: entry.defaultDescription || undefined, generated: true, }, }) created += 1 } } } // Counts surface on the Payload Jobs admin page as the task output. payload.logger.info( { created, skipped, weeksAhead }, 'generateRecurringMasses finished', ) return { output: { created, skipped }, } }, }