312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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<GenerateRecurringMassesOutput> => {
|
|
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 },
|
|
}
|
|
},
|
|
}
|