From a0821f1ae74646ea0edde99c30fa40504a3b0f45 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Wed, 24 Jun 2026 08:19:40 +0200 Subject: [PATCH] fix: mode --- src/jobs/generateRecurringMasses.ts | 96 +++++++++++++++++++---------- src/payload.config.ts | 4 +- 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/src/jobs/generateRecurringMasses.ts b/src/jobs/generateRecurringMasses.ts index f246a81..107f429 100644 --- a/src/jobs/generateRecurringMasses.ts +++ b/src/jobs/generateRecurringMasses.ts @@ -56,20 +56,26 @@ type ChurchLike = { recurringSchedule?: unknown[] | null } +type RegenerateMode = 'replace' | 'append' + /** * 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). + * 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. * - * 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). + * Hand-created Worship docs (generated !== true) are never deleted in either + * mode, and never duplicated in `append` mode. */ export const regenerateMassesForChurch = async ({ church, @@ -77,12 +83,14 @@ export const regenerateMassesForChurch = async ({ req, weeksAhead = DEFAULT_WEEKS_AHEAD, now = new Date(), + mode = 'replace', }: { church: ChurchLike payload: Payload req?: PayloadRequest weeksAhead?: number now?: Date + mode?: RegenerateMode }): Promise => { const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) @@ -92,16 +100,16 @@ export const regenerateMassesForChurch = async ({ let created = 0 let skipped = 0 + let deleted = 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. + // 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 } }, - { generated: { equals: true } }, { date: { greater_than_equal: windowStart.toISOString() } }, ], }, @@ -110,28 +118,33 @@ export const regenerateMassesForChurch = async ({ 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()), ) - // 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 + 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) { @@ -177,16 +190,25 @@ export const regenerateMassesForChurch = async ({ 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. + 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: date.toISOString(), + date: iso, location: church.id, type: entry.type, - cancelled: cancelledDates.has(date.toISOString()), + cancelled: cancelledDates.has(iso), title: entry.defaultTitle || undefined, celebrant: entry.defaultCelebrant || undefined, description: entry.defaultDescription || undefined, @@ -195,6 +217,8 @@ export const regenerateMassesForChurch = async ({ req, }) + // Track within-run so two schedule entries can't double-book a slot. + existingSlots.add(iso) created += 1 } } @@ -265,6 +289,10 @@ export const generateRecurringMassesTask: TaskConfig<{ 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 diff --git a/src/payload.config.ts b/src/payload.config.ts index b59d7bf..e6305e0 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -113,8 +113,8 @@ export default buildConfig({ tasks: [generateRecurringMassesTask, generateEventOccurrencesTask], autoRun: [ { - // every 15 minutes (6-field cron, seconds first) - cron: '0 */15 * * * *', + // once a day at 03:00 (6-field cron, seconds first) + cron: '0 0 3 * * *', queue: 'default', limit: 10, },