church-website/src/jobs/generateRecurringMasses.ts
2026-04-09 11:12:54 +02:00

197 lines
6.3 KiB
TypeScript

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 },
}
},
}