From 2766dafd3202d3c54bd902c5e421a949b5becbe0 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Wed, 24 Jun 2026 07:32:07 +0200 Subject: [PATCH] fix: recurring masses --- src/collections/Churches.ts | 18 +- src/jobs/generateRecurringMasses.ts | 277 ++++++++++++++++++---------- src/payload-types.ts | 1 + 3 files changed, 196 insertions(+), 100 deletions(-) diff --git a/src/collections/Churches.ts b/src/collections/Churches.ts index 38da8bb..e74d8ec 100644 --- a/src/collections/Churches.ts +++ b/src/collections/Churches.ts @@ -1,5 +1,6 @@ import { CollectionConfig } from 'payload' import { hide, isAdminOrEmployee } from '@/collections/access/admin' +import { regenerateMassesForChurch } from '@/jobs/generateRecurringMasses' export const Churches: CollectionConfig = { slug: 'church', @@ -214,16 +215,23 @@ export const Churches: CollectionConfig = { hooks: { afterChange: [ async ({ doc, req }) => { - if (!doc.recurringSchedule?.length) return try { - await req.payload.jobs.queue({ - task: 'generateRecurringMasses', - input: { churchId: doc.id }, + // Materialize Worship docs synchronously so they appear right + // after saving — same approach as Events/eventOccurrence. This + // wipes the church's future auto-generated masses and rebuilds + // them from the current schedule, so changing (or clearing) the + // schedule never leaves stale/double masses behind. The weekly + // job (generateRecurringMasses) still backfills the rolling + // window across all churches via its cron schedule. + await regenerateMassesForChurch({ + church: doc, + payload: req.payload, + req, }) } catch (err) { req.payload.logger.error( { err, churchId: doc.id }, - 'Failed to queue generateRecurringMasses job', + 'Failed to regenerate recurring masses', ) } }, diff --git a/src/jobs/generateRecurringMasses.ts b/src/jobs/generateRecurringMasses.ts index c796791..f246a81 100644 --- a/src/jobs/generateRecurringMasses.ts +++ b/src/jobs/generateRecurringMasses.ts @@ -1,4 +1,5 @@ -import type { TaskConfig } from 'payload' +import type { PayloadRequest, TaskConfig } from 'payload' +import type { Payload } from 'payload' import { combineDateAndTime, @@ -18,8 +19,9 @@ import { * 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 + * - `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' })` @@ -27,9 +29,17 @@ import { // 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 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 @@ -37,9 +47,161 @@ type GenerateRecurringMassesInput = { type GenerateRecurringMassesOutput = { created: number + deleted: number skipped: number } +type ChurchLike = { + id: string + recurringSchedule?: unknown[] | null +} + +/** + * Materializes a single Church's `recurringSchedule` into real `Worship` + * documents for the rolling [last Monday, horizon] window. + * + * Wipe-and-regenerate: every future auto-generated (`generated: true`) + * Worship doc for this church is deleted and rebuilt from the current + * schedule, so a changed/cleared schedule never leaves stale or double + * masses behind. Hand-created Worship docs are never touched. Manual + * cancellations on generated docs are snapshotted by date and re-applied + * so they survive the regen (mirrors Events/eventOccurrence). + * + * Exposed both as a direct function (called from the Church `afterChange` + * hook so edits reflect immediately) and via the Jobs Queue task below + * (weekly cron backfill across all churches). + */ +export const regenerateMassesForChurch = async ({ + church, + payload, + req, + weeksAhead = DEFAULT_WEEKS_AHEAD, + now = new Date(), +}: { + church: ChurchLike + payload: Payload + req?: PayloadRequest + weeksAhead?: number + now?: Date +}): Promise => { + 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 + + // Snapshot manual cancellations on generated docs so they survive the + // wipe/regen. Matched by ISO timestamp because regen rebuilds the same + // (church, date) slots from the schedule. + const existing = await payload.find({ + collection: 'worship', + where: { + and: [ + { location: { equals: church.id } }, + { generated: { equals: true } }, + { date: { greater_than_equal: windowStart.toISOString() } }, + ], + }, + depth: 0, + limit: 1000, + pagination: false, + req, + }) + const cancelledDates = new Set( + existing.docs + .filter((doc) => doc.cancelled === true && typeof doc.date === 'string') + .map((doc) => new Date(doc.date as string).toISOString()), + ) + + // Wipe future auto-generated docs, then regenerate from the current + // schedule. This is what keeps a changed schedule from leaving stale + // (double) masses behind. Hand-created Worship docs (generated !== true) + // are never touched. + const wipe = await payload.delete({ + collection: 'worship', + where: { + and: [ + { location: { equals: church.id } }, + { generated: { equals: true } }, + { date: { greater_than_equal: windowStart.toISOString() } }, + ], + }, + req, + }) + const 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 + } + + // `generated: true` marks this as auto-created so a later run (or the + // wipe above) 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: date.toISOString(), + location: church.id, + type: entry.type, + cancelled: cancelledDates.has(date.toISOString()), + title: entry.defaultTitle || undefined, + celebrant: entry.defaultCelebrant || undefined, + description: entry.defaultDescription || undefined, + generated: true, + }, + req, + }) + + created += 1 + } + } + + return { created, deleted, skipped } +} + export const generateRecurringMassesTask: TaskConfig<{ input: GenerateRecurringMassesInput output: GenerateRecurringMassesOutput @@ -60,6 +222,7 @@ export const generateRecurringMassesTask: TaskConfig<{ ], outputSchema: [ { name: 'created', type: 'number' }, + { name: 'deleted', type: 'number' }, { name: 'skipped', type: 'number' }, ], // Weekly cron to keep the rolling window populated even if nobody @@ -75,10 +238,9 @@ export const generateRecurringMassesTask: TaskConfig<{ 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. + // 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, @@ -90,108 +252,33 @@ export const generateRecurringMassesTask: TaskConfig<{ }) 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 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' | '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, 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 - } - } + const result = await regenerateMassesForChurch({ + church: church as ChurchLike, + payload, + req, + weeksAhead, + now, + }) + 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, skipped, weeksAhead }, + { created, deleted, skipped, weeksAhead }, 'generateRecurringMasses finished', ) return { - output: { created, skipped }, + output: { created, deleted, skipped }, } }, } diff --git a/src/payload-types.ts b/src/payload-types.ts index 14cbeee..9dd1a64 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -2940,6 +2940,7 @@ export interface TaskGenerateRecurringMasses { }; output: { created?: number | null; + deleted?: number | null; skipped?: number | null; }; }