269 lines
8.1 KiB
TypeScript
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 },
|
|
}
|
|
},
|
|
}
|