From 1457fb49204241ac1a55e1751e7de928425aa794 Mon Sep 17 00:00:00 2001 From: Benno Tielen Date: Wed, 24 Jun 2026 08:23:43 +0200 Subject: [PATCH] fix: mode --- src/jobs/generateEventOccurrences.ts | 85 ++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 24 deletions(-) diff --git a/src/jobs/generateEventOccurrences.ts b/src/jobs/generateEventOccurrences.ts index d7c3b8d..ac7c3e0 100644 --- a/src/jobs/generateEventOccurrences.ts +++ b/src/jobs/generateEventOccurrences.ts @@ -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 recurring→once - * 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 recurring→once + * 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 => { 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