feature: schedule mass times
This commit is contained in:
parent
ee0fa3678f
commit
d4cfec3a98
14 changed files with 38759 additions and 4 deletions
36
README.md
36
README.md
|
|
@ -73,6 +73,42 @@ Note: After deploying changes to production, ensure migrations are executed agai
|
||||||
Once the server is running, open:
|
Once the server is running, open:
|
||||||
- http://localhost:3000/admin
|
- 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
|
## Scripts
|
||||||
Common scripts available in `package.json`:
|
Common scripts available in `package.json`:
|
||||||
- `npm run dev` — start Next.js dev server
|
- `npm run dev` — start Next.js dev server
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,180 @@ export const Churches: CollectionConfig = {
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
required: true,
|
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: {
|
admin: {
|
||||||
useAsTitle: 'name',
|
useAsTitle: 'name',
|
||||||
hidden: hide
|
hidden: hide,
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
read: () => true,
|
read: () => true,
|
||||||
|
|
@ -39,4 +209,22 @@ export const Churches: CollectionConfig = {
|
||||||
update: isAdminOrEmployee(),
|
update: isAdminOrEmployee(),
|
||||||
delete: 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',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ export const Worship: CollectionConfig = {
|
||||||
label: {
|
label: {
|
||||||
de: 'Abgesagt',
|
de: 'Abgesagt',
|
||||||
},
|
},
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'liturgicalDay',
|
name: 'liturgicalDay',
|
||||||
|
|
@ -98,6 +101,18 @@ export const Worship: CollectionConfig = {
|
||||||
de: 'Hinweise',
|
de: 'Hinweise',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'generated',
|
||||||
|
type: 'checkbox',
|
||||||
|
defaultValue: false,
|
||||||
|
label: {
|
||||||
|
de: 'Automatisch erzeugt',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
readOnly: true,
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
defaultColumns: ["date", 'location', 'type', 'celebrant'],
|
defaultColumns: ["date", 'location', 'type', 'celebrant'],
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export const fetchWorship = async (
|
||||||
location: true,
|
location: true,
|
||||||
title: true,
|
title: true,
|
||||||
},
|
},
|
||||||
limit: 15,
|
limit: 100,
|
||||||
}) as Promise<PaginatedDocs<Worship>>
|
}) as Promise<PaginatedDocs<Worship>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
197
src/jobs/generateRecurringMasses.ts
Normal file
197
src/jobs/generateRecurringMasses.ts
Normal 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 },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
219
src/jobs/lib/scheduleOccurrences.test.ts
Normal file
219
src/jobs/lib/scheduleOccurrences.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
189
src/jobs/lib/scheduleOccurrences.ts
Normal file
189
src/jobs/lib/scheduleOccurrences.ts
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
18758
src/migrations/20260408_143149.json
Normal file
18758
src/migrations/20260408_143149.json
Normal file
File diff suppressed because it is too large
Load diff
106
src/migrations/20260408_143149.ts
Normal file
106
src/migrations/20260408_143149.ts
Normal 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";`)
|
||||||
|
}
|
||||||
18764
src/migrations/20260409_083638.json
Normal file
18764
src/migrations/20260409_083638.json
Normal file
File diff suppressed because it is too large
Load diff
17
src/migrations/20260409_083638.ts
Normal file
17
src/migrations/20260409_083638.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_223804_contactperson_block from './20260319_223804_contactperson_block';
|
||||||
import * as migration_20260319_224419 from './20260319_224419';
|
import * as migration_20260319_224419 from './20260319_224419';
|
||||||
import * as migration_20260408_115618 from './20260408_115618';
|
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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -160,6 +162,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260408_115618.up,
|
up: migration_20260408_115618.up,
|
||||||
down: migration_20260408_115618.down,
|
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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export interface Config {
|
||||||
media: Media;
|
media: Media;
|
||||||
users: User;
|
users: User;
|
||||||
'payload-kv': PayloadKv;
|
'payload-kv': PayloadKv;
|
||||||
|
'payload-jobs': PayloadJob;
|
||||||
'payload-locked-documents': PayloadLockedDocument;
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
'payload-preferences': PayloadPreference;
|
'payload-preferences': PayloadPreference;
|
||||||
'payload-migrations': PayloadMigration;
|
'payload-migrations': PayloadMigration;
|
||||||
|
|
@ -115,6 +116,7 @@ export interface Config {
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
|
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
|
|
@ -126,17 +128,25 @@ export interface Config {
|
||||||
globals: {
|
globals: {
|
||||||
menu: Menu;
|
menu: Menu;
|
||||||
footer: Footer;
|
footer: Footer;
|
||||||
|
'payload-jobs-stats': PayloadJobsStat;
|
||||||
};
|
};
|
||||||
globalsSelect: {
|
globalsSelect: {
|
||||||
menu: MenuSelect<false> | MenuSelect<true>;
|
menu: MenuSelect<false> | MenuSelect<true>;
|
||||||
footer: FooterSelect<false> | FooterSelect<true>;
|
footer: FooterSelect<false> | FooterSelect<true>;
|
||||||
|
'payload-jobs-stats': PayloadJobsStatsSelect<false> | PayloadJobsStatsSelect<true>;
|
||||||
};
|
};
|
||||||
locale: null;
|
locale: null;
|
||||||
user: User & {
|
user: User & {
|
||||||
collection: 'users';
|
collection: 'users';
|
||||||
};
|
};
|
||||||
jobs: {
|
jobs: {
|
||||||
tasks: unknown;
|
tasks: {
|
||||||
|
generateRecurringMasses: TaskGenerateRecurringMasses;
|
||||||
|
inline: {
|
||||||
|
input: unknown;
|
||||||
|
output: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
workflows: unknown;
|
workflows: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +257,36 @@ export interface Church {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
address: 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;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -341,6 +381,7 @@ export interface Worship {
|
||||||
liturgicalDay?: string | null;
|
liturgicalDay?: string | null;
|
||||||
celebrant?: string | null;
|
celebrant?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
generated?: boolean | null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -964,6 +1005,107 @@ export interface PayloadKv {
|
||||||
| boolean
|
| boolean
|
||||||
| null;
|
| 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents".
|
* via the `definition` "payload-locked-documents".
|
||||||
|
|
@ -1168,6 +1310,21 @@ export interface ParishSelect<T extends boolean = true> {
|
||||||
export interface ChurchSelect<T extends boolean = true> {
|
export interface ChurchSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
address?: 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;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
@ -1184,6 +1341,7 @@ export interface WorshipSelect<T extends boolean = true> {
|
||||||
liturgicalDay?: T;
|
liturgicalDay?: T;
|
||||||
celebrant?: T;
|
celebrant?: T;
|
||||||
description?: T;
|
description?: T;
|
||||||
|
generated?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|
@ -1775,6 +1933,38 @@ export interface PayloadKvSelect<T extends boolean = true> {
|
||||||
key?: T;
|
key?: T;
|
||||||
data?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "payload-locked-documents_select".
|
* via the `definition` "payload-locked-documents_select".
|
||||||
|
|
@ -1898,6 +2088,24 @@ export interface Footer {
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "menu_select".
|
* via the `definition` "menu_select".
|
||||||
|
|
@ -2001,6 +2209,30 @@ export interface FooterSelect<T extends boolean = true> {
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "auth".
|
* via the `definition` "auth".
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import { DonationForms } from '@/collections/DonationForms'
|
||||||
import { Pages } from '@/collections/Pages'
|
import { Pages } from '@/collections/Pages'
|
||||||
import { Prayers } from '@/collections/Prayers'
|
import { Prayers } from '@/collections/Prayers'
|
||||||
import { siteConfig } from '@/config/site'
|
import { siteConfig } from '@/config/site'
|
||||||
|
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
||||||
|
|
||||||
const filename = fileURLToPath(import.meta.url)
|
const filename = fileURLToPath(import.meta.url)
|
||||||
const dirname = path.dirname(filename)
|
const dirname = path.dirname(filename)
|
||||||
|
|
@ -104,6 +105,27 @@ export default buildConfig({
|
||||||
MenuGlobal,
|
MenuGlobal,
|
||||||
FooterGlobal,
|
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: {
|
graphQL: {
|
||||||
disable: true
|
disable: true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue