Compare commits

...

2 commits

Author SHA1 Message Date
Benno Tielen
1457fb4920 fix: mode
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-06-24 08:23:43 +02:00
Benno Tielen
a0821f1ae7 fix: mode 2026-06-24 08:19:40 +02:00
3 changed files with 125 additions and 60 deletions

View file

@ -10,24 +10,31 @@ import {
* Materializes each Event's recurrence rule into `eventOccurrence` rows for
* a rolling window.
*
* Per event we wipe every future occurrence and regenerate from the current
* rule. Occurrences are derivative pointers to the parent event, so there
* is no editor-edited state to preserve rule edits and recurringonce
* transitions stay trivially correct.
* Two modes:
* - `replace` (default): wipe every future occurrence and regenerate from
* the current rule. Used by the Events `afterChange` hook, where the rule
* or date may have changed wiping keeps rule edits and recurringonce
* transitions trivially correct.
* - `append`: never delete; only create occurrences whose slot doesn't yet
* exist. Used by the periodic backfill job, where the rule hasn't changed
* and we only want to extend the window forward without churning (and
* re-id'ing) occurrences that are already correct.
*
* Weekday and time-of-day come directly from `event.date`. Calendar-date
* generation lives in scheduleOccurrences.ts (DST-safe by construction:
* dates are built at local midnight and then stitched with the event's
* HH:mm).
*
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a
* direct function called from the Events `afterChange` hook so edits are
* reflected immediately without waiting for a queue worker.
* Exposed both as a Payload Jobs Queue task (cron backfill) and as a direct
* function called from the Events `afterChange` hook so edits are reflected
* immediately without waiting for a queue worker.
*/
const DEFAULT_WEEKS_AHEAD = 8
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
type RegenerateMode = 'replace' | 'append'
type GenerateEventOccurrencesInput = {
weeksAhead?: number
eventId?: string
@ -60,19 +67,22 @@ export const regenerateOccurrencesForEvent = async ({
req,
weeksAhead = DEFAULT_WEEKS_AHEAD,
now = new Date(),
mode = 'replace',
}: {
event: EventLike
payload: Payload
req?: PayloadRequest
weeksAhead?: number
now?: Date
mode?: RegenerateMode
}): Promise<GenerateEventOccurrencesOutput> => {
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
// Snapshot cancelled dates so manual cancellations survive the wipe/regen.
// Matching by ISO date works because regen derives timestamps from the same
// event.date stepped by whole calendar days — shi)fts in event.date
// intentionally drop stale cancellations.
// Existing future occurrences. Used to (replace) snapshot cancellations so
// they survive the wipe, and (append) dedup so we don't recreate slots that
// already exist. Matching by ISO date works because regen derives
// timestamps from the same event.date stepped by whole calendar days —
// shifts in event.date intentionally drop stale cancellations.
const existing = await payload.find({
collection: 'eventOccurrence',
where: {
@ -91,21 +101,32 @@ export const regenerateOccurrencesForEvent = async ({
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
.map((occ) => new Date(occ.date as string).toISOString()),
)
const existingSlots = new Set(
existing.docs
.filter((occ) => typeof occ.date === 'string')
.map((occ) => new Date(occ.date as string).toISOString()),
)
const wipe = await payload.delete({
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: event.id } },
{ date: { greater_than_equal: now.toISOString() } },
],
},
req,
})
let deleted = wipe.docs.length
let deleted = 0
let created = 0
let skipped = 0
if (mode === 'replace') {
// Wipe future occurrences and rebuild from the current rule. Used by the
// Event hook, where the rule/date may have changed.
const wipe = await payload.delete({
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: event.id } },
{ date: { greater_than_equal: now.toISOString() } },
],
},
req,
})
deleted = wipe.docs.length
}
if (!event.date) {
return { created, deleted, skipped: skipped + 1 }
}
@ -128,16 +149,21 @@ export const regenerateOccurrencesForEvent = async ({
const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') {
const iso = eventDate.toISOString()
if (eventDate.getTime() < now.getTime()) {
return { created, deleted, skipped: skipped + 1 }
}
// In append mode the slot may already exist (not wiped) — leave it alone.
if (mode === 'append' && existingSlots.has(iso)) {
return { created, deleted, skipped: skipped + 1 }
}
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: eventDate.toISOString(),
date: iso,
endDateTime: endFor(eventDate),
cancelled: cancelledDates.has(eventDate.toISOString()),
cancelled: cancelledDates.has(iso),
generated: true,
},
req,
@ -152,6 +178,12 @@ export const regenerateOccurrencesForEvent = async ({
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
for (const date of dates) {
const iso = date.toISOString()
// In append mode the future occurrences were never wiped, so leave any
// slot that already has an occurrence alone.
if (mode === 'append' && existingSlots.has(iso)) {
skipped += 1
continue
}
await payload.create({
collection: 'eventOccurrence',
data: {
@ -163,6 +195,7 @@ export const regenerateOccurrencesForEvent = async ({
},
req,
})
existingSlots.add(iso)
created += 1
}
@ -214,6 +247,10 @@ export const generateEventOccurrencesTask: TaskConfig<{
payload,
weeksAhead,
now,
// Periodic backfill: only extend the window forward. The rule hasn't
// changed here, so don't wipe/recreate occurrences that are already
// correct (that only happens via the Event hook on an actual edit).
mode: 'append',
})
created += result.created
deleted += result.deleted

View file

@ -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<GenerateRecurringMassesOutput> => {
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

View file

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