feature: schedule mass times

This commit is contained in:
Benno Tielen 2026-04-09 11:12:54 +02:00
parent ee0fa3678f
commit d4cfec3a98
14 changed files with 38759 additions and 4 deletions

View file

@ -73,6 +73,42 @@ Note: After deploying changes to production, ensure migrations are executed agai
Once the server is running, open:
- http://localhost:3000/admin
## Recurring mass times (jobs queue)
Churches have a `recurringSchedule` field on their admin page where editors can
configure wiederkehrende Gottesdienste — weekly, biweekly (with an anchor date
for the 2-week parity), or monthly by weekday (e.g. every 3rd Sunday). These
entries are not rendered directly: a background job materializes them into real
`Worship` documents for a rolling window a few weeks into the future, so the
existing `MassTimesBlock`, gemeinde pages and `/gottesdienst/{id}` detail pages
work unchanged. Generated documents are normal `Worship` docs — an editor can
still cancel a specific occurrence, change the celebrant, etc., and the job
will never overwrite manual edits (it only ever appends new rows).
The task is defined in `src/jobs/generateRecurringMasses.ts` and is triggered
three ways:
1. **On save**`Churches.afterChange` queues a job whenever a schedule is
edited.
2. **Weekly cron** — the task carries its own `schedule` so Payload auto-queues
it every Monday at 03:00 to keep the rolling window populated.
3. **Manually** — queue from code with
`payload.jobs.queue({ task: 'generateRecurringMasses' })`, or run the full
queue from the CLI:
```bash
npm run payload jobs:run --cron "* * * * *" --queue default
```
`autoRun` in `payload.config.ts` only fires in production
(`NODE_ENV === 'production'`), so in development you either need the CLI
command above, flip `shouldAutoRun` to `true` temporarily, or hit the "Run now"
button on the Payload Jobs admin page.
> Note: Payload's `autoRun` requires a long-running server. This project ships
> via Docker, so that's fine — but if the app ever moves to a serverless host
> like Vercel, `autoRun` must be replaced with an external cron (e.g.
> Vercel Cron) that invokes the CLI command above.
## Scripts
Common scripts available in `package.json`:
- `npm run dev` — start Next.js dev server

View file

@ -28,10 +28,180 @@ export const Churches: CollectionConfig = {
type: 'textarea',
required: true,
},
{
name: 'recurringSchedule',
type: 'array',
label: {
de: 'Wiederkehrende Messzeiten',
},
labels: {
singular: {
de: 'Wiederkehrende Messzeit',
},
plural: {
de: 'Wiederkehrende Messzeiten',
},
},
admin: {
description:
'Wiederkehrende Gottesdienste werden automatisch für die nächsten Wochen als Einträge in der Gottesdienstliste angelegt. Einzelne Termine können dort weiterhin manuell bearbeitet (z. B. abgesagt) werden.',
},
fields: [
{
type: 'row',
fields: [
{
name: 'type',
label: {
de: 'Art',
},
type: 'select',
required: true,
options: [
{ label: 'Heilige Messe', value: 'MASS' },
{ label: 'Familien Messe', value: 'FAMILY' },
{ label: 'Wort-Gottes-Feier', value: 'WORD' },
],
},
{
name: 'frequency',
label: {
de: 'Häufigkeit',
},
type: 'select',
required: true,
defaultValue: 'weekly',
options: [
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' },
{
label: 'Monatlich (n-ter Wochentag)',
value: 'monthlyByWeekday',
},
],
},
],
},
{
type: 'row',
fields: [
{
name: 'day',
label: {
de: 'Tag',
},
type: 'select',
required: true,
options: [
{ label: 'Montag', value: 'monday' },
{ label: 'Dienstag', value: 'tuesday' },
{ label: 'Mittwoch', value: 'wednesday' },
{ label: 'Donnerstag', value: 'thursday' },
{ label: 'Freitag', value: 'friday' },
{ label: 'Samstag', value: 'saturday' },
{ label: 'Sonntag', value: 'sunday' },
],
},
{
name: 'time',
label: {
de: 'Uhrzeit',
},
type: 'date',
required: true,
admin: {
date: {
pickerAppearance: 'timeOnly',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
],
},
{
name: 'weekOfMonth',
label: {
de: 'Wochentag im Monat',
},
type: 'select',
options: [
{ label: '1.', value: 'first' },
{ label: '2.', value: 'second' },
{ label: '3.', value: 'third' },
{ label: '4.', value: 'fourth' },
{ label: 'Letzter', value: 'last' },
],
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByWeekday',
description:
'z. B. „3.“ + „Sonntag“ = jeden 3. Sonntag im Monat',
},
},
{
name: 'biweeklyAnchor',
label: {
de: 'Anker-Datum (alle 2 Wochen)',
},
type: 'date',
admin: {
date: {
pickerAppearance: 'dayOnly',
},
condition: (_data, siblingData) =>
siblingData?.frequency === 'biweekly',
description:
'Ein Datum, an dem dieser Termin stattfindet. Davon ausgehend wird im 2-Wochen-Rhythmus weitergerechnet.',
},
},
{
type: 'row',
fields: [
{
name: 'defaultCelebrant',
label: {
de: 'Standard-Zelebrant',
},
type: 'text',
},
{
name: 'defaultTitle',
label: {
de: 'Standard-Titel',
},
type: 'text',
},
],
},
{
name: 'defaultDescription',
label: {
de: 'Standard-Hinweise',
},
type: 'textarea',
admin: {
description:
'Wird als „Hinweise“ auf jeden automatisch erzeugten Gottesdienst übernommen, z. B. „Vor der Messe beten wir den Rosenkranz.“',
},
},
{
name: 'notes',
label: {
de: 'Notiz',
},
type: 'text',
admin: {
description:
'Nur intern, z. B. „Sommerferien: ausgesetzt“. Beeinflusst die Generierung nicht.',
},
},
],
},
],
admin: {
useAsTitle: 'name',
hidden: hide
hidden: hide,
},
access: {
read: () => true,
@ -39,4 +209,22 @@ export const Churches: CollectionConfig = {
update: isAdminOrEmployee(),
delete: isAdminOrEmployee(),
},
hooks: {
afterChange: [
async ({ doc, req }) => {
if (!doc.recurringSchedule?.length) return
try {
await req.payload.jobs.queue({
task: 'generateRecurringMasses',
input: { churchId: doc.id },
})
} catch (err) {
req.payload.logger.error(
{ err, churchId: doc.id },
'Failed to queue generateRecurringMasses job',
)
}
},
],
},
}

View file

@ -74,6 +74,9 @@ export const Worship: CollectionConfig = {
label: {
de: 'Abgesagt',
},
admin: {
position: 'sidebar',
},
},
{
name: 'liturgicalDay',
@ -98,6 +101,18 @@ export const Worship: CollectionConfig = {
de: 'Hinweise',
},
},
{
name: 'generated',
type: 'checkbox',
defaultValue: false,
label: {
de: 'Automatisch erzeugt',
},
admin: {
readOnly: true,
position: 'sidebar',
},
},
],
admin: {
defaultColumns: ["date", 'location', 'type', 'celebrant'],

View file

@ -57,7 +57,7 @@ export const fetchWorship = async (
location: true,
title: true,
},
limit: 15,
limit: 100,
}) as Promise<PaginatedDocs<Worship>>
}

View file

@ -0,0 +1,197 @@
import type { TaskConfig } from 'payload'
import {
combineDateAndTime,
generateOccurrenceDates,
type ScheduleEntry,
} from '@/jobs/lib/scheduleOccurrences'
/**
* Payload Jobs Queue task that materializes each Church's
* `recurringSchedule` into real `Worship` documents for the coming weeks.
*
* Rationale: the rest of the app (MassTimesBlock, gemeinde pages,
* /gottesdienst/{id} detail pages) all read from the Worship collection
* via `fetchWorship`. By generating real documents instead of rendering
* from the schedule on the fly, we keep a single source of truth and
* avoid duplicating rendering logic. Generated docs are normal Worship
* documents and can be edited by hand (cancel, change celebrant, etc.).
*
* Triggers:
* - `afterChange` hook on Church (see collections/Churches.ts) queues
* a run whenever a schedule is saved
* - Own `schedule` entry below runs weekly to keep the rolling window
* populated
* - Manual: call `payload.jobs.queue({ task: 'generateRecurringMasses' })`
*/
// How far into the future we materialize documents on each run.
// The weekly cron keeps this rolling window populated.
const DEFAULT_WEEKS_AHEAD = 2
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
type GenerateRecurringMassesInput = {
weeksAhead?: number
churchId?: string
}
type GenerateRecurringMassesOutput = {
created: number
skipped: number
}
export const generateRecurringMassesTask: TaskConfig<{
input: GenerateRecurringMassesInput
output: GenerateRecurringMassesOutput
}> = {
slug: 'generateRecurringMasses',
label: 'Wiederkehrende Messzeiten erzeugen',
inputSchema: [
{
name: 'weeksAhead',
type: 'number',
required: false,
},
{
name: 'churchId',
type: 'text',
required: false,
},
],
outputSchema: [
{ name: 'created', type: 'number' },
{ name: 'skipped', type: 'number' },
],
// Weekly cron to keep the rolling window populated even if nobody
// edits a schedule. Payload's 6-field cron format starts with seconds.
// → every Monday at 03:00 server time.
schedule: [
{
cron: '0 0 3 * * 1',
queue: 'default',
},
],
handler: async ({ input, req }) => {
const { payload } = req
const weeksAhead = input?.weeksAhead ?? DEFAULT_WEEKS_AHEAD
const now = new Date()
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
// Scope to a single church when invoked from the Church afterChange
// hook, otherwise process every church in one run.
const churchesResult = await payload.find({
collection: 'church',
depth: 0,
limit: 1000,
pagination: false,
where: input?.churchId
? { id: { equals: input.churchId } }
: undefined,
})
let created = 0
let skipped = 0
for (const church of churchesResult.docs) {
// Cast needed because payload-types only exposes recurringSchedule
// on the full Church interface and payload.find returns a looser
// shape at depth: 0.
const schedule = (church as { recurringSchedule?: unknown[] })
.recurringSchedule
if (!Array.isArray(schedule) || schedule.length === 0) continue
for (const rawEntry of schedule) {
const entry = rawEntry as ScheduleEntry & {
time?: string | Date | null
type?: 'MASS' | 'FAMILY' | 'WORD'
defaultCelebrant?: string | null
defaultTitle?: string | null
defaultDescription?: string | null
}
// Guard: required fields may be missing if an editor saved a
// half-filled row. Skip rather than crash the whole run.
if (!entry.time || !entry.type) {
skipped += 1
continue
}
// Resolve the entry's recurrence pattern (weekly / biweekly /
// monthly Nth weekday) into concrete calendar dates in the
// [now, horizon] window. Returns dates at midnight only — we
// combine with the time-of-day below.
const occurrenceDates = generateOccurrenceDates(entry, now, horizon)
if (occurrenceDates.length === 0) {
skipped += 1
continue
}
const timeSource = new Date(entry.time)
for (const occurrenceDate of occurrenceDates) {
// Build the real Worship.date as (calendar date) + (HH:mm from
// the schedule) using local-time components. This is the step
// that keeps DST transitions from shifting the wall-clock hour.
const date = combineDateAndTime(occurrenceDate, timeSource)
// `now` could land mid-week: skip any occurrence that already
// passed earlier today.
if (date.getTime() < now.getTime()) {
skipped += 1
continue
}
// Append-only: if *any* Worship doc already exists at this
// exact (church, timestamp) slot — whether generated by a
// previous run or entered manually — leave it alone. This is
// what protects admin edits (cancellations, celebrant changes,
// etc.) from being overwritten.
const existing = await payload.find({
collection: 'worship',
depth: 0,
limit: 1,
pagination: false,
where: {
and: [
{ location: { equals: church.id } },
{ date: { equals: date.toISOString() } },
],
},
})
if (existing.docs.length > 0) {
skipped += 1
continue
}
// `generated: true` marks this as auto-created so future
// cleanup tooling can target it without touching manual rows.
await payload.create({
collection: 'worship',
data: {
date: date.toISOString(),
location: church.id,
type: entry.type,
cancelled: false,
title: entry.defaultTitle || undefined,
celebrant: entry.defaultCelebrant || undefined,
description: entry.defaultDescription || undefined,
generated: true,
},
})
created += 1
}
}
}
// Counts surface on the Payload Jobs admin page as the task output.
payload.logger.info(
{ created, skipped, weeksAhead },
'generateRecurringMasses finished',
)
return {
output: { created, skipped },
}
},
}

View file

@ -0,0 +1,219 @@
import { describe, it, expect } from 'vitest'
import {
combineDateAndTime,
generateOccurrenceDates,
type ScheduleEntry,
} from './scheduleOccurrences'
// All dates in this file use the local-time Date constructor so they
// are independent of the machine's timezone. Months are 0-indexed:
// new Date(2026, 3, 13) === 13 April 2026 local time
const d = (year: number, month1to12: number, day: number): Date =>
new Date(year, month1to12 - 1, day)
// Reference calendar used throughout these tests:
// April 2026 — Wed 1, Sun 5/12/19/26, Mon 6/13/20/27 (only 4 Mondays)
// March 2026 — Sun 1, Mon 2/9/16/23/30 (five Mondays, last = 30)
describe('generateOccurrenceDates weekly', () => {
it('yields the next N sundays in the window', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }
const result = generateOccurrenceDates(entry, d(2026, 4, 6), d(2026, 4, 27))
expect(result.map((x) => x.getDate())).toEqual([12, 19, 26])
})
it('includes the start day itself if it matches the target weekday', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }
// 2026-04-05 is a Sunday
const result = generateOccurrenceDates(entry, d(2026, 4, 5), d(2026, 4, 19))
expect(result.map((x) => x.getDate())).toEqual([5, 12, 19])
})
it('returns an empty array when the window is shorter than a week and misses the target', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }
// Mon -> Sat, no Sunday in range
const result = generateOccurrenceDates(entry, d(2026, 4, 6), d(2026, 4, 11))
expect(result).toEqual([])
})
it('works for weekdays other than sunday', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'wednesday' }
// Apr 1 is a Wednesday
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 4, 30))
expect(result.map((x) => x.getDate())).toEqual([1, 8, 15, 22, 29])
})
})
describe('generateOccurrenceDates biweekly', () => {
it('yields dates aligned with the anchor, every two weeks', () => {
const entry: ScheduleEntry = {
frequency: 'biweekly',
day: 'sunday',
biweeklyAnchor: d(2026, 4, 5), // a Sunday
}
const result = generateOccurrenceDates(entry, d(2026, 4, 6), d(2026, 5, 31))
// from anchor: 4/5, 4/19, 5/3, 5/17, 5/31 — window starts after 4/5
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-19',
'5-3',
'5-17',
'5-31',
])
})
it('skips the off-parity week when the naive first-occurrence lands on it', () => {
const entry: ScheduleEntry = {
frequency: 'biweekly',
day: 'sunday',
biweeklyAnchor: d(2026, 4, 5),
}
// Start on 4/12 — that's a Sunday but off-parity (7 days from anchor).
// Next valid occurrence should be 4/19, not 4/12.
const result = generateOccurrenceDates(entry, d(2026, 4, 12), d(2026, 4, 26))
expect(result.map((x) => x.getDate())).toEqual([19])
})
it('handles anchor in the future by rolling backward in parity', () => {
const entry: ScheduleEntry = {
frequency: 'biweekly',
day: 'sunday',
biweeklyAnchor: d(2026, 6, 7), // future Sunday
}
const result = generateOccurrenceDates(entry, d(2026, 4, 6), d(2026, 5, 31))
// anchor parity backward: 6/7 - 14 = 5/24, - 14 = 5/10, -14 = 4/26, -14 = 4/12
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-12',
'4-26',
'5-10',
'5-24',
])
})
it('returns empty when biweeklyAnchor is missing', () => {
const entry: ScheduleEntry = { frequency: 'biweekly', day: 'sunday' }
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 4, 30))
expect(result).toEqual([])
})
})
describe('generateOccurrenceDates monthlyByWeekday', () => {
it('yields the 3rd sunday each month', () => {
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'sunday',
weekOfMonth: 'third',
}
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 30))
// Apr 19, May 17, Jun 21 — let me verify May/June:
// May 2026: Sun 3, 10, 17, 24, 31 → third = 17
// Jun 2026: Sun 7, 14, 21, 28 → third = 21
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-19',
'5-17',
'6-21',
])
})
it('yields the last sunday each month', () => {
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'sunday',
weekOfMonth: 'last',
}
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 6, 30))
// Apr 26, May 31, Jun 28
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'4-26',
'5-31',
'6-28',
])
})
it('treats "fourth" as the 4th occurrence, not the last', () => {
// March 2026 has 5 Mondays (2, 9, 16, 23, 30).
// "fourth" should be 23, NOT 30.
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'monday',
weekOfMonth: 'fourth',
}
const result = generateOccurrenceDates(entry, d(2026, 3, 1), d(2026, 3, 31))
expect(result.map((x) => x.getDate())).toEqual([23])
})
it('works when the start-of-window is after that month\'s occurrence', () => {
// 3rd Sunday of April 2026 is 4/19. Start the window on 4/20.
// April should be skipped; May should still appear.
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'sunday',
weekOfMonth: 'third',
}
const result = generateOccurrenceDates(entry, d(2026, 4, 20), d(2026, 5, 31))
expect(result.map((x) => `${x.getMonth() + 1}-${x.getDate()}`)).toEqual([
'5-17',
])
})
it('returns empty when weekOfMonth is missing', () => {
const entry: ScheduleEntry = {
frequency: 'monthlyByWeekday',
day: 'sunday',
}
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 4, 30))
expect(result).toEqual([])
})
})
describe('generateOccurrenceDates edge cases', () => {
it('returns empty when before < after', () => {
const entry: ScheduleEntry = { frequency: 'weekly', day: 'sunday' }
const result = generateOccurrenceDates(entry, d(2026, 4, 30), d(2026, 4, 1))
expect(result).toEqual([])
})
it('returns empty for an unknown day value', () => {
const entry = {
frequency: 'weekly',
day: 'someday',
} as unknown as ScheduleEntry
const result = generateOccurrenceDates(entry, d(2026, 4, 1), d(2026, 4, 30))
expect(result).toEqual([])
})
})
describe('combineDateAndTime', () => {
it('copies HH:mm from timeSource onto the calendar date', () => {
const date = d(2026, 4, 19)
const time = new Date(1970, 0, 1, 10, 30)
const combined = combineDateAndTime(date, time)
expect(combined.getFullYear()).toBe(2026)
expect(combined.getMonth()).toBe(3)
expect(combined.getDate()).toBe(19)
expect(combined.getHours()).toBe(10)
expect(combined.getMinutes()).toBe(30)
expect(combined.getSeconds()).toBe(0)
expect(combined.getMilliseconds()).toBe(0)
})
it('zeroes seconds and milliseconds even when the source has them', () => {
const date = d(2026, 4, 19)
const time = new Date(1970, 0, 1, 18, 45, 42, 123)
const combined = combineDateAndTime(date, time)
expect(combined.getHours()).toBe(18)
expect(combined.getMinutes()).toBe(45)
expect(combined.getSeconds()).toBe(0)
expect(combined.getMilliseconds()).toBe(0)
})
it('ignores the year/month/day of the timeSource', () => {
const date = d(2026, 4, 19)
const time = new Date(1999, 11, 31, 8, 0)
const combined = combineDateAndTime(date, time)
expect(combined.getFullYear()).toBe(2026)
expect(combined.getMonth()).toBe(3)
expect(combined.getDate()).toBe(19)
expect(combined.getHours()).toBe(8)
})
})

View file

@ -0,0 +1,189 @@
export type ScheduleDay =
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday'
export type ScheduleFrequency = 'weekly' | 'biweekly' | 'monthlyByWeekday'
export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last'
export interface ScheduleEntry {
frequency: ScheduleFrequency
day: ScheduleDay
weekOfMonth?: WeekOfMonth | null
biweeklyAnchor?: string | Date | null
}
// JS getDay(): 0 = Sunday ... 6 = Saturday
const DAY_INDEX: Record<ScheduleDay, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
}
const WEEK_OF_MONTH_N: Record<WeekOfMonth, number> = {
first: 1,
second: 2,
third: 3,
fourth: 4,
last: -1,
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
const startOfDay = (d: Date): Date => {
const r = new Date(d)
r.setHours(0, 0, 0, 0)
return r
}
const addDays = (d: Date, n: number): Date => {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
const firstOccurrenceOnOrAfter = (from: Date, targetDay: number): Date => {
const start = startOfDay(from)
const diff = (targetDay - start.getDay() + 7) % 7
return addDays(start, diff)
}
/**
* For a given (year, month), return the date of the Nth occurrence of
* `targetDay`. `n` is 1..4 for first..fourth, or -1 for "last".
* Returns null if the month has no such occurrence (e.g. asking for the
* 5th Monday in a month that only has 4).
*/
const nthWeekdayOfMonth = (
year: number,
month: number,
targetDay: number,
n: number,
): Date | null => {
if (n > 0) {
const first = new Date(year, month, 1)
const diff = (targetDay - first.getDay() + 7) % 7
const day = 1 + diff + (n - 1) * 7
const daysInMonth = new Date(year, month + 1, 0).getDate()
if (day > daysInMonth) return null
return new Date(year, month, day)
}
// last occurrence of targetDay in month
const lastDay = new Date(year, month + 1, 0)
const diff = (lastDay.getDay() - targetDay + 7) % 7
return new Date(year, month, lastDay.getDate() - diff)
}
/**
* Return every calendar date (time-of-day at local midnight) on which
* this schedule entry should fire, within [after, before].
*
* Dates only the caller combines each date with the schedule's
* time-of-day via {@link combineDateAndTime}. Splitting date from time
* makes DST transitions a non-issue: we always build the final Date
* with a local-time constructor.
*/
export const generateOccurrenceDates = (
entry: ScheduleEntry,
after: Date,
before: Date,
): Date[] => {
const targetDay = DAY_INDEX[entry.day]
if (targetDay === undefined) return []
if (before.getTime() < after.getTime()) return []
const dates: Date[] = []
const afterStart = startOfDay(after)
const beforeEnd = startOfDay(before)
switch (entry.frequency) {
case 'weekly': {
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay)
while (cursor.getTime() <= beforeEnd.getTime()) {
dates.push(cursor)
cursor = addDays(cursor, 7)
}
return dates
}
case 'biweekly': {
if (!entry.biweeklyAnchor) return []
const anchor = startOfDay(new Date(entry.biweeklyAnchor))
if (Number.isNaN(anchor.getTime())) return []
let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay)
// Align cursor with anchor's 2-week parity. Compute the whole-day
// delta using midday to avoid DST rounding pushing it off by one.
const daysFromAnchor = Math.round(
(cursor.getTime() + MS_PER_DAY / 2 - (anchor.getTime() + MS_PER_DAY / 2)) /
MS_PER_DAY,
)
const mod = ((daysFromAnchor % 14) + 14) % 14
if (mod !== 0) cursor = addDays(cursor, 14 - mod)
while (cursor.getTime() <= beforeEnd.getTime()) {
if (cursor.getTime() >= afterStart.getTime()) dates.push(cursor)
cursor = addDays(cursor, 14)
}
return dates
}
case 'monthlyByWeekday': {
if (!entry.weekOfMonth) return []
const n = WEEK_OF_MONTH_N[entry.weekOfMonth]
let year = afterStart.getFullYear()
let month = afterStart.getMonth()
const endYear = beforeEnd.getFullYear()
const endMonth = beforeEnd.getMonth()
while (year < endYear || (year === endYear && month <= endMonth)) {
const occ = nthWeekdayOfMonth(year, month, targetDay, n)
if (
occ &&
occ.getTime() >= afterStart.getTime() &&
occ.getTime() <= beforeEnd.getTime()
) {
dates.push(occ)
}
month += 1
if (month > 11) {
month = 0
year += 1
}
}
return dates
}
default:
return []
}
}
/**
* Combine a calendar date (year/month/day from `date`) with a wall-clock
* time taken from `timeSource` (a Date whose local hours/minutes we read).
* Always produced in the server's local timezone, which is what Payload's
* date fields display.
*/
export const combineDateAndTime = (date: Date, timeSource: Date): Date => {
const time = new Date(timeSource)
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
time.getHours(),
time.getMinutes(),
0,
0,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_church_recurring_schedule_type" AS ENUM('MASS', 'FAMILY', 'WORD');
CREATE TYPE "public"."enum_church_recurring_schedule_frequency" AS ENUM('weekly', 'biweekly', 'monthlyByWeekday');
CREATE TYPE "public"."enum_church_recurring_schedule_day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum_church_recurring_schedule_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
CREATE TYPE "public"."enum_payload_jobs_log_task_slug" AS ENUM('inline', 'generateRecurringMasses');
CREATE TYPE "public"."enum_payload_jobs_log_state" AS ENUM('failed', 'succeeded');
CREATE TYPE "public"."enum_payload_jobs_task_slug" AS ENUM('inline', 'generateRecurringMasses');
CREATE TABLE "church_recurring_schedule" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"type" "enum_church_recurring_schedule_type" NOT NULL,
"frequency" "enum_church_recurring_schedule_frequency" DEFAULT 'weekly' NOT NULL,
"day" "enum_church_recurring_schedule_day" NOT NULL,
"time" timestamp(3) with time zone NOT NULL,
"week_of_month" "enum_church_recurring_schedule_week_of_month",
"biweekly_anchor" timestamp(3) with time zone,
"default_celebrant" varchar,
"default_title" varchar,
"notes" varchar
);
CREATE TABLE "payload_jobs_log" (
"_order" integer NOT NULL,
"_parent_id" uuid NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"executed_at" timestamp(3) with time zone NOT NULL,
"completed_at" timestamp(3) with time zone NOT NULL,
"task_slug" "enum_payload_jobs_log_task_slug" NOT NULL,
"task_i_d" varchar NOT NULL,
"input" jsonb,
"output" jsonb,
"state" "enum_payload_jobs_log_state" NOT NULL,
"error" jsonb
);
CREATE TABLE "payload_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"input" jsonb,
"completed_at" timestamp(3) with time zone,
"total_tried" numeric DEFAULT 0,
"has_error" boolean DEFAULT false,
"error" jsonb,
"task_slug" "enum_payload_jobs_task_slug",
"queue" varchar DEFAULT 'default',
"wait_until" timestamp(3) with time zone,
"processing" boolean DEFAULT false,
"meta" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload_jobs_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"stats" jsonb,
"updated_at" timestamp(3) with time zone,
"created_at" timestamp(3) with time zone
);
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-12T14:31:48.373Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-12T14:31:48.665Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-08T14:31:48.733Z';
ALTER TABLE "worship" ADD COLUMN "generated" boolean DEFAULT false;
ALTER TABLE "church_recurring_schedule" ADD CONSTRAINT "church_recurring_schedule_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."church"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload_jobs_log" ADD CONSTRAINT "payload_jobs_log_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."payload_jobs"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "church_recurring_schedule_order_idx" ON "church_recurring_schedule" USING btree ("_order");
CREATE INDEX "church_recurring_schedule_parent_id_idx" ON "church_recurring_schedule" USING btree ("_parent_id");
CREATE INDEX "payload_jobs_log_order_idx" ON "payload_jobs_log" USING btree ("_order");
CREATE INDEX "payload_jobs_log_parent_id_idx" ON "payload_jobs_log" USING btree ("_parent_id");
CREATE INDEX "payload_jobs_completed_at_idx" ON "payload_jobs" USING btree ("completed_at");
CREATE INDEX "payload_jobs_total_tried_idx" ON "payload_jobs" USING btree ("total_tried");
CREATE INDEX "payload_jobs_has_error_idx" ON "payload_jobs" USING btree ("has_error");
CREATE INDEX "payload_jobs_task_slug_idx" ON "payload_jobs" USING btree ("task_slug");
CREATE INDEX "payload_jobs_queue_idx" ON "payload_jobs" USING btree ("queue");
CREATE INDEX "payload_jobs_wait_until_idx" ON "payload_jobs" USING btree ("wait_until");
CREATE INDEX "payload_jobs_processing_idx" ON "payload_jobs" USING btree ("processing");
CREATE INDEX "payload_jobs_updated_at_idx" ON "payload_jobs" USING btree ("updated_at");
CREATE INDEX "payload_jobs_created_at_idx" ON "payload_jobs" USING btree ("created_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "church_recurring_schedule" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_jobs_log" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_jobs" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_jobs_stats" DISABLE ROW LEVEL SECURITY;
DROP TABLE "church_recurring_schedule" CASCADE;
DROP TABLE "payload_jobs_log" CASCADE;
DROP TABLE "payload_jobs" CASCADE;
DROP TABLE "payload_jobs_stats" CASCADE;
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-12T11:56:17.764Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-12T11:56:18.063Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-08T11:56:18.128Z';
ALTER TABLE "worship" DROP COLUMN "generated";
DROP TYPE "public"."enum_church_recurring_schedule_type";
DROP TYPE "public"."enum_church_recurring_schedule_frequency";
DROP TYPE "public"."enum_church_recurring_schedule_day";
DROP TYPE "public"."enum_church_recurring_schedule_week_of_month";
DROP TYPE "public"."enum_payload_jobs_log_task_slug";
DROP TYPE "public"."enum_payload_jobs_log_state";
DROP TYPE "public"."enum_payload_jobs_task_slug";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-12T08:36:37.826Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-12T08:36:38.113Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-09T08:36:38.168Z';
ALTER TABLE "church_recurring_schedule" ADD COLUMN "default_description" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-12T14:31:48.373Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-12T14:31:48.665Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-08T14:31:48.733Z';
ALTER TABLE "church_recurring_schedule" DROP COLUMN "default_description";`)
}

View file

@ -25,6 +25,8 @@ import * as migration_20260319_215840_collaps_item from './20260319_215840_colla
import * as migration_20260319_223804_contactperson_block from './20260319_223804_contactperson_block';
import * as migration_20260319_224419 from './20260319_224419';
import * as migration_20260408_115618 from './20260408_115618';
import * as migration_20260408_143149 from './20260408_143149';
import * as migration_20260409_083638 from './20260409_083638';
export const migrations = [
{
@ -160,6 +162,16 @@ export const migrations = [
{
up: migration_20260408_115618.up,
down: migration_20260408_115618.down,
name: '20260408_115618'
name: '20260408_115618',
},
{
up: migration_20260408_143149.up,
down: migration_20260408_143149.down,
name: '20260408_143149',
},
{
up: migration_20260409_083638.up,
down: migration_20260409_083638.down,
name: '20260409_083638'
},
];

View file

@ -88,6 +88,7 @@ export interface Config {
media: Media;
users: User;
'payload-kv': PayloadKv;
'payload-jobs': PayloadJob;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@ -115,6 +116,7 @@ export interface Config {
media: MediaSelect<false> | MediaSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@ -126,17 +128,25 @@ export interface Config {
globals: {
menu: Menu;
footer: Footer;
'payload-jobs-stats': PayloadJobsStat;
};
globalsSelect: {
menu: MenuSelect<false> | MenuSelect<true>;
footer: FooterSelect<false> | FooterSelect<true>;
'payload-jobs-stats': PayloadJobsStatsSelect<false> | PayloadJobsStatsSelect<true>;
};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
tasks: {
generateRecurringMasses: TaskGenerateRecurringMasses;
inline: {
input: unknown;
output: unknown;
};
};
workflows: unknown;
};
}
@ -247,6 +257,36 @@ export interface Church {
id: string;
name: string;
address: string;
/**
* Wiederkehrende Gottesdienste werden automatisch für die nächsten Wochen als Einträge in der Gottesdienstliste angelegt. Einzelne Termine können dort weiterhin manuell bearbeitet (z. B. abgesagt) werden.
*/
recurringSchedule?:
| {
type: 'MASS' | 'FAMILY' | 'WORD';
frequency: 'weekly' | 'biweekly' | 'monthlyByWeekday';
day: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
time: string;
/**
* z. B. 3. + Sonntag = jeden 3. Sonntag im Monat
*/
weekOfMonth?: ('first' | 'second' | 'third' | 'fourth' | 'last') | null;
/**
* Ein Datum, an dem dieser Termin stattfindet. Davon ausgehend wird im 2-Wochen-Rhythmus weitergerechnet.
*/
biweeklyAnchor?: string | null;
defaultCelebrant?: string | null;
defaultTitle?: string | null;
/**
* Wird als Hinweise auf jeden automatisch erzeugten Gottesdienst übernommen, z. B. Vor der Messe beten wir den Rosenkranz.
*/
defaultDescription?: string | null;
/**
* Nur intern, z. B. Sommerferien: ausgesetzt. Beeinflusst die Generierung nicht.
*/
notes?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
@ -341,6 +381,7 @@ export interface Worship {
liturgicalDay?: string | null;
celebrant?: string | null;
description?: string | null;
generated?: boolean | null;
updatedAt: string;
createdAt: string;
}
@ -964,6 +1005,107 @@ export interface PayloadKv {
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs".
*/
export interface PayloadJob {
id: string;
/**
* Input data provided to the job
*/
input?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
taskStatus?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
completedAt?: string | null;
totalTried?: number | null;
/**
* If hasError is true this job will not be retried
*/
hasError?: boolean | null;
/**
* If hasError is true, this is the error that caused it
*/
error?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Task execution log
*/
log?:
| {
executedAt: string;
completedAt: string;
taskSlug: 'inline' | 'generateRecurringMasses';
taskID: string;
input?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
output?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
state: 'failed' | 'succeeded';
error?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
id?: string | null;
}[]
| null;
taskSlug?: ('inline' | 'generateRecurringMasses') | null;
queue?: string | null;
waitUntil?: string | null;
processing?: boolean | null;
meta?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@ -1168,6 +1310,21 @@ export interface ParishSelect<T extends boolean = true> {
export interface ChurchSelect<T extends boolean = true> {
name?: T;
address?: T;
recurringSchedule?:
| T
| {
type?: T;
frequency?: T;
day?: T;
time?: T;
weekOfMonth?: T;
biweeklyAnchor?: T;
defaultCelebrant?: T;
defaultTitle?: T;
defaultDescription?: T;
notes?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
@ -1184,6 +1341,7 @@ export interface WorshipSelect<T extends boolean = true> {
liturgicalDay?: T;
celebrant?: T;
description?: T;
generated?: T;
updatedAt?: T;
createdAt?: T;
}
@ -1775,6 +1933,38 @@ export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs_select".
*/
export interface PayloadJobsSelect<T extends boolean = true> {
input?: T;
taskStatus?: T;
completedAt?: T;
totalTried?: T;
hasError?: T;
error?: T;
log?:
| T
| {
executedAt?: T;
completedAt?: T;
taskSlug?: T;
taskID?: T;
input?: T;
output?: T;
state?: T;
error?: T;
id?: T;
};
taskSlug?: T;
queue?: T;
waitUntil?: T;
processing?: T;
meta?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
@ -1898,6 +2088,24 @@ export interface Footer {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs-stats".
*/
export interface PayloadJobsStat {
id: string;
stats?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "menu_select".
@ -2001,6 +2209,30 @@ export interface FooterSelect<T extends boolean = true> {
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-jobs-stats_select".
*/
export interface PayloadJobsStatsSelect<T extends boolean = true> {
stats?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskGenerateRecurringMasses".
*/
export interface TaskGenerateRecurringMasses {
input: {
weeksAhead?: number | null;
churchId?: string | null;
};
output: {
created?: number | null;
skipped?: number | null;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View file

@ -43,6 +43,7 @@ import { DonationForms } from '@/collections/DonationForms'
import { Pages } from '@/collections/Pages'
import { Prayers } from '@/collections/Prayers'
import { siteConfig } from '@/config/site'
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -104,6 +105,27 @@ export default buildConfig({
MenuGlobal,
FooterGlobal,
],
jobs: {
tasks: [generateRecurringMassesTask],
autoRun: [
{
// every 15 minutes (6-field cron, seconds first)
cron: '0 */15 * * * *',
queue: 'default',
limit: 10,
},
],
// show jobs in the admin panel
jobsCollectionOverrides: ({ defaultJobsCollection }) => {
if (!defaultJobsCollection.admin) {
defaultJobsCollection.admin = {}
}
defaultJobsCollection.admin.hidden = process.env.NODE_ENV === 'production'
return defaultJobsCollection
},
shouldAutoRun: () => process.env.NODE_ENV === 'production',
},
graphQL: {
disable: true
},