feature: recurring events

This commit is contained in:
Benno Tielen 2026-04-17 14:49:41 +02:00
parent 542bb8c098
commit 20b0c0a768
24 changed files with 50348 additions and 269 deletions

View file

@ -1,6 +1,6 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Parish } from '@/pageComponents/Parish/Parish' import { Parish } from '@/pageComponents/Parish/Parish'
import { fetchEvents } from '@/fetch/events' import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { fetchWorship } from '@/fetch/worship' import { fetchWorship } from '@/fetch/worship'
import { fetchParish } from '@/fetch/parish' import { fetchParish } from '@/fetch/parish'
import { fetchLastAnnouncement } from '@/fetch/announcement' import { fetchLastAnnouncement } from '@/fetch/announcement'
@ -38,7 +38,7 @@ export default async function ParishPage ({ params }: { params: Promise<{slug: s
gallery, gallery,
content content
} = parish } = parish
const events = await fetchEvents({ parishId: id }) const events = await fetchUpcomingOccurrences({ parishId: id })
const churchIds = churches.map(c => typeof c === "string" ? c : c.id) const churchIds = churches.map(c => typeof c === "string" ? c : c.id)
const worship = await fetchWorship({ locations: churchIds }) const worship = await fetchWorship({ locations: churchIds })
const announcement = await fetchLastAnnouncement(id); const announcement = await fetchLastAnnouncement(id);

View file

@ -0,0 +1,67 @@
import { notFound } from 'next/navigation'
import { draftMode } from 'next/headers'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import {
fetchOccurrenceById,
fetchUpcomingOccurrences,
} from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { transformOccurrences } from '@/utils/dto/events'
export default async function Page({
params,
}: {
params: Promise<{ eventId: string; occurrenceId: string }>
}) {
const { eventId, occurrenceId } = await params
const { isEnabled: isDraft } = await draftMode()
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
if (!occurrence) notFound()
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
if (!event) notFound()
if (event.id !== eventId) notFound()
const authenticated = await isAuthenticated()
if (!authenticated && event._status !== 'published') notFound()
const group =
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
? event.group[0].slug
: undefined
const photo = getPhoto('tablet', event.photo)
const upcomingRaw = await fetchUpcomingOccurrences({
eventId,
limit: 5,
fromDate: new Date(new Date(occurrence.date).getTime() + 1),
})
const upcomingOccurrences = transformOccurrences(upcomingRaw.docs)
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
title={event.title}
date={occurrence.date}
createdAt={event.createdAt}
cancelled={event.cancelled || occurrence.cancelled}
recurrenceType={event.recurrenceType}
location={event.location}
description={event.description}
shortDescription={event.shortDescription}
group={group}
contact={event.contact || undefined}
rsvpLink={event.rsvpLink || undefined}
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
photo={photo}
isAuthenticated={authenticated}
upcomingOccurrences={upcomingOccurrences}
/>
</>
)
}

View file

@ -0,0 +1,80 @@
import { notFound, redirect } from 'next/navigation'
import { draftMode } from 'next/headers'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { fetchEventById } from '@/fetch/events'
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { getPayload } from 'payload'
import config from '@/payload.config'
/**
* Entry route per event. Redirects to the current next-upcoming occurrence
* when one exists so shared/search URLs land on the canonical detail page.
* Falls back to an inline render when there are no occurrences yet (fresh
* event, draft preview) so live preview stays responsive.
*/
export default async function Page({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params
const { isEnabled: isDraft } = await draftMode()
const authenticated = await isAuthenticated()
const event = await fetchEventById(eventId, isDraft)
if (!event) notFound()
if (!authenticated && event._status !== 'published') notFound()
// Try next upcoming first, then most recent past as a fallback.
const upcoming = await fetchUpcomingOccurrences({ eventId, limit: 1 })
if (upcoming.docs.length > 0) {
redirect(`/veranstaltungen/${eventId}/${upcoming.docs[0].id}`)
}
const payload = await getPayload({ config })
const past = await payload.find({
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: eventId } },
{ date: { less_than: new Date().toISOString() } },
],
},
sort: '-date',
limit: 1,
depth: 0,
})
if (past.docs.length > 0) {
redirect(`/veranstaltungen/${eventId}/${past.docs[0].id}`)
}
// No occurrences yet (e.g. draft preview right after save). Render from
// the event's own date so editors still see their content.
const group =
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
? event.group[0].slug
: undefined
const photo = getPhoto('tablet', event.photo)
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
title={event.title}
date={event.date}
createdAt={event.createdAt}
cancelled={event.cancelled}
recurrenceType={event.recurrenceType}
location={event.location}
description={event.description}
shortDescription={event.shortDescription}
group={group}
contact={event.contact || undefined}
rsvpLink={event.rsvpLink || undefined}
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
photo={photo}
isAuthenticated={authenticated}
/>
</>
)
}

View file

@ -1,50 +0,0 @@
import { notFound } from 'next/navigation'
import { EventPage } from '@/pageComponents/Event/Event'
import { getPhoto } from '@/utils/dto/gallery'
import { isAuthenticated } from '@/utils/auth'
import { fetchEventById } from '@/fetch/events'
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
import { draftMode } from 'next/headers'
export default async function Page({ params }: { params: Promise<{id: string}>}) {
const id = (await params).id;
const { isEnabled: isDraft } = await draftMode()
const event = await fetchEventById(id, isDraft)
if (!event) {
notFound()
}
const authenticated = await isAuthenticated();
if(!authenticated && event._status !== "published") {
notFound();
}
const group = Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] == "object" ? event.group[0].slug : undefined;
const photo = getPhoto("tablet", event.photo);
return (
<>
{isDraft && <RefreshRouteOnSave />}
<EventPage
id={event.id}
title={event.title}
date={event.date}
createdAt={event.createdAt}
cancelled={event.cancelled}
isRecurring={event.isRecurring}
location={event.location}
description={event.description}
shortDescription={event.shortDescription}
group={group}
contact={event.contact || undefined}
rsvpLink={event.rsvpLink || undefined}
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
photo={photo}
isAuthenticated={authenticated}
/>
</>
)
}

View file

@ -1,11 +1,11 @@
import { fetchEvents } from '@/fetch/events' import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { PageHeader } from '@/compositions/PageHeader/PageHeader' import { PageHeader } from '@/compositions/PageHeader/PageHeader'
import { Section } from '@/components/Section/Section' import { Section } from '@/components/Section/Section'
import { Container } from '@/components/Container/Container' import { Container } from '@/components/Container/Container'
import { Title } from '@/components/Title/Title' import { Title } from '@/components/Title/Title'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons' import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import moment from 'moment' import moment from 'moment'
import { transformEvents } from '@/utils/dto/events' import { transformOccurrences } from '@/utils/dto/events'
import { weekNumber } from '@/utils/week' import { weekNumber } from '@/utils/week'
import { EventRow } from '@/components/EventRow/EventRow' import { EventRow } from '@/components/EventRow/EventRow'
import { fetchHighlightsBetweenDates } from '@/fetch/highlights' import { fetchHighlightsBetweenDates } from '@/fetch/highlights'
@ -38,7 +38,7 @@ export default async function EventsPage({searchParams}: {
const toDate = moment(week).add(1, 'week'); const toDate = moment(week).add(1, 'week');
const lastWeek = moment(week).subtract(1, 'week'); const lastWeek = moment(week).subtract(1, 'week');
const paginatedEvents = await fetchEvents( const paginatedOccurrences = await fetchUpcomingOccurrences(
{ {
limit: limit, limit: limit,
fromDate: fromDate.toDate(), fromDate: fromDate.toDate(),
@ -51,11 +51,11 @@ export default async function EventsPage({searchParams}: {
toDate.toDate(), toDate.toDate(),
))?.docs) || []; ))?.docs) || [];
if (!paginatedEvents) { if (!paginatedOccurrences) {
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>; return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
} }
const events = transformEvents(paginatedEvents.docs) const events = transformOccurrences(paginatedOccurrences.docs)
return ( return (
<> <>

View file

@ -0,0 +1,76 @@
import { CollectionConfig } from 'payload'
import { isAdminOrEmployee } from '@/collections/access/admin'
export const EventOccurrences: CollectionConfig = {
slug: 'eventOccurrence',
labels: {
singular: {
de: 'Veranstaltungs-Termin',
},
plural: {
de: 'Veranstaltungs-Termine',
},
},
fields: [
{
name: 'event',
type: 'relationship',
relationTo: 'event',
required: true,
index: true,
label: {
de: 'Veranstaltung',
},
},
{
name: 'date',
type: 'date',
required: true,
index: true,
label: {
de: 'Datum',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
{
name: 'cancelled',
type: 'checkbox',
required: true,
defaultValue: false,
label: {
de: 'Abgesagt',
},
admin: {
position: 'sidebar',
},
},
{
name: 'generated',
type: 'checkbox',
defaultValue: false,
label: {
de: 'Automatisch erzeugt',
},
admin: {
readOnly: true,
position: 'sidebar',
},
},
],
admin: {
defaultColumns: ['date', 'event', 'cancelled'],
hidden: false,
},
access: {
read: () => true,
create: isAdminOrEmployee(),
update: isAdminOrEmployee(),
delete: isAdminOrEmployee(),
},
}

View file

@ -2,6 +2,7 @@ import { CollectionConfig } from 'payload'
import { Group, User } from '@/payload-types' import { Group, User } from '@/payload-types'
import { fetchEventById } from '@/fetch/events' import { fetchEventById } from '@/fetch/events'
import { isPublishedPublic } from '@/collections/access/public' import { isPublishedPublic } from '@/collections/access/public'
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
export const Events: CollectionConfig = { export const Events: CollectionConfig = {
slug: 'event', slug: 'event',
@ -15,170 +16,217 @@ export const Events: CollectionConfig = {
}, },
fields: [ fields: [
{ {
name: 'title', type: 'tabs',
type: 'text', tabs: [
required: true, {
label: { label: { de: 'Allgemein' },
de: 'Titel', fields: [
}, {
}, name: 'title',
{ type: 'text',
name: 'date', required: true,
type: 'date', label: {
required: true, de: 'Titel',
label: { },
de: 'Datum', },
}, {
admin: { name: 'date',
date: { type: 'date',
pickerAppearance: 'dayAndTime', required: true,
timeIntervals: 15, label: {
timeFormat: 'HH:mm' de: 'Datum',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm'
},
},
},
{
name: 'location',
type: 'relationship',
relationTo: 'locations',
required: true,
label: {
de: 'Location'
},
},
{
name: 'shortDescription',
type: 'textarea',
required: true,
label: {
de: 'Kurzumschreibung (max. 200)',
},
maxLength: 200
},
{
name: 'description',
type: 'textarea',
required: true,
label: {
de: 'Einladung',
},
},
{
name: 'contact',
type: 'relationship',
relationTo: 'contactPerson',
label: {
de: "Ansprechperson"
}
},
{
name: 'rsvpLink',
type: 'text',
required: false,
label: {
de: "Anmeldelink"
}
},
],
}, },
}, {
}, label: { de: 'Zuordnung' },
{ fields: [
name: 'location', {
type: 'relationship', name: 'parish',
relationTo: 'locations', type: 'relationship',
required: true, relationTo: 'parish',
label: { hasMany: true,
de: 'Location' label: {
}, de: 'Gemeinde',
}, },
{ validate: (value, options) => {
name: 'parish', let user = options.req.user
type: 'relationship',
relationTo: 'parish',
hasMany: true,
label: {
de: 'Gemeinde',
},
validate: (value, options) => {
let user = options.req.user
if (!user) { if (!user) {
return 'You are not allowed to do this' return 'You are not allowed to do this'
} }
if (user.roles === 'user' && value && value.length > 0) { if (user.roles === 'user' && value && value.length > 0) {
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.' return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
} }
return true return true
}, },
}, },
{ {
name: 'group', name: 'group',
type: 'relationship', type: 'relationship',
relationTo: 'group', relationTo: 'group',
hasMany: true, hasMany: true,
label: { label: {
de: 'Gruppe', de: 'Gruppe',
}, },
access: { access: {
update: ({req: { user}, data}) => { update: ({req: { user}, data}) => {
if(user && (user.roles == "admin" || user.roles =="employee")) { if(user && (user.roles == "admin" || user.roles =="employee")) {
return true return true
} }
if(hasGroup(user, data)) { if(hasGroup(user, data)) {
return true return true
} }
return false return false
} }
}, },
validate: (value, options: { req: { user: any } }) => { validate: (value, options: { req: { user: any } }) => {
let user = options.req.user let user = options.req.user
if (!user) { if (!user) {
return 'You are not allowed to do this' return 'You are not allowed to do this'
} }
if (user.roles === 'user') { if (user.roles === 'user') {
if(!Array.isArray(value) || value.length === 0) { if(!Array.isArray(value) || value.length === 0) {
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.' return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
} }
if(!Array.isArray(user.groups) || user.groups.length === 0) { if(!Array.isArray(user.groups) || user.groups.length === 0) {
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen." return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
} }
if(!value.every(id => user.groups.includes(id))) { if(!value.every(id => user.groups.includes(id))) {
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen" return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
} }
} }
return true return true
}, },
}, },
{ ],
name: 'contact', },
type: 'relationship', {
relationTo: 'contactPerson', label: { de: 'Medien' },
label: { fields: [
de: "Ansprechperson" {
} name: 'photo',
}, label: {
{ de: 'Foto',
name: 'shortDescription', },
type: 'textarea', type: 'upload',
required: true, relationTo: 'media',
label: { },
de: 'Kurzumschreibung (max. 200)', {
}, name: 'flyer',
maxLength: 200 label: {
}, de: "Flyer (PDF)"
{ },
name: 'description', type: 'upload',
type: 'textarea', relationTo: 'documents'
required: true, },
label: { ],
de: 'Einladung', },
}, {
}, label: { de: 'Wiederholung' },
{ fields: [
name: 'rsvpLink', {
type: 'text', name: 'recurrenceType',
required: false, type: 'select',
label: { required: true,
de: "Anmeldelink" defaultValue: 'none',
} label: {
}, de: 'Wiederholung',
{ },
name: 'photo', options: [
label: { { label: 'Einmalig', value: 'none' },
de: 'Foto', { label: 'Wöchentlich', value: 'weekly' },
}, { label: 'Alle 2 Wochen', value: 'biweekly' },
type: 'upload', ],
relationTo: 'media', admin: {
}, description:
{ 'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
name: 'flyer', },
label: { },
de: "Flyer (PDF)" {
}, name: 'endDate',
type: 'upload', type: 'date',
relationTo: 'documents' label: {
}, de: 'Enddatum',
{ },
name: 'cancelled', admin: {
type: 'checkbox', date: {
required: true, pickerAppearance: 'dayOnly',
label: { },
de: 'Abgesagt', description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
}, },
defaultValue: false, },
}, {
{ name: 'cancelled',
name: 'isRecurring', type: 'checkbox',
type: 'checkbox', required: true,
required: true, label: {
label: { de: 'Abgesagt',
de: 'Regelmäßig', },
}, defaultValue: false,
defaultValue: false, },
],
},
],
}, },
], ],
admin: { admin: {
@ -215,6 +263,35 @@ export const Events: CollectionConfig = {
return false return false
}, },
}, },
hooks: {
afterChange: [
async ({ doc, req }) => {
try {
await regenerateOccurrencesForEvent({
event: doc,
payload: req.payload,
req,
})
} catch (err) {
req.payload.logger.error(
{ err, eventId: doc.id },
'Failed to regenerate event occurrences',
)
}
},
],
beforeDelete: [
async ({ id, req }) => {
// Cascade before the event row is removed so the FK from
// eventOccurrence.event (required) doesn't abort the transaction.
await req.payload.delete({
collection: 'eventOccurrence',
where: { event: { equals: id } },
req,
})
},
],
},
} }
/** /**
@ -243,4 +320,3 @@ const hasGroup = (user: null | User , data: Partial<any> | undefined) => {
return user.groups.includes(group.id) return user.groups.includes(group.id)
}) })
} }

View file

@ -1,7 +1,7 @@
import { Highlight } from '@/payload-types' import { Highlight } from '@/payload-types'
import { fetchEvents } from '@/fetch/events' import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { fetchHighlights } from '@/fetch/highlights' import { fetchHighlights } from '@/fetch/highlights'
import { transformEvents } from '@/utils/dto/events' import { transformOccurrences } from '@/utils/dto/events'
import { highlightLink } from '@/utils/dto/highlight' import { highlightLink } from '@/utils/dto/highlight'
import { Section } from '@/components/Section/Section' import { Section } from '@/components/Section/Section'
import { Title } from '@/components/Title/Title' import { Title } from '@/components/Title/Title'
@ -18,10 +18,10 @@ export async function EventsBlock({
title = 'Veranstaltungen', title = 'Veranstaltungen',
itemsPerPage = 6, itemsPerPage = 6,
}: EventsBlockProps) { }: EventsBlockProps) {
const events = await fetchEvents() const occurrences = await fetchUpcomingOccurrences({ limit: itemsPerPage || 6 })
const eventDocs = events?.docs || [] const occurrenceDocs = occurrences?.docs || []
if (eventDocs.length === 0) return null if (occurrenceDocs.length === 0) return null
const highlights = await fetchHighlights() const highlights = await fetchHighlights()
const highlightDocs = highlights?.docs || [] const highlightDocs = highlights?.docs || []
@ -31,7 +31,7 @@ export async function EventsBlock({
<Section> <Section>
<Title color={'contrast'} title={title || 'Veranstaltungen'} /> <Title color={'contrast'} title={title || 'Veranstaltungen'} />
<Events <Events
events={transformEvents(eventDocs)} events={transformOccurrences(occurrenceDocs)}
n={itemsPerPage || 6} n={itemsPerPage || 6}
schema={'contrast'} schema={'contrast'}
/> />

View file

@ -1,7 +1,7 @@
import { fetchEvents } from '@/fetch/events' import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { Title } from '@/components/Title/Title' import { Title } from '@/components/Title/Title'
import { Events } from '@/compositions/Events/Events' import { Events } from '@/compositions/Events/Events'
import { transformEvents } from '@/utils/dto/events' import { transformOccurrences } from '@/utils/dto/events'
type GroupEventsType = { type GroupEventsType = {
id: string; id: string;
@ -9,7 +9,7 @@ type GroupEventsType = {
export const GroupEvents = async ({id}: GroupEventsType) => { export const GroupEvents = async ({id}: GroupEventsType) => {
const events = await fetchEvents({groupId: id}) const events = await fetchUpcomingOccurrences({groupId: id})
return ( return (
@ -23,7 +23,7 @@ export const GroupEvents = async ({id}: GroupEventsType) => {
{ events && events.docs.length > 0 && { events && events.docs.length > 0 &&
<> <>
<Events <Events
events={transformEvents(events.docs)} events={transformOccurrences(events.docs)}
n={3} n={3}
schema={"contrast"} schema={"contrast"}
/> />

View file

@ -0,0 +1,82 @@
import { getPayload, PaginatedDocs } from 'payload'
import config from '@/payload.config'
import { EventOccurrence } from '@/payload-types'
type ListArgs = {
parishId?: string
groupId?: string
eventId?: string
limit?: number
page?: number
fromDate?: Date
toDate?: Date
}
/**
* Fetch upcoming event occurrences, joined to their parent event. Always
* filters to occurrences whose parent event is published.
*/
export async function fetchUpcomingOccurrences(
args?: ListArgs,
): Promise<PaginatedDocs<EventOccurrence>> {
const {
parishId,
groupId,
eventId,
limit = 30,
page = 0,
fromDate = new Date(),
toDate,
} = args || {}
const query: any = {
and: [
{ date: { greater_than_equal: fromDate.toISOString() } },
{ 'event._status': { equals: 'published' } },
],
}
if (toDate) {
query.and.push({ date: { less_than: toDate.toISOString() } })
}
if (eventId) {
query.and.push({ event: { equals: eventId } })
}
if (parishId) {
query.and.push({ 'event.parish': { equals: parishId } })
}
if (groupId) {
query.and.push({ 'event.group': { equals: groupId } })
}
const payload = await getPayload({ config })
return payload.find({
collection: 'eventOccurrence',
sort: 'date',
where: query,
depth: 2,
limit,
page,
}) as Promise<PaginatedDocs<EventOccurrence>>
}
/**
* Fetch a single occurrence by id with its parent event populated.
* Returns undefined if not found or if the id is malformed.
*/
export async function fetchOccurrenceById(
id: string,
draft: boolean = false,
): Promise<EventOccurrence | undefined> {
try {
const payload = await getPayload({ config })
return await payload.findByID({
collection: 'eventOccurrence',
id,
depth: 2,
draft,
})
} catch {
return undefined
}
}

View file

@ -0,0 +1,203 @@
import type { PayloadRequest, TaskConfig } from 'payload'
import type { Payload } from 'payload'
/**
* Materializes each Event's recurrence rule into `eventOccurrence` rows for
* a rolling window.
*
* Per event we wipe every future occurrence and regenerate from the current
* rule. Occurrences are derivative pointers to the parent event, so there
* is no editor-edited state to preserve rule edits and recurringonce
* transitions stay trivially correct.
*
* Weekday and time-of-day come directly from `event.date`. We step by
* whole calendar days (`setDate(getDate() + N)`) so DST transitions don't
* shift the wall-clock time.
*
* Exposed both as a Payload Jobs Queue task (weekly cron backfill) and as a
* direct function called from the Events `afterChange` hook so edits are
* reflected immediately without waiting for a queue worker.
*/
const DEFAULT_WEEKS_AHEAD = 8
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000
type GenerateEventOccurrencesInput = {
weeksAhead?: number
eventId?: string
}
type GenerateEventOccurrencesOutput = {
created: number
deleted: number
skipped: number
}
type EventLike = {
id: string
date?: string | null
recurrenceType?: 'none' | 'weekly' | 'biweekly' | null
endDate?: string | null
}
const stepDaysFor = (recurrenceType: string): number | null => {
if (recurrenceType === 'weekly') return 7
if (recurrenceType === 'biweekly') return 14
return null
}
// Treat endDate as inclusive of the whole local day.
const endOfLocalDay = (iso: string): Date => {
const d = new Date(iso)
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999)
}
export const regenerateOccurrencesForEvent = async ({
event,
payload,
req,
weeksAhead = DEFAULT_WEEKS_AHEAD,
now = new Date(),
}: {
event: EventLike
payload: Payload
req?: PayloadRequest
weeksAhead?: number
now?: Date
}): Promise<GenerateEventOccurrencesOutput> => {
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
const wipe = await payload.delete({
collection: 'eventOccurrence',
where: {
and: [
{ event: { equals: event.id } },
{ date: { greater_than_equal: now.toISOString() } },
],
},
req,
})
let deleted = wipe.docs.length
let created = 0
let skipped = 0
if (!event.date) {
return { created, deleted, skipped: skipped + 1 }
}
const eventDate = new Date(event.date)
if (Number.isNaN(eventDate.getTime())) {
return { created, deleted, skipped: skipped + 1 }
}
const recurrenceType = event.recurrenceType ?? 'none'
if (recurrenceType === 'none') {
if (eventDate.getTime() < now.getTime()) {
return { created, deleted, skipped: skipped + 1 }
}
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: eventDate.toISOString(),
cancelled: false,
generated: true,
},
req,
})
return { created: created + 1, deleted, skipped }
}
const step = stepDaysFor(recurrenceType)
if (step === null) {
return { created, deleted, skipped: skipped + 1 }
}
const effectiveEnd = event.endDate
? new Date(Math.min(horizon.getTime(), endOfLocalDay(event.endDate).getTime()))
: horizon
const cursor = new Date(eventDate)
while (cursor.getTime() <= effectiveEnd.getTime()) {
if (cursor.getTime() >= now.getTime()) {
await payload.create({
collection: 'eventOccurrence',
data: {
event: event.id,
date: cursor.toISOString(),
cancelled: false,
generated: true,
},
req,
})
created += 1
} else {
skipped += 1
}
cursor.setDate(cursor.getDate() + step)
}
return { created, deleted, skipped }
}
export const generateEventOccurrencesTask: TaskConfig<{
input: GenerateEventOccurrencesInput
output: GenerateEventOccurrencesOutput
}> = {
slug: 'generateEventOccurrences',
label: 'Veranstaltungs-Termine erzeugen',
inputSchema: [
{ name: 'weeksAhead', type: 'number', required: false },
{ name: 'eventId', type: 'text', required: false },
],
outputSchema: [
{ name: 'created', type: 'number' },
{ name: 'deleted', type: 'number' },
{ name: 'skipped', type: 'number' },
],
// Staggered 30 minutes after the Worship task to avoid DB contention.
schedule: [
{
cron: '0 30 3 * * 1',
queue: 'default',
},
],
handler: async ({ input, req }) => {
const { payload } = req
const weeksAhead = input?.weeksAhead ?? DEFAULT_WEEKS_AHEAD
const now = new Date()
const eventsResult = await payload.find({
collection: 'event',
depth: 0,
limit: 1000,
pagination: false,
where: input?.eventId ? { id: { equals: input.eventId } } : undefined,
})
let created = 0
let deleted = 0
let skipped = 0
for (const event of eventsResult.docs) {
const result = await regenerateOccurrencesForEvent({
event: event as EventLike,
payload,
weeksAhead,
now,
})
created += result.created
deleted += result.deleted
skipped += result.skipped
}
payload.logger.info(
{ created, deleted, skipped, weeksAhead },
'generateEventOccurrences finished',
)
return {
output: { created, deleted, skipped },
}
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
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_event_recurrence_type" AS ENUM('none', 'weekly', 'biweekly', 'monthlyByWeekday');
CREATE TYPE "public"."enum_event_day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum_event_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
CREATE TYPE "public"."enum__event_v_version_recurrence_type" AS ENUM('none', 'weekly', 'biweekly', 'monthlyByWeekday');
CREATE TYPE "public"."enum__event_v_version_day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum__event_v_version_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TYPE "public"."enum_payload_jobs_log_task_slug" ADD VALUE 'generateEventOccurrences';
ALTER TYPE "public"."enum_payload_jobs_task_slug" ADD VALUE 'generateEventOccurrences';
CREATE TABLE "event_occurrence" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"event_id" uuid NOT NULL,
"date" timestamp(3) with time zone NOT NULL,
"cancelled" boolean DEFAULT false NOT NULL,
"generated" boolean DEFAULT false,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:18:54.302Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:18:54.627Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-17T11:18:54.690Z';
ALTER TABLE "event" ADD COLUMN "recurrence_type" "enum_event_recurrence_type" DEFAULT 'none';
ALTER TABLE "event" ADD COLUMN "day" "enum_event_day";
ALTER TABLE "event" ADD COLUMN "week_of_month" "enum_event_week_of_month";
ALTER TABLE "event" ADD COLUMN "biweekly_anchor" timestamp(3) with time zone;
ALTER TABLE "event" ADD COLUMN "recurrence_end_date" timestamp(3) with time zone;
ALTER TABLE "_event_v" ADD COLUMN "version_recurrence_type" "enum__event_v_version_recurrence_type" DEFAULT 'none';
ALTER TABLE "_event_v" ADD COLUMN "version_day" "enum__event_v_version_day";
ALTER TABLE "_event_v" ADD COLUMN "version_week_of_month" "enum__event_v_version_week_of_month";
ALTER TABLE "_event_v" ADD COLUMN "version_biweekly_anchor" timestamp(3) with time zone;
ALTER TABLE "_event_v" ADD COLUMN "version_recurrence_end_date" timestamp(3) with time zone;
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "event_occurrence_id" uuid;
ALTER TABLE "event_occurrence" ADD CONSTRAINT "event_occurrence_event_id_event_id_fk" FOREIGN KEY ("event_id") REFERENCES "public"."event"("id") ON DELETE set null ON UPDATE no action;
CREATE INDEX "event_occurrence_event_idx" ON "event_occurrence" USING btree ("event_id");
CREATE INDEX "event_occurrence_date_idx" ON "event_occurrence" USING btree ("date");
CREATE INDEX "event_occurrence_updated_at_idx" ON "event_occurrence" USING btree ("updated_at");
CREATE INDEX "event_occurrence_created_at_idx" ON "event_occurrence" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_event_occurrence_fk" FOREIGN KEY ("event_occurrence_id") REFERENCES "public"."event_occurrence"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_event_occurrence_id_idx" ON "payload_locked_documents_rels" USING btree ("event_occurrence_id");
ALTER TABLE "event" DROP COLUMN "is_recurring";
ALTER TABLE "_event_v" DROP COLUMN "version_is_recurring";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "event_occurrence" DISABLE ROW LEVEL SECURITY;
DROP TABLE "event_occurrence" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_event_occurrence_fk";
ALTER TABLE "payload_jobs_log" ALTER COLUMN "task_slug" SET DATA TYPE text;
DROP TYPE "public"."enum_payload_jobs_log_task_slug";
CREATE TYPE "public"."enum_payload_jobs_log_task_slug" AS ENUM('inline', 'generateRecurringMasses');
ALTER TABLE "payload_jobs_log" ALTER COLUMN "task_slug" SET DATA TYPE "public"."enum_payload_jobs_log_task_slug" USING "task_slug"::"public"."enum_payload_jobs_log_task_slug";
ALTER TABLE "payload_jobs" ALTER COLUMN "task_slug" SET DATA TYPE text;
DROP TYPE "public"."enum_payload_jobs_task_slug";
CREATE TYPE "public"."enum_payload_jobs_task_slug" AS ENUM('inline', 'generateRecurringMasses');
ALTER TABLE "payload_jobs" ALTER COLUMN "task_slug" SET DATA TYPE "public"."enum_payload_jobs_task_slug" USING "task_slug"::"public"."enum_payload_jobs_task_slug";
DROP INDEX "payload_locked_documents_rels_event_occurrence_id_idx";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-19T07:51:55.082Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-19T07:51:55.418Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-17T07:51:55.487Z';
ALTER TABLE "event" ADD COLUMN "is_recurring" boolean DEFAULT false;
ALTER TABLE "_event_v" ADD COLUMN "version_is_recurring" boolean DEFAULT false;
ALTER TABLE "event" DROP COLUMN "recurrence_type";
ALTER TABLE "event" DROP COLUMN "day";
ALTER TABLE "event" DROP COLUMN "week_of_month";
ALTER TABLE "event" DROP COLUMN "biweekly_anchor";
ALTER TABLE "event" DROP COLUMN "recurrence_end_date";
ALTER TABLE "_event_v" DROP COLUMN "version_recurrence_type";
ALTER TABLE "_event_v" DROP COLUMN "version_day";
ALTER TABLE "_event_v" DROP COLUMN "version_week_of_month";
ALTER TABLE "_event_v" DROP COLUMN "version_biweekly_anchor";
ALTER TABLE "_event_v" DROP COLUMN "version_recurrence_end_date";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "event_occurrence_id";
DROP TYPE "public"."enum_event_recurrence_type";
DROP TYPE "public"."enum_event_day";
DROP TYPE "public"."enum_event_week_of_month";
DROP TYPE "public"."enum__event_v_version_recurrence_type";
DROP TYPE "public"."enum__event_v_version_day";
DROP TYPE "public"."enum__event_v_version_week_of_month";`)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "event" RENAME COLUMN "recurrence_end_date" TO "end_date";
ALTER TABLE "_event_v" RENAME COLUMN "version_recurrence_end_date" TO "version_end_date";
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DATA TYPE text;
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DEFAULT 'none'::text;
DROP TYPE "public"."enum_event_recurrence_type";
CREATE TYPE "public"."enum_event_recurrence_type" AS ENUM('none', 'weekly', 'biweekly');
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DEFAULT 'none'::"public"."enum_event_recurrence_type";
ALTER TABLE "event" ALTER COLUMN "recurrence_type" SET DATA TYPE "public"."enum_event_recurrence_type" USING "recurrence_type"::"public"."enum_event_recurrence_type";
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DATA TYPE text;
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DEFAULT 'none'::text;
DROP TYPE "public"."enum__event_v_version_recurrence_type";
CREATE TYPE "public"."enum__event_v_version_recurrence_type" AS ENUM('none', 'weekly', 'biweekly');
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DEFAULT 'none'::"public"."enum__event_v_version_recurrence_type";
ALTER TABLE "_event_v" ALTER COLUMN "version_recurrence_type" SET DATA TYPE "public"."enum__event_v_version_recurrence_type" USING "version_recurrence_type"::"public"."enum__event_v_version_recurrence_type";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:47:26.630Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:47:26.914Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-17T11:47:26.972Z';
ALTER TABLE "event" DROP COLUMN "day";
ALTER TABLE "event" DROP COLUMN "week_of_month";
ALTER TABLE "event" DROP COLUMN "biweekly_anchor";
ALTER TABLE "_event_v" DROP COLUMN "version_day";
ALTER TABLE "_event_v" DROP COLUMN "version_week_of_month";
ALTER TABLE "_event_v" DROP COLUMN "version_biweekly_anchor";
DROP TYPE "public"."enum_event_day";
DROP TYPE "public"."enum_event_week_of_month";
DROP TYPE "public"."enum__event_v_version_day";
DROP TYPE "public"."enum__event_v_version_week_of_month";`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_event_day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum_event_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
CREATE TYPE "public"."enum__event_v_version_day" AS ENUM('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
CREATE TYPE "public"."enum__event_v_version_week_of_month" AS ENUM('first', 'second', 'third', 'fourth', 'last');
ALTER TYPE "public"."enum_event_recurrence_type" ADD VALUE 'monthlyByWeekday';
ALTER TYPE "public"."enum__event_v_version_recurrence_type" ADD VALUE 'monthlyByWeekday';
ALTER TABLE "_event_v" RENAME COLUMN "version_end_date" TO "version_recurrence_end_date";
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:18:54.302Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-04-19T11:18:54.627Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-05-17T11:18:54.690Z';
ALTER TABLE "event" ADD COLUMN "day" "enum_event_day";
ALTER TABLE "event" ADD COLUMN "week_of_month" "enum_event_week_of_month";
ALTER TABLE "event" ADD COLUMN "biweekly_anchor" timestamp(3) with time zone;
ALTER TABLE "event" ADD COLUMN "recurrence_end_date" timestamp(3) with time zone;
ALTER TABLE "_event_v" ADD COLUMN "version_day" "enum__event_v_version_day";
ALTER TABLE "_event_v" ADD COLUMN "version_week_of_month" "enum__event_v_version_week_of_month";
ALTER TABLE "_event_v" ADD COLUMN "version_biweekly_anchor" timestamp(3) with time zone;
ALTER TABLE "event" DROP COLUMN "end_date";`)
}

View file

@ -38,6 +38,8 @@ import * as migration_20260416_115446 from './20260416_115446';
import * as migration_20260416_121451 from './20260416_121451'; import * as migration_20260416_121451 from './20260416_121451';
import * as migration_20260417_072846 from './20260417_072846'; import * as migration_20260417_072846 from './20260417_072846';
import * as migration_20260417_075155 from './20260417_075155'; import * as migration_20260417_075155 from './20260417_075155';
import * as migration_20260417_111855_event_occurrences from './20260417_111855_event_occurrences';
import * as migration_20260417_114727_simplify_recurring_events from './20260417_114727_simplify_recurring_events';
export const migrations = [ export const migrations = [
{ {
@ -238,6 +240,16 @@ export const migrations = [
{ {
up: migration_20260417_075155.up, up: migration_20260417_075155.up,
down: migration_20260417_075155.down, down: migration_20260417_075155.down,
name: '20260417_075155' name: '20260417_075155',
},
{
up: migration_20260417_111855_event_occurrences.up,
down: migration_20260417_111855_event_occurrences.down,
name: '20260417_111855_event_occurrences',
},
{
up: migration_20260417_114727_simplify_recurring_events.up,
down: migration_20260417_114727_simplify_recurring_events.down,
name: '20260417_114727_simplify_recurring_events'
}, },
]; ];

View file

@ -16,7 +16,7 @@ export const Default: Story = {
date: "2024-12-02T09:21:19Z", date: "2024-12-02T09:21:19Z",
createdAt: "2024-12-02T09:21:19Z", createdAt: "2024-12-02T09:21:19Z",
cancelled: false, cancelled: false,
isRecurring: true, recurrenceType: 'weekly',
location: { location: {
id: "l1", id: "l1",
name: "St. Richard", name: "St. Richard",

View file

@ -1,5 +1,5 @@
import styles from "./styles.module.scss" import styles from "./styles.module.scss"
import { ContactPerson, Document, Location } from '@/payload-types' import { ContactPerson, Document, Event, Location } from '@/payload-types'
import { Section } from '@/components/Section/Section' import { Section } from '@/components/Section/Section'
import { Title } from '@/components/Title/Title' import { Title } from '@/components/Title/Title'
import { Container } from '@/components/Container/Container' import { Container } from '@/components/Container/Container'
@ -16,6 +16,16 @@ import { locationString } from '@/utils/dto/location'
import { ContactPerson2 } from '@/components/ContactPerson2/ContactPerson2' import { ContactPerson2 } from '@/components/ContactPerson2/ContactPerson2'
import { getPhoto } from '@/utils/dto/gallery' import { getPhoto } from '@/utils/dto/gallery'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu' import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { EventRow } from '@/components/EventRow/EventRow'
type UpcomingOccurrence = {
id: string
date: string
title: string
href: string
location: string
cancelled: boolean
}
type EventProps = { type EventProps = {
id: string, id: string,
@ -23,7 +33,7 @@ type EventProps = {
date: string, date: string,
createdAt: string, createdAt: string,
cancelled: boolean, cancelled: boolean,
isRecurring?: boolean, recurrenceType?: Event['recurrenceType'],
location: string | Location, location: string | Location,
description: string, description: string,
shortDescription: string, shortDescription: string,
@ -32,7 +42,8 @@ type EventProps = {
flyer?: Document, flyer?: Document,
photo?: StaticImageData, photo?: StaticImageData,
rsvpLink?: string, rsvpLink?: string,
isAuthenticated: boolean isAuthenticated: boolean,
upcomingOccurrences?: UpcomingOccurrence[],
} }
export function EventPage( export function EventPage(
@ -42,7 +53,7 @@ export function EventPage(
date, date,
createdAt, createdAt,
cancelled, cancelled,
isRecurring, recurrenceType,
location, location,
description, description,
shortDescription, shortDescription,
@ -51,13 +62,15 @@ export function EventPage(
group, group,
photo, photo,
rsvpLink, rsvpLink,
isAuthenticated isAuthenticated,
upcomingOccurrences,
}: EventProps }: EventProps
) { ) {
const published = useDate(createdAt) const published = useDate(createdAt)
const readableDate = readableDateTime(date) const readableDate = readableDateTime(date)
const where = locationString(location); const where = locationString(location);
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined; const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
const isRecurring = recurrenceType && recurrenceType !== 'none'
return ( return (
<> <>
@ -170,6 +183,32 @@ export function EventPage(
</EventExcerpt> </EventExcerpt>
</Section> </Section>
{ upcomingOccurrences && upcomingOccurrences.length > 0 &&
<Section padding={"medium"}>
<Container>
<Title
title={"Nächste Termine"}
size={"md"}
color={"contrast"}
align={"center"}
/>
<Section padding={"small"}>
{ upcomingOccurrences.map(o =>
<EventRow
key={o.id}
date={o.date}
title={o.title}
href={o.href}
location={o.location}
cancelled={o.cancelled}
/>
)}
</Section>
</Container>
</Section>
}
<Section/> <Section/>
<AdminMenu <AdminMenu
collection={"event"} collection={"event"}
@ -178,4 +217,4 @@ export function EventPage(
/> />
</> </>
) )
} }

View file

@ -1,5 +1,5 @@
import moment from 'moment' import moment from 'moment'
import { fetchEvents } from '@/fetch/events' import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { fetchWorship } from '@/fetch/worship' import { fetchWorship } from '@/fetch/worship'
import { fetchBlogPosts } from '@/fetch/blog' import { fetchBlogPosts } from '@/fetch/blog'
import { fetchHighlights } from '@/fetch/highlights' import { fetchHighlights } from '@/fetch/highlights'
@ -13,7 +13,7 @@ export const Home = async () => {
const fromDate = moment().isoWeekday(1).hours(0).minutes(0) const fromDate = moment().isoWeekday(1).hours(0).minutes(0)
const tillDate = moment().isoWeekday(7).hours(23).minutes(59) const tillDate = moment().isoWeekday(7).hours(23).minutes(59)
const events = await fetchEvents() const events = await fetchUpcomingOccurrences()
const worship = await fetchWorship({ const worship = await fetchWorship({
fromDate: fromDate.toDate(), fromDate: fromDate.toDate(),
tillDate: tillDate.toDate(), tillDate: tillDate.toDate(),

View file

@ -1,4 +1,4 @@
import { Blog, Worship, Event, Highlight } from '@/payload-types' import { Blog, Worship, EventOccurrence, Highlight } from '@/payload-types'
import { Banner } from '@/components/Banner/Banner' import { Banner } from '@/components/Banner/Banner'
import { Container } from '@/components/Container/Container' import { Container } from '@/components/Container/Container'
import { Section } from '@/components/Section/Section' import { Section } from '@/components/Section/Section'
@ -13,7 +13,7 @@ import { ContentWithSlider } from '@/compositions/ContentWithSlider/ContentWithS
import { EventRow } from '@/components/EventRow/EventRow' import { EventRow } from '@/components/EventRow/EventRow'
import { highlightLink } from '@/utils/dto/highlight' import { highlightLink } from '@/utils/dto/highlight'
import { Events } from '@/compositions/Events/Events' import { Events } from '@/compositions/Events/Events'
import { transformEvents } from '@/utils/dto/events' import { transformOccurrences } from '@/utils/dto/events'
import { ContactSection } from '@/compositions/ContactSection/ContactSection' import { ContactSection } from '@/compositions/ContactSection/ContactSection'
import { CollapsibleImageWithText } from '@/compositions/CollapsibleImageWithText/CollapsibleImageWithText' import { CollapsibleImageWithText } from '@/compositions/CollapsibleImageWithText/CollapsibleImageWithText'
import { MoreInformation } from '@/pageComponents/Home/MoreInformation' import { MoreInformation } from '@/pageComponents/Home/MoreInformation'
@ -23,7 +23,7 @@ import { Link, PopupButton } from '@/components/PopupButton/PopupButton'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
type HomeViewProps = { type HomeViewProps = {
events: Event[], events: EventOccurrence[],
worship: Worship[], worship: Worship[],
blog: Blog[], blog: Blog[],
highlights: Highlight[], highlights: Highlight[],
@ -178,7 +178,7 @@ export const HomeView = ({
title={'Veranstaltungen'} title={'Veranstaltungen'}
/> />
<Events <Events
events={transformEvents(events)} events={transformOccurrences(events)}
n={6} n={6}
schema={"contrast"} schema={"contrast"}
/> />

View file

@ -9,8 +9,8 @@ import { TwoColumnText } from '@/components/TwoColumnText/TwoColumnText'
import { Events } from '@/compositions/Events/Events' import { Events } from '@/compositions/Events/Events'
import { MarginBottom } from '@/components/Margin/MarbinBottom' import { MarginBottom } from '@/components/Margin/MarbinBottom'
import { ContactPersonList } from '@/components/ContactPerson/ContactPersonList' import { ContactPersonList } from '@/components/ContactPerson/ContactPersonList'
import { Event, Worship } from '@/payload-types' import { EventOccurrence, Worship } from '@/payload-types'
import { transformEvents } from '@/utils/dto/events' import { transformOccurrences } from '@/utils/dto/events'
import { tranformWorship } from '@/utils/dto/worship' import { tranformWorship } from '@/utils/dto/worship'
import { TextDiv } from '@/components/Text/TextDiv' import { TextDiv } from '@/components/Text/TextDiv'
import { Gallery, GalleryItem } from '@/components/Gallery/Gallery' import { Gallery, GalleryItem } from '@/components/Gallery/Gallery'
@ -29,7 +29,7 @@ type ParishProps = {
description: string description: string
}[], }[],
contact: string contact: string
events: Event[], events: EventOccurrence[],
worship: Worship[] worship: Worship[]
announcement?: string, announcement?: string,
calendar?: string, calendar?: string,
@ -72,7 +72,7 @@ export const Parish = (
<Row> <Row>
<Col> <Col>
<Title title={"Veranstaltungen"} size={"md"} color={"contrast"}/> <Title title={"Veranstaltungen"} size={"md"} color={"contrast"}/>
<Events events={transformEvents(events)} n={4} schema={"contrast"}/> <Events events={transformOccurrences(events)} n={4} schema={"contrast"}/>
<MarginBottom /> <MarginBottom />

View file

@ -76,6 +76,7 @@ export interface Config {
blog: Blog; blog: Blog;
highlight: Highlight; highlight: Highlight;
event: Event; event: Event;
eventOccurrence: EventOccurrence;
classifieds: Classified; classifieds: Classified;
contactPerson: ContactPerson; contactPerson: ContactPerson;
locations: Location; locations: Location;
@ -105,6 +106,7 @@ export interface Config {
blog: BlogSelect<false> | BlogSelect<true>; blog: BlogSelect<false> | BlogSelect<true>;
highlight: HighlightSelect<false> | HighlightSelect<true>; highlight: HighlightSelect<false> | HighlightSelect<true>;
event: EventSelect<false> | EventSelect<true>; event: EventSelect<false> | EventSelect<true>;
eventOccurrence: EventOccurrenceSelect<false> | EventOccurrenceSelect<true>;
classifieds: ClassifiedsSelect<false> | ClassifiedsSelect<true>; classifieds: ClassifiedsSelect<false> | ClassifiedsSelect<true>;
contactPerson: ContactPersonSelect<false> | ContactPersonSelect<true>; contactPerson: ContactPersonSelect<false> | ContactPersonSelect<true>;
locations: LocationsSelect<false> | LocationsSelect<true>; locations: LocationsSelect<false> | LocationsSelect<true>;
@ -144,6 +146,7 @@ export interface Config {
jobs: { jobs: {
tasks: { tasks: {
generateRecurringMasses: TaskGenerateRecurringMasses; generateRecurringMasses: TaskGenerateRecurringMasses;
generateEventOccurrences: TaskGenerateEventOccurrences;
inline: { inline: {
input: unknown; input: unknown;
output: unknown; output: unknown;
@ -1062,16 +1065,23 @@ export interface Event {
title: string; title: string;
date: string; date: string;
location: string | Location; location: string | Location;
parish?: (string | Parish)[] | null;
group?: (string | Group)[] | null;
contact?: (string | null) | ContactPerson;
shortDescription: string; shortDescription: string;
description: string; description: string;
contact?: (string | null) | ContactPerson;
rsvpLink?: string | null; rsvpLink?: string | null;
parish?: (string | Parish)[] | null;
group?: (string | Group)[] | null;
photo?: (string | null) | Media; photo?: (string | null) | Media;
flyer?: (string | null) | Document; flyer?: (string | null) | Document;
/**
* Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.
*/
recurrenceType: 'none' | 'weekly' | 'biweekly';
/**
* Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.
*/
endDate?: string | null;
cancelled: boolean; cancelled: boolean;
isRecurring: boolean;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@ -1094,6 +1104,19 @@ export interface Location {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "eventOccurrence".
*/
export interface EventOccurrence {
id: string;
event: string | Event;
date: string;
cancelled: boolean;
generated?: boolean | null;
updatedAt: string;
createdAt: string;
}
/** /**
* Dieser Bereich des Dashboards ermöglicht die umfassende Verwaltung aller veröffentlichten Kleinanzeigen für freiwillige Tätigkeiten. Hier können Administratoren Inserate einsehen, bearbeiten, veröffentlichen und entfernen, um die Qualität und Relevanz der angebotenen Möglichkeiten sicherzustellen. * Dieser Bereich des Dashboards ermöglicht die umfassende Verwaltung aller veröffentlichten Kleinanzeigen für freiwillige Tätigkeiten. Hier können Administratoren Inserate einsehen, bearbeiten, veröffentlichen und entfernen, um die Qualität und Relevanz der angebotenen Möglichkeiten sicherzustellen.
* *
@ -1300,7 +1323,7 @@ export interface PayloadJob {
| { | {
executedAt: string; executedAt: string;
completedAt: string; completedAt: string;
taskSlug: 'inline' | 'generateRecurringMasses'; taskSlug: 'inline' | 'generateRecurringMasses' | 'generateEventOccurrences';
taskID: string; taskID: string;
input?: input?:
| { | {
@ -1333,7 +1356,7 @@ export interface PayloadJob {
id?: string | null; id?: string | null;
}[] }[]
| null; | null;
taskSlug?: ('inline' | 'generateRecurringMasses') | null; taskSlug?: ('inline' | 'generateRecurringMasses' | 'generateEventOccurrences') | null;
queue?: string | null; queue?: string | null;
waitUntil?: string | null; waitUntil?: string | null;
processing?: boolean | null; processing?: boolean | null;
@ -1392,6 +1415,10 @@ export interface PayloadLockedDocument {
relationTo: 'event'; relationTo: 'event';
value: string | Event; value: string | Event;
} | null) } | null)
| ({
relationTo: 'eventOccurrence';
value: string | EventOccurrence;
} | null)
| ({ | ({
relationTo: 'classifieds'; relationTo: 'classifieds';
value: string | Classified; value: string | Classified;
@ -1787,20 +1814,33 @@ export interface EventSelect<T extends boolean = true> {
title?: T; title?: T;
date?: T; date?: T;
location?: T; location?: T;
parish?: T;
group?: T;
contact?: T;
shortDescription?: T; shortDescription?: T;
description?: T; description?: T;
contact?: T;
rsvpLink?: T; rsvpLink?: T;
parish?: T;
group?: T;
photo?: T; photo?: T;
flyer?: T; flyer?: T;
recurrenceType?: T;
endDate?: T;
cancelled?: T; cancelled?: T;
isRecurring?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "eventOccurrence_select".
*/
export interface EventOccurrenceSelect<T extends boolean = true> {
event?: T;
date?: T;
cancelled?: T;
generated?: 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` "classifieds_select". * via the `definition` "classifieds_select".
@ -2638,6 +2678,21 @@ export interface TaskGenerateRecurringMasses {
skipped?: number | null; skipped?: number | null;
}; };
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "TaskGenerateEventOccurrences".
*/
export interface TaskGenerateEventOccurrences {
input: {
weeksAhead?: number | null;
eventId?: string | null;
};
output: {
created?: number | null;
deleted?: 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".

View file

@ -25,6 +25,7 @@ import { de } from '@payloadcms/translations/languages/de'
import { Parish } from '@/collections/Parish' import { Parish } from '@/collections/Parish'
import { Groups } from '@/collections/Groups' import { Groups } from '@/collections/Groups'
import { Events } from '@/collections/Events' import { Events } from '@/collections/Events'
import { EventOccurrences } from '@/collections/EventOccurrences'
import { Announcements } from '@/collections/Announcements' import { Announcements } from '@/collections/Announcements'
import { Blog } from '@/collections/Blog' import { Blog } from '@/collections/Blog'
import { Highlight } from '@/collections/Highlight' import { Highlight } from '@/collections/Highlight'
@ -44,6 +45,7 @@ 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' import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
import { searchPlugin } from '@payloadcms/plugin-search' import { searchPlugin } from '@payloadcms/plugin-search'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
@ -90,6 +92,7 @@ export default buildConfig({
Blog, Blog,
Highlight, Highlight,
Events, Events,
EventOccurrences,
Classifieds, Classifieds,
ContactPerson, ContactPerson,
Locations, Locations,
@ -107,7 +110,7 @@ export default buildConfig({
FooterGlobal, FooterGlobal,
], ],
jobs: { jobs: {
tasks: [generateRecurringMassesTask], tasks: [generateRecurringMassesTask, generateEventOccurrencesTask],
autoRun: [ autoRun: [
{ {
// every 15 minutes (6-field cron, seconds first) // every 15 minutes (6-field cron, seconds first)

View file

@ -1,23 +1,44 @@
import { Event } from '@/payload-types' import { Event, EventOccurrence } from '@/payload-types'
type EventWithId = { type EventRowItem = {
id: string, id: string
date: string, date: string
title: string, title: string
href: string, href: string
location: string, location: string
cancelled: boolean cancelled: boolean
} }
export const transformEvents = (events: Event[]): EventWithId[] => { export const transformOccurrences = (
return events.map(e => { occurrences: EventOccurrence[],
return { ): EventRowItem[] => {
id: e.id, return occurrences
title: e.title, .map((o) => {
date: e.date, const event = typeof o.event === 'object' ? o.event : undefined
href: `/veranstaltungen/${e.id}`, if (!event) return undefined
location: typeof e.location === "object" ? e.location.name : "Unbekannt", return {
cancelled: e.cancelled, id: o.id,
} title: event.title,
}) date: o.date,
} href: `/veranstaltungen/${event.id}/${o.id}`,
location: typeof event.location === 'object' ? event.location.name : 'Unbekannt',
cancelled: Boolean(event.cancelled || o.cancelled),
}
})
.filter((x): x is EventRowItem => Boolean(x))
}
/**
* Legacy mapper kept for any caller still working with bare Event docs.
* New code should prefer `transformOccurrences`.
*/
export const transformEvents = (events: Event[]): EventRowItem[] => {
return events.map((e) => ({
id: e.id,
title: e.title,
date: e.date,
href: `/veranstaltungen/${e.id}`,
location: typeof e.location === 'object' ? e.location.name : 'Unbekannt',
cancelled: e.cancelled,
}))
}