import type { PayloadRequest, TaskConfig } from 'payload' import type { Payload } from 'payload' import { expandEventOccurrences, type EventRecurrenceType, type RecurrenceRule, } from '@/jobs/lib/eventRecurrence' /** * Materializes each Event's recurrence rule into `eventOccurrence` rows for * a rolling window. * * Two modes: * - `replace` (default): wipe every future occurrence and regenerate from * the current rule. Used by the Events `afterChange` hook, where the rule * or date may have changed — wiping keeps rule edits and recurring→once * transitions trivially correct. * - `append`: never delete; only create occurrences whose slot doesn't yet * exist. Used by the periodic backfill job, where the rule hasn't changed * and we only want to extend the window forward without churning (and * re-id'ing) occurrences that are already correct. * * Weekday and time-of-day come directly from `event.date`. Calendar-date * generation lives in scheduleOccurrences.ts (DST-safe by construction: * dates are built at local midnight and then stitched with the event's * HH:mm). * * Exposed both as a Payload Jobs Queue task (cron backfill) and as a direct * function called from the Events `afterChange` hook so edits are reflected * immediately without waiting for a queue worker. */ const DEFAULT_WEEKS_AHEAD = 8 const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000 type RegenerateMode = 'replace' | 'append' type GenerateEventOccurrencesInput = { weeksAhead?: number eventId?: string } type GenerateEventOccurrencesOutput = { created: number deleted: number skipped: number } type EventLike = { id: string date?: string | null endDateTime?: string | null recurrenceType?: EventRecurrenceType | null recurrenceRules?: RecurrenceRule[] | null endDate?: string | null } // Treat endDate as inclusive of the whole local day. const endOfLocalDay = (iso: string): Date => { const d = new Date(iso) return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999) } export const regenerateOccurrencesForEvent = async ({ event, payload, req, weeksAhead = DEFAULT_WEEKS_AHEAD, now = new Date(), mode = 'replace', }: { event: EventLike payload: Payload req?: PayloadRequest weeksAhead?: number now?: Date mode?: RegenerateMode }): Promise => { const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) // Existing future occurrences. Used to (replace) snapshot cancellations so // they survive the wipe, and (append) dedup so we don't recreate slots that // already exist. Matching by ISO date works because regen derives // timestamps from the same event.date stepped by whole calendar days — // shifts in event.date intentionally drop stale cancellations. const existing = await payload.find({ collection: 'eventOccurrence', where: { and: [ { event: { equals: event.id } }, { date: { greater_than_equal: now.toISOString() } }, ], }, depth: 0, limit: 1000, pagination: false, req, }) const cancelledDates = new Set( existing.docs .filter((occ) => occ.cancelled === true && typeof occ.date === 'string') .map((occ) => new Date(occ.date as string).toISOString()), ) const existingSlots = new Set( existing.docs .filter((occ) => typeof occ.date === 'string') .map((occ) => new Date(occ.date as string).toISOString()), ) let deleted = 0 let created = 0 let skipped = 0 if (mode === 'replace') { // Wipe future occurrences and rebuild from the current rule. Used by the // Event hook, where the rule/date may have changed. const wipe = await payload.delete({ collection: 'eventOccurrence', where: { and: [ { event: { equals: event.id } }, { date: { greater_than_equal: now.toISOString() } }, ], }, req, }) deleted = wipe.docs.length } if (!event.date) { return { created, deleted, skipped: skipped + 1 } } const eventDate = new Date(event.date) if (Number.isNaN(eventDate.getTime())) { return { created, deleted, skipped: skipped + 1 } } // Derive each occurrence's end from the event's duration (end - start), // re-anchored to the occurrence's own start. A missing or non-positive // duration yields no end on the occurrences. const eventEnd = event.endDateTime ? new Date(event.endDateTime) : null const durationMs = eventEnd && !Number.isNaN(eventEnd.getTime()) && eventEnd.getTime() > eventDate.getTime() ? eventEnd.getTime() - eventDate.getTime() : null const endFor = (start: Date): string | null => durationMs === null ? null : new Date(start.getTime() + durationMs).toISOString() const recurrenceType = event.recurrenceType ?? 'none' if (recurrenceType === 'none') { const iso = eventDate.toISOString() if (eventDate.getTime() < now.getTime()) { return { created, deleted, skipped: skipped + 1 } } // In append mode the slot may already exist (not wiped) — leave it alone. if (mode === 'append' && existingSlots.has(iso)) { return { created, deleted, skipped: skipped + 1 } } await payload.create({ collection: 'eventOccurrence', data: { event: event.id, date: iso, endDateTime: endFor(eventDate), cancelled: cancelledDates.has(iso), generated: true, }, req, }) return { created: created + 1, deleted, skipped } } const effectiveEnd = event.endDate ? new Date(Math.min(horizon.getTime(), endOfLocalDay(event.endDate).getTime())) : horizon const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd }) for (const date of dates) { const iso = date.toISOString() // In append mode the future occurrences were never wiped, so leave any // slot that already has an occurrence alone. if (mode === 'append' && existingSlots.has(iso)) { skipped += 1 continue } await payload.create({ collection: 'eventOccurrence', data: { event: event.id, date: iso, endDateTime: endFor(date), cancelled: cancelledDates.has(iso), generated: true, }, req, }) existingSlots.add(iso) created += 1 } return { created, deleted, skipped } } export const generateEventOccurrencesTask: TaskConfig<{ input: GenerateEventOccurrencesInput output: GenerateEventOccurrencesOutput }> = { slug: 'generateEventOccurrences', label: 'Veranstaltungs-Termine erzeugen', inputSchema: [ { name: 'weeksAhead', type: 'number', required: false }, { name: 'eventId', type: 'text', required: false }, ], outputSchema: [ { name: 'created', type: 'number' }, { name: 'deleted', type: 'number' }, { name: 'skipped', type: 'number' }, ], // Staggered 30 minutes after the Worship task to avoid DB contention. schedule: [ { cron: '0 30 3 * * 1', queue: 'default', }, ], handler: async ({ input, req }) => { const { payload } = req const weeksAhead = input?.weeksAhead ?? DEFAULT_WEEKS_AHEAD const now = new Date() const eventsResult = await payload.find({ collection: 'event', depth: 0, limit: 1000, pagination: false, where: input?.eventId ? { id: { equals: input.eventId } } : undefined, }) let created = 0 let deleted = 0 let skipped = 0 for (const event of eventsResult.docs) { const result = await regenerateOccurrencesForEvent({ event: event as EventLike, payload, weeksAhead, now, // Periodic backfill: only extend the window forward. The rule hasn't // changed here, so don't wipe/recreate occurrences that are already // correct (that only happens via the Event hook on an actual edit). mode: 'append', }) created += result.created deleted += result.deleted skipped += result.skipped } payload.logger.info( { created, deleted, skipped, weeksAhead }, 'generateEventOccurrences finished', ) return { output: { created, deleted, skipped }, } }, }