fix: mode

This commit is contained in:
Benno Tielen 2026-06-24 08:19:40 +02:00
parent e96d04c317
commit a0821f1ae7
2 changed files with 64 additions and 36 deletions

View file

@ -56,20 +56,26 @@ type ChurchLike = {
recurringSchedule?: unknown[] | null recurringSchedule?: unknown[] | null
} }
type RegenerateMode = 'replace' | 'append'
/** /**
* Materializes a single Church's `recurringSchedule` into real `Worship` * Materializes a single Church's `recurringSchedule` into real `Worship`
* documents for the rolling [last Monday, horizon] window. * documents for the rolling [last Monday, horizon] window.
* *
* Wipe-and-regenerate: every future auto-generated (`generated: true`) * Two modes:
* Worship doc for this church is deleted and rebuilt from the current * - `replace` (default): wipe every future auto-generated (`generated: true`)
* schedule, so a changed/cleared schedule never leaves stale or double * Worship doc for this church and rebuild from the current schedule. Used
* masses behind. Hand-created Worship docs are never touched. Manual * by the Church `afterChange` hook, where the schedule may have *changed*
* cancellations on generated docs are snapshotted by date and re-applied * wiping is what stops a changed/cleared schedule from leaving stale or
* so they survive the regen (mirrors Events/eventOccurrence). * 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` * Hand-created Worship docs (generated !== true) are never deleted in either
* hook so edits reflect immediately) and via the Jobs Queue task below * mode, and never duplicated in `append` mode.
* (weekly cron backfill across all churches).
*/ */
export const regenerateMassesForChurch = async ({ export const regenerateMassesForChurch = async ({
church, church,
@ -77,12 +83,14 @@ export const regenerateMassesForChurch = async ({
req, req,
weeksAhead = DEFAULT_WEEKS_AHEAD, weeksAhead = DEFAULT_WEEKS_AHEAD,
now = new Date(), now = new Date(),
mode = 'replace',
}: { }: {
church: ChurchLike church: ChurchLike
payload: Payload payload: Payload
req?: PayloadRequest req?: PayloadRequest
weeksAhead?: number weeksAhead?: number
now?: Date now?: Date
mode?: RegenerateMode
}): Promise<GenerateRecurringMassesOutput> => { }): Promise<GenerateRecurringMassesOutput> => {
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
@ -92,16 +100,16 @@ export const regenerateMassesForChurch = async ({
let created = 0 let created = 0
let skipped = 0 let skipped = 0
let deleted = 0
// Snapshot manual cancellations on generated docs so they survive the // Existing Worship docs in the window. We use these to (replace) snapshot
// wipe/regen. Matched by ISO timestamp because regen rebuilds the same // cancellations so they survive the wipe, and (append) dedup so we don't
// (church, date) slots from the schedule. // recreate slots that already exist.
const existing = await payload.find({ const existing = await payload.find({
collection: 'worship', collection: 'worship',
where: { where: {
and: [ and: [
{ location: { equals: church.id } }, { location: { equals: church.id } },
{ generated: { equals: true } },
{ date: { greater_than_equal: windowStart.toISOString() } }, { date: { greater_than_equal: windowStart.toISOString() } },
], ],
}, },
@ -110,28 +118,33 @@ export const regenerateMassesForChurch = async ({
pagination: false, pagination: false,
req, 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( const cancelledDates = new Set(
existing.docs existing.docs
.filter((doc) => doc.cancelled === true && typeof doc.date === 'string') .filter((doc) => doc.cancelled === true && typeof doc.date === 'string')
.map((doc) => new Date(doc.date as string).toISOString()), .map((doc) => new Date(doc.date as string).toISOString()),
) )
// Wipe future auto-generated docs, then regenerate from the current if (mode === 'replace') {
// schedule. This is what keeps a changed schedule from leaving stale // Wipe future auto-generated docs, then regenerate from the current
// (double) masses behind. Hand-created Worship docs (generated !== true) // schedule. Hand-created docs (generated !== true) are untouched.
// are never touched. const wipe = await payload.delete({
const wipe = await payload.delete({ collection: 'worship',
collection: 'worship', where: {
where: { and: [
and: [ { location: { equals: church.id } },
{ location: { equals: church.id } }, { generated: { equals: true } },
{ generated: { equals: true } }, { date: { greater_than_equal: windowStart.toISOString() } },
{ date: { greater_than_equal: windowStart.toISOString() } }, ],
], },
}, req,
req, })
}) deleted = wipe.docs.length
const deleted = wipe.docs.length }
const schedule = church.recurringSchedule const schedule = church.recurringSchedule
if (!Array.isArray(schedule) || schedule.length === 0) { if (!Array.isArray(schedule) || schedule.length === 0) {
@ -177,16 +190,25 @@ export const regenerateMassesForChurch = async ({
continue continue
} }
// `generated: true` marks this as auto-created so a later run (or the const iso = date.toISOString()
// wipe above) can target it without touching manual rows. Re-apply any
// cancellation the editor had set on this slot before the wipe. // 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({ await payload.create({
collection: 'worship', collection: 'worship',
data: { data: {
date: date.toISOString(), date: iso,
location: church.id, location: church.id,
type: entry.type, type: entry.type,
cancelled: cancelledDates.has(date.toISOString()), cancelled: cancelledDates.has(iso),
title: entry.defaultTitle || undefined, title: entry.defaultTitle || undefined,
celebrant: entry.defaultCelebrant || undefined, celebrant: entry.defaultCelebrant || undefined,
description: entry.defaultDescription || undefined, description: entry.defaultDescription || undefined,
@ -195,6 +217,8 @@ export const regenerateMassesForChurch = async ({
req, req,
}) })
// Track within-run so two schedule entries can't double-book a slot.
existingSlots.add(iso)
created += 1 created += 1
} }
} }
@ -265,6 +289,10 @@ export const generateRecurringMassesTask: TaskConfig<{
req, req,
weeksAhead, weeksAhead,
now, 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 created += result.created
deleted += result.deleted deleted += result.deleted

View file

@ -113,8 +113,8 @@ export default buildConfig({
tasks: [generateRecurringMassesTask, generateEventOccurrencesTask], tasks: [generateRecurringMassesTask, generateEventOccurrencesTask],
autoRun: [ autoRun: [
{ {
// every 15 minutes (6-field cron, seconds first) // once a day at 03:00 (6-field cron, seconds first)
cron: '0 */15 * * * *', cron: '0 0 3 * * *',
queue: 'default', queue: 'default',
limit: 10, limit: 10,
}, },