church-website/src/jobs/generateEventOccurrences.ts
Benno Tielen 1457fb4920
Some checks are pending
Deploy / deploy (push) Waiting to run
fix: mode
2026-06-24 08:23:43 +02:00

269 lines
8.1 KiB
TypeScript

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<GenerateEventOccurrencesOutput> => {
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 },
}
},
}