import type { PayloadRequest, TaskConfig } from 'payload' import type { Payload } 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) calls * `regenerateMassesForChurch` directly so generated Worship docs appear * immediately on save — same pattern as Events/eventOccurrence. * - 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 = 4 const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000 // Most recent Monday at local midnight (Monday itself if `d` is a Monday). const startOfLastMonday = (d: Date): Date => { const date = new Date(d.getFullYear(), d.getMonth(), d.getDate()) const daysSinceMonday = (date.getDay() + 6) % 7 // 0=Sun..6=Sat → Mon=0 date.setDate(date.getDate() - daysSinceMonday) return date } type GenerateRecurringMassesInput = { weeksAhead?: number churchId?: string } type GenerateRecurringMassesOutput = { created: number deleted: number skipped: number } type ChurchLike = { id: string recurringSchedule?: unknown[] | null } type RegenerateMode = 'replace' | 'append' /** * Materializes a single Church's `recurringSchedule` into real `Worship` * documents for the rolling [last Monday, horizon] window. * * Two modes: * - `replace` (default): wipe every future auto-generated (`generated: true`) * Worship doc for this church and rebuild from the current schedule. Used * by the Church `afterChange` hook, where the schedule may have *changed* — * wiping is what stops a changed/cleared schedule from leaving stale or * double masses behind. Manual cancellations on generated docs are * snapshotted by date and re-applied so they survive the regen. * - `append`: never delete; only create occurrences whose slot doesn't yet * exist. Used by the periodic backfill job, where the schedule hasn't * changed and we only want to extend the rolling window forward — no point * churning (and re-id'ing) docs that are already correct. * * Hand-created Worship docs (generated !== true) are never deleted in either * mode, and never duplicated in `append` mode. */ export const regenerateMassesForChurch = async ({ church, payload, req, weeksAhead = DEFAULT_WEEKS_AHEAD, now = new Date(), mode = 'replace', }: { church: ChurchLike payload: Payload req?: PayloadRequest weeksAhead?: number now?: Date mode?: RegenerateMode }): Promise => { const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) // Start the window at the most recent Monday (local midnight) rather than // `now`, so masses earlier in the current week are still materialized. const windowStart = startOfLastMonday(now) let created = 0 let skipped = 0 let deleted = 0 // Existing Worship docs in the window. We use these to (replace) snapshot // cancellations so they survive the wipe, and (append) dedup so we don't // recreate slots that already exist. const existing = await payload.find({ collection: 'worship', where: { and: [ { location: { equals: church.id } }, { date: { greater_than_equal: windowStart.toISOString() } }, ], }, depth: 0, limit: 1000, pagination: false, req, }) const existingSlots = new Set( existing.docs .filter((doc) => typeof doc.date === 'string') .map((doc) => new Date(doc.date as string).toISOString()), ) const cancelledDates = new Set( existing.docs .filter((doc) => doc.cancelled === true && typeof doc.date === 'string') .map((doc) => new Date(doc.date as string).toISOString()), ) if (mode === 'replace') { // Wipe future auto-generated docs, then regenerate from the current // schedule. Hand-created docs (generated !== true) are untouched. const wipe = await payload.delete({ collection: 'worship', where: { and: [ { location: { equals: church.id } }, { generated: { equals: true } }, { date: { greater_than_equal: windowStart.toISOString() } }, ], }, req, }) deleted = wipe.docs.length } const schedule = church.recurringSchedule if (!Array.isArray(schedule) || schedule.length === 0) { return { created, deleted, skipped } } for (const rawEntry of schedule) { const entry = rawEntry as ScheduleEntry & { time?: string | Date | null type?: 'MASS' | 'FAMILY' | 'WORD' | 'LANGUAGE' | 'OTHER' 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, windowStart, 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) // Skip anything before the window start (last Monday). if (date.getTime() < windowStart.getTime()) { skipped += 1 continue } const iso = date.toISOString() // In append mode the future docs were never wiped, so leave any slot // that already has a Worship doc (generated or hand-created) alone. if (mode === 'append' && existingSlots.has(iso)) { skipped += 1 continue } // `generated: true` marks this as auto-created so a later run (or a // replace wipe) can target it without touching manual rows. Re-apply // any cancellation the editor had set on this slot before the wipe. await payload.create({ collection: 'worship', data: { date: iso, location: church.id, type: entry.type, cancelled: cancelledDates.has(iso), title: entry.defaultTitle || undefined, celebrant: entry.defaultCelebrant || undefined, description: entry.defaultDescription || undefined, generated: true, }, req, }) // Track within-run so two schedule entries can't double-book a slot. existingSlots.add(iso) created += 1 } } return { created, deleted, skipped } } 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: 'deleted', 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() // Scope to a single church when invoked with a churchId, otherwise // process every church in one run (weekly cron backfill). 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 deleted = 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 result = await regenerateMassesForChurch({ church: church as ChurchLike, payload, req, weeksAhead, now, // Periodic backfill: only extend the window forward. The schedule // hasn't changed here, so don't wipe/recreate docs that are already // correct (that only happens via the Church hook on an actual edit). mode: 'append', }) created += result.created deleted += result.deleted skipped += result.skipped } // Counts surface on the Payload Jobs admin page as the task output. payload.logger.info( { created, deleted, skipped, weeksAhead }, 'generateRecurringMasses finished', ) return { output: { created, deleted, skipped }, } }, }