Compare commits
No commits in common. "1457fb49204241ac1a55e1751e7de928425aa794" and "e96d04c317c097dc4e271751a8dcb365e33b5370" have entirely different histories.
1457fb4920
...
e96d04c317
3 changed files with 60 additions and 125 deletions
|
|
@ -10,31 +10,24 @@ import {
|
||||||
* Materializes each Event's recurrence rule into `eventOccurrence` rows for
|
* Materializes each Event's recurrence rule into `eventOccurrence` rows for
|
||||||
* a rolling window.
|
* a rolling window.
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Per event we wipe every future occurrence and regenerate from the current
|
||||||
* - `replace` (default): wipe every future occurrence and regenerate from
|
* rule. Occurrences are derivative pointers to the parent event, so there
|
||||||
* the current rule. Used by the Events `afterChange` hook, where the rule
|
* is no editor-edited state to preserve — rule edits and recurring→once
|
||||||
* or date may have changed — wiping keeps rule edits and recurring→once
|
* transitions stay trivially correct.
|
||||||
* 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
|
* Weekday and time-of-day come directly from `event.date`. Calendar-date
|
||||||
* generation lives in scheduleOccurrences.ts (DST-safe by construction:
|
* generation lives in scheduleOccurrences.ts (DST-safe by construction:
|
||||||
* dates are built at local midnight and then stitched with the event's
|
* dates are built at local midnight and then stitched with the event's
|
||||||
* HH:mm).
|
* HH:mm).
|
||||||
*
|
*
|
||||||
* Exposed both as a Payload Jobs Queue task (cron backfill) and as a direct
|
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a
|
||||||
* function called from the Events `afterChange` hook so edits are reflected
|
* direct function called from the Events `afterChange` hook so edits are
|
||||||
* immediately without waiting for a queue worker.
|
* reflected immediately without waiting for a queue worker.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_WEEKS_AHEAD = 8
|
const DEFAULT_WEEKS_AHEAD = 8
|
||||||
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
|
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
type RegenerateMode = 'replace' | 'append'
|
|
||||||
|
|
||||||
type GenerateEventOccurrencesInput = {
|
type GenerateEventOccurrencesInput = {
|
||||||
weeksAhead?: number
|
weeksAhead?: number
|
||||||
eventId?: string
|
eventId?: string
|
||||||
|
|
@ -67,22 +60,19 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
req,
|
req,
|
||||||
weeksAhead = DEFAULT_WEEKS_AHEAD,
|
weeksAhead = DEFAULT_WEEKS_AHEAD,
|
||||||
now = new Date(),
|
now = new Date(),
|
||||||
mode = 'replace',
|
|
||||||
}: {
|
}: {
|
||||||
event: EventLike
|
event: EventLike
|
||||||
payload: Payload
|
payload: Payload
|
||||||
req?: PayloadRequest
|
req?: PayloadRequest
|
||||||
weeksAhead?: number
|
weeksAhead?: number
|
||||||
now?: Date
|
now?: Date
|
||||||
mode?: RegenerateMode
|
|
||||||
}): Promise<GenerateEventOccurrencesOutput> => {
|
}): Promise<GenerateEventOccurrencesOutput> => {
|
||||||
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
|
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
|
||||||
|
|
||||||
// Existing future occurrences. Used to (replace) snapshot cancellations so
|
// Snapshot cancelled dates so manual cancellations survive the wipe/regen.
|
||||||
// they survive the wipe, and (append) dedup so we don't recreate slots that
|
// Matching by ISO date works because regen derives timestamps from the same
|
||||||
// already exist. Matching by ISO date works because regen derives
|
// event.date stepped by whole calendar days — shi)fts in event.date
|
||||||
// timestamps from the same event.date stepped by whole calendar days —
|
// intentionally drop stale cancellations.
|
||||||
// shifts in event.date intentionally drop stale cancellations.
|
|
||||||
const existing = await payload.find({
|
const existing = await payload.find({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -101,19 +91,7 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
|
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
|
||||||
.map((occ) => new Date(occ.date as string).toISOString()),
|
.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()),
|
|
||||||
)
|
|
||||||
|
|
||||||
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({
|
const wipe = await payload.delete({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -124,8 +102,9 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
})
|
})
|
||||||
deleted = wipe.docs.length
|
let deleted = wipe.docs.length
|
||||||
}
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
if (!event.date) {
|
if (!event.date) {
|
||||||
return { created, deleted, skipped: skipped + 1 }
|
return { created, deleted, skipped: skipped + 1 }
|
||||||
|
|
@ -149,21 +128,16 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
const recurrenceType = event.recurrenceType ?? 'none'
|
const recurrenceType = event.recurrenceType ?? 'none'
|
||||||
|
|
||||||
if (recurrenceType === 'none') {
|
if (recurrenceType === 'none') {
|
||||||
const iso = eventDate.toISOString()
|
|
||||||
if (eventDate.getTime() < now.getTime()) {
|
if (eventDate.getTime() < now.getTime()) {
|
||||||
return { created, deleted, skipped: skipped + 1 }
|
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({
|
await payload.create({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
data: {
|
data: {
|
||||||
event: event.id,
|
event: event.id,
|
||||||
date: iso,
|
date: eventDate.toISOString(),
|
||||||
endDateTime: endFor(eventDate),
|
endDateTime: endFor(eventDate),
|
||||||
cancelled: cancelledDates.has(iso),
|
cancelled: cancelledDates.has(eventDate.toISOString()),
|
||||||
generated: true,
|
generated: true,
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
|
|
@ -178,12 +152,6 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
|
const dates = expandEventOccurrences(event, { now, horizon: effectiveEnd })
|
||||||
for (const date of dates) {
|
for (const date of dates) {
|
||||||
const iso = date.toISOString()
|
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({
|
await payload.create({
|
||||||
collection: 'eventOccurrence',
|
collection: 'eventOccurrence',
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -195,7 +163,6 @@ export const regenerateOccurrencesForEvent = async ({
|
||||||
},
|
},
|
||||||
req,
|
req,
|
||||||
})
|
})
|
||||||
existingSlots.add(iso)
|
|
||||||
created += 1
|
created += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,10 +214,6 @@ export const generateEventOccurrencesTask: TaskConfig<{
|
||||||
payload,
|
payload,
|
||||||
weeksAhead,
|
weeksAhead,
|
||||||
now,
|
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
|
created += result.created
|
||||||
deleted += result.deleted
|
deleted += result.deleted
|
||||||
|
|
|
||||||
|
|
@ -56,26 +56,20 @@ 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.
|
||||||
*
|
*
|
||||||
* Two modes:
|
* Wipe-and-regenerate: every future auto-generated (`generated: true`)
|
||||||
* - `replace` (default): wipe every future auto-generated (`generated: true`)
|
* Worship doc for this church is deleted and rebuilt from the current
|
||||||
* Worship doc for this church and rebuild from the current schedule. Used
|
* schedule, so a changed/cleared schedule never leaves stale or double
|
||||||
* by the Church `afterChange` hook, where the schedule may have *changed* —
|
* masses behind. Hand-created Worship docs are never touched. Manual
|
||||||
* wiping is what stops a changed/cleared schedule from leaving stale or
|
* cancellations on generated docs are snapshotted by date and re-applied
|
||||||
* double masses behind. Manual cancellations on generated docs are
|
* so they survive the regen (mirrors Events/eventOccurrence).
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Hand-created Worship docs (generated !== true) are never deleted in either
|
* Exposed both as a direct function (called from the Church `afterChange`
|
||||||
* mode, and never duplicated in `append` mode.
|
* hook so edits reflect immediately) and via the Jobs Queue task below
|
||||||
|
* (weekly cron backfill across all churches).
|
||||||
*/
|
*/
|
||||||
export const regenerateMassesForChurch = async ({
|
export const regenerateMassesForChurch = async ({
|
||||||
church,
|
church,
|
||||||
|
|
@ -83,14 +77,12 @@ 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)
|
||||||
|
|
||||||
|
|
@ -100,16 +92,16 @@ export const regenerateMassesForChurch = async ({
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
let deleted = 0
|
|
||||||
|
|
||||||
// Existing Worship docs in the window. We use these to (replace) snapshot
|
// Snapshot manual cancellations on generated docs so they survive the
|
||||||
// cancellations so they survive the wipe, and (append) dedup so we don't
|
// wipe/regen. Matched by ISO timestamp because regen rebuilds the same
|
||||||
// recreate slots that already exist.
|
// (church, date) slots from the schedule.
|
||||||
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() } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -118,20 +110,16 @@ 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()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (mode === 'replace') {
|
|
||||||
// Wipe future auto-generated docs, then regenerate from the current
|
// Wipe future auto-generated docs, then regenerate from the current
|
||||||
// schedule. Hand-created docs (generated !== true) are untouched.
|
// 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({
|
const wipe = await payload.delete({
|
||||||
collection: 'worship',
|
collection: 'worship',
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -143,8 +131,7 @@ export const regenerateMassesForChurch = async ({
|
||||||
},
|
},
|
||||||
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) {
|
||||||
|
|
@ -190,25 +177,16 @@ export const regenerateMassesForChurch = async ({
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const iso = date.toISOString()
|
// `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
|
||||||
// In append mode the future docs were never wiped, so leave any slot
|
// cancellation the editor had set on this slot before the wipe.
|
||||||
// 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: iso,
|
date: date.toISOString(),
|
||||||
location: church.id,
|
location: church.id,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
cancelled: cancelledDates.has(iso),
|
cancelled: cancelledDates.has(date.toISOString()),
|
||||||
title: entry.defaultTitle || undefined,
|
title: entry.defaultTitle || undefined,
|
||||||
celebrant: entry.defaultCelebrant || undefined,
|
celebrant: entry.defaultCelebrant || undefined,
|
||||||
description: entry.defaultDescription || undefined,
|
description: entry.defaultDescription || undefined,
|
||||||
|
|
@ -217,8 +195,6 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -289,10 +265,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,8 @@ export default buildConfig({
|
||||||
tasks: [generateRecurringMassesTask, generateEventOccurrencesTask],
|
tasks: [generateRecurringMassesTask, generateEventOccurrencesTask],
|
||||||
autoRun: [
|
autoRun: [
|
||||||
{
|
{
|
||||||
// once a day at 03:00 (6-field cron, seconds first)
|
// every 15 minutes (6-field cron, seconds first)
|
||||||
cron: '0 0 3 * * *',
|
cron: '0 */15 * * * *',
|
||||||
queue: 'default',
|
queue: 'default',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue