feature: recurring events
This commit is contained in:
parent
542bb8c098
commit
20b0c0a768
24 changed files with 50348 additions and 269 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import { Parish } from '@/pageComponents/Parish/Parish'
|
||||
import { fetchEvents } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { fetchWorship } from '@/fetch/worship'
|
||||
import { fetchParish } from '@/fetch/parish'
|
||||
import { fetchLastAnnouncement } from '@/fetch/announcement'
|
||||
|
|
@ -38,7 +38,7 @@ export default async function ParishPage ({ params }: { params: Promise<{slug: s
|
|||
gallery,
|
||||
content
|
||||
} = 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 worship = await fetchWorship({ locations: churchIds })
|
||||
const announcement = await fetchLastAnnouncement(id);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
80
src/app/(home)/veranstaltungen/[eventId]/page.tsx
Normal file
80
src/app/(home)/veranstaltungen/[eventId]/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { fetchEvents } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { PageHeader } from '@/compositions/PageHeader/PageHeader'
|
||||
import { Section } from '@/components/Section/Section'
|
||||
import { Container } from '@/components/Container/Container'
|
||||
import { Title } from '@/components/Title/Title'
|
||||
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
|
||||
import moment from 'moment'
|
||||
import { transformEvents } from '@/utils/dto/events'
|
||||
import { transformOccurrences } from '@/utils/dto/events'
|
||||
import { weekNumber } from '@/utils/week'
|
||||
import { EventRow } from '@/components/EventRow/EventRow'
|
||||
import { fetchHighlightsBetweenDates } from '@/fetch/highlights'
|
||||
|
|
@ -38,7 +38,7 @@ export default async function EventsPage({searchParams}: {
|
|||
const toDate = moment(week).add(1, 'week');
|
||||
const lastWeek = moment(week).subtract(1, 'week');
|
||||
|
||||
const paginatedEvents = await fetchEvents(
|
||||
const paginatedOccurrences = await fetchUpcomingOccurrences(
|
||||
{
|
||||
limit: limit,
|
||||
fromDate: fromDate.toDate(),
|
||||
|
|
@ -51,11 +51,11 @@ export default async function EventsPage({searchParams}: {
|
|||
toDate.toDate(),
|
||||
))?.docs) || [];
|
||||
|
||||
if (!paginatedEvents) {
|
||||
if (!paginatedOccurrences) {
|
||||
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
|
||||
}
|
||||
|
||||
const events = transformEvents(paginatedEvents.docs)
|
||||
const events = transformOccurrences(paginatedOccurrences.docs)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
76
src/collections/EventOccurrences.ts
Normal file
76
src/collections/EventOccurrences.ts
Normal 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(),
|
||||
},
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { CollectionConfig } from 'payload'
|
|||
import { Group, User } from '@/payload-types'
|
||||
import { fetchEventById } from '@/fetch/events'
|
||||
import { isPublishedPublic } from '@/collections/access/public'
|
||||
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
|
||||
|
||||
export const Events: CollectionConfig = {
|
||||
slug: 'event',
|
||||
|
|
@ -15,170 +16,217 @@ export const Events: CollectionConfig = {
|
|||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Titel',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Datum',
|
||||
},
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
timeIntervals: 15,
|
||||
timeFormat: 'HH:mm'
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: { de: 'Allgemein' },
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Titel',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
type: 'date',
|
||||
required: true,
|
||||
label: {
|
||||
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"
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
type: 'relationship',
|
||||
relationTo: 'locations',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Location'
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'parish',
|
||||
type: 'relationship',
|
||||
relationTo: 'parish',
|
||||
hasMany: true,
|
||||
label: {
|
||||
de: 'Gemeinde',
|
||||
},
|
||||
validate: (value, options) => {
|
||||
let user = options.req.user
|
||||
{
|
||||
label: { de: 'Zuordnung' },
|
||||
fields: [
|
||||
{
|
||||
name: 'parish',
|
||||
type: 'relationship',
|
||||
relationTo: 'parish',
|
||||
hasMany: true,
|
||||
label: {
|
||||
de: 'Gemeinde',
|
||||
},
|
||||
validate: (value, options) => {
|
||||
let user = options.req.user
|
||||
|
||||
if (!user) {
|
||||
return 'You are not allowed to do this'
|
||||
}
|
||||
if (!user) {
|
||||
return 'You are not allowed to do this'
|
||||
}
|
||||
|
||||
if (user.roles === 'user' && value && value.length > 0) {
|
||||
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
|
||||
}
|
||||
if (user.roles === 'user' && value && value.length > 0) {
|
||||
return 'Sie sind nur erlaubt Veranstaltungen für Gruppen zu erstellen.'
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'relationship',
|
||||
relationTo: 'group',
|
||||
hasMany: true,
|
||||
label: {
|
||||
de: 'Gruppe',
|
||||
},
|
||||
access: {
|
||||
update: ({req: { user}, data}) => {
|
||||
if(user && (user.roles == "admin" || user.roles =="employee")) {
|
||||
return true
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'group',
|
||||
type: 'relationship',
|
||||
relationTo: 'group',
|
||||
hasMany: true,
|
||||
label: {
|
||||
de: 'Gruppe',
|
||||
},
|
||||
access: {
|
||||
update: ({req: { user}, data}) => {
|
||||
if(user && (user.roles == "admin" || user.roles =="employee")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if(hasGroup(user, data)) {
|
||||
return true
|
||||
}
|
||||
if(hasGroup(user, data)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
validate: (value, options: { req: { user: any } }) => {
|
||||
let user = options.req.user
|
||||
return false
|
||||
}
|
||||
},
|
||||
validate: (value, options: { req: { user: any } }) => {
|
||||
let user = options.req.user
|
||||
|
||||
if (!user) {
|
||||
return 'You are not allowed to do this'
|
||||
}
|
||||
if (!user) {
|
||||
return 'You are not allowed to do this'
|
||||
}
|
||||
|
||||
if (user.roles === 'user') {
|
||||
if(!Array.isArray(value) || value.length === 0) {
|
||||
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
||||
}
|
||||
if (user.roles === 'user') {
|
||||
if(!Array.isArray(value) || value.length === 0) {
|
||||
return 'Sie müssen die Veranstaltung verknüpfen mit ihrer Gruppe.'
|
||||
}
|
||||
|
||||
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
||||
}
|
||||
if(!Array.isArray(user.groups) || user.groups.length === 0) {
|
||||
return "Sie sind kein Mitglied einer Gruppe, und können deswegen keine Veranstaltung erstellen."
|
||||
}
|
||||
|
||||
if(!value.every(id => user.groups.includes(id))) {
|
||||
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
||||
}
|
||||
}
|
||||
if(!value.every(id => user.groups.includes(id))) {
|
||||
return "Sie sind nur berechtigt Veranstaltungen für ihrer Gruppe zu erstellen"
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
type: 'relationship',
|
||||
relationTo: 'contactPerson',
|
||||
label: {
|
||||
de: "Ansprechperson"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'shortDescription',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Kurzumschreibung (max. 200)',
|
||||
},
|
||||
maxLength: 200
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Einladung',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'rsvpLink',
|
||||
type: 'text',
|
||||
required: false,
|
||||
label: {
|
||||
de: "Anmeldelink"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'photo',
|
||||
label: {
|
||||
de: 'Foto',
|
||||
},
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'flyer',
|
||||
label: {
|
||||
de: "Flyer (PDF)"
|
||||
},
|
||||
type: 'upload',
|
||||
relationTo: 'documents'
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Abgesagt',
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'isRecurring',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Regelmäßig',
|
||||
},
|
||||
defaultValue: false,
|
||||
return true
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: { de: 'Medien' },
|
||||
fields: [
|
||||
{
|
||||
name: 'photo',
|
||||
label: {
|
||||
de: 'Foto',
|
||||
},
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'flyer',
|
||||
label: {
|
||||
de: "Flyer (PDF)"
|
||||
},
|
||||
type: 'upload',
|
||||
relationTo: 'documents'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: { de: 'Wiederholung' },
|
||||
fields: [
|
||||
{
|
||||
name: 'recurrenceType',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'none',
|
||||
label: {
|
||||
de: 'Wiederholung',
|
||||
},
|
||||
options: [
|
||||
{ label: 'Einmalig', value: 'none' },
|
||||
{ label: 'Wöchentlich', value: 'weekly' },
|
||||
{ label: 'Alle 2 Wochen', value: 'biweekly' },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag und Uhrzeit werden aus dem Datumsfeld übernommen.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'endDate',
|
||||
type: 'date',
|
||||
label: {
|
||||
de: 'Enddatum',
|
||||
},
|
||||
admin: {
|
||||
date: {
|
||||
pickerAppearance: 'dayOnly',
|
||||
},
|
||||
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cancelled',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
label: {
|
||||
de: 'Abgesagt',
|
||||
},
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
|
|
@ -215,6 +263,35 @@ export const Events: CollectionConfig = {
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Highlight } from '@/payload-types'
|
||||
import { fetchEvents } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { fetchHighlights } from '@/fetch/highlights'
|
||||
import { transformEvents } from '@/utils/dto/events'
|
||||
import { transformOccurrences } from '@/utils/dto/events'
|
||||
import { highlightLink } from '@/utils/dto/highlight'
|
||||
import { Section } from '@/components/Section/Section'
|
||||
import { Title } from '@/components/Title/Title'
|
||||
|
|
@ -18,10 +18,10 @@ export async function EventsBlock({
|
|||
title = 'Veranstaltungen',
|
||||
itemsPerPage = 6,
|
||||
}: EventsBlockProps) {
|
||||
const events = await fetchEvents()
|
||||
const eventDocs = events?.docs || []
|
||||
const occurrences = await fetchUpcomingOccurrences({ limit: itemsPerPage || 6 })
|
||||
const occurrenceDocs = occurrences?.docs || []
|
||||
|
||||
if (eventDocs.length === 0) return null
|
||||
if (occurrenceDocs.length === 0) return null
|
||||
|
||||
const highlights = await fetchHighlights()
|
||||
const highlightDocs = highlights?.docs || []
|
||||
|
|
@ -31,7 +31,7 @@ export async function EventsBlock({
|
|||
<Section>
|
||||
<Title color={'contrast'} title={title || 'Veranstaltungen'} />
|
||||
<Events
|
||||
events={transformEvents(eventDocs)}
|
||||
events={transformOccurrences(occurrenceDocs)}
|
||||
n={itemsPerPage || 6}
|
||||
schema={'contrast'}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { fetchEvents } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { Title } from '@/components/Title/Title'
|
||||
import { Events } from '@/compositions/Events/Events'
|
||||
import { transformEvents } from '@/utils/dto/events'
|
||||
import { transformOccurrences } from '@/utils/dto/events'
|
||||
|
||||
type GroupEventsType = {
|
||||
id: string;
|
||||
|
|
@ -9,7 +9,7 @@ type GroupEventsType = {
|
|||
|
||||
export const GroupEvents = async ({id}: GroupEventsType) => {
|
||||
|
||||
const events = await fetchEvents({groupId: id})
|
||||
const events = await fetchUpcomingOccurrences({groupId: id})
|
||||
|
||||
return (
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ export const GroupEvents = async ({id}: GroupEventsType) => {
|
|||
{ events && events.docs.length > 0 &&
|
||||
<>
|
||||
<Events
|
||||
events={transformEvents(events.docs)}
|
||||
events={transformOccurrences(events.docs)}
|
||||
n={3}
|
||||
schema={"contrast"}
|
||||
/>
|
||||
|
|
|
|||
82
src/fetch/eventOccurrences.ts
Normal file
82
src/fetch/eventOccurrences.ts
Normal 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
|
||||
}
|
||||
}
|
||||
203
src/jobs/generateEventOccurrences.ts
Normal file
203
src/jobs/generateEventOccurrences.ts
Normal 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 recurring→once
|
||||
* 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 },
|
||||
}
|
||||
},
|
||||
}
|
||||
24683
src/migrations/20260417_111855_event_occurrences.json
Normal file
24683
src/migrations/20260417_111855_event_occurrences.json
Normal file
File diff suppressed because it is too large
Load diff
85
src/migrations/20260417_111855_event_occurrences.ts
Normal file
85
src/migrations/20260417_111855_event_occurrences.ts
Normal 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";`)
|
||||
}
|
||||
24593
src/migrations/20260417_114727_simplify_recurring_events.json
Normal file
24593
src/migrations/20260417_114727_simplify_recurring_events.json
Normal file
File diff suppressed because it is too large
Load diff
54
src/migrations/20260417_114727_simplify_recurring_events.ts
Normal file
54
src/migrations/20260417_114727_simplify_recurring_events.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ import * as migration_20260416_115446 from './20260416_115446';
|
|||
import * as migration_20260416_121451 from './20260416_121451';
|
||||
import * as migration_20260417_072846 from './20260417_072846';
|
||||
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 = [
|
||||
{
|
||||
|
|
@ -238,6 +240,16 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260417_075155.up,
|
||||
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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export const Default: Story = {
|
|||
date: "2024-12-02T09:21:19Z",
|
||||
createdAt: "2024-12-02T09:21:19Z",
|
||||
cancelled: false,
|
||||
isRecurring: true,
|
||||
recurrenceType: 'weekly',
|
||||
location: {
|
||||
id: "l1",
|
||||
name: "St. Richard",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Title } from '@/components/Title/Title'
|
||||
import { Container } from '@/components/Container/Container'
|
||||
|
|
@ -16,6 +16,16 @@ import { locationString } from '@/utils/dto/location'
|
|||
import { ContactPerson2 } from '@/components/ContactPerson2/ContactPerson2'
|
||||
import { getPhoto } from '@/utils/dto/gallery'
|
||||
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 = {
|
||||
id: string,
|
||||
|
|
@ -23,7 +33,7 @@ type EventProps = {
|
|||
date: string,
|
||||
createdAt: string,
|
||||
cancelled: boolean,
|
||||
isRecurring?: boolean,
|
||||
recurrenceType?: Event['recurrenceType'],
|
||||
location: string | Location,
|
||||
description: string,
|
||||
shortDescription: string,
|
||||
|
|
@ -32,7 +42,8 @@ type EventProps = {
|
|||
flyer?: Document,
|
||||
photo?: StaticImageData,
|
||||
rsvpLink?: string,
|
||||
isAuthenticated: boolean
|
||||
isAuthenticated: boolean,
|
||||
upcomingOccurrences?: UpcomingOccurrence[],
|
||||
}
|
||||
|
||||
export function EventPage(
|
||||
|
|
@ -42,7 +53,7 @@ export function EventPage(
|
|||
date,
|
||||
createdAt,
|
||||
cancelled,
|
||||
isRecurring,
|
||||
recurrenceType,
|
||||
location,
|
||||
description,
|
||||
shortDescription,
|
||||
|
|
@ -51,13 +62,15 @@ export function EventPage(
|
|||
group,
|
||||
photo,
|
||||
rsvpLink,
|
||||
isAuthenticated
|
||||
isAuthenticated,
|
||||
upcomingOccurrences,
|
||||
}: EventProps
|
||||
) {
|
||||
const published = useDate(createdAt)
|
||||
const readableDate = readableDateTime(date)
|
||||
const where = locationString(location);
|
||||
const contactPersonPhoto = typeof contact === "object" ? getPhoto("thumbnail", contact.photo) : undefined;
|
||||
const isRecurring = recurrenceType && recurrenceType !== 'none'
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -170,6 +183,32 @@ export function EventPage(
|
|||
</EventExcerpt>
|
||||
|
||||
</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/>
|
||||
<AdminMenu
|
||||
collection={"event"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import moment from 'moment'
|
||||
import { fetchEvents } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { fetchWorship } from '@/fetch/worship'
|
||||
import { fetchBlogPosts } from '@/fetch/blog'
|
||||
import { fetchHighlights } from '@/fetch/highlights'
|
||||
|
|
@ -13,7 +13,7 @@ export const Home = async () => {
|
|||
const fromDate = moment().isoWeekday(1).hours(0).minutes(0)
|
||||
const tillDate = moment().isoWeekday(7).hours(23).minutes(59)
|
||||
|
||||
const events = await fetchEvents()
|
||||
const events = await fetchUpcomingOccurrences()
|
||||
const worship = await fetchWorship({
|
||||
fromDate: fromDate.toDate(),
|
||||
tillDate: tillDate.toDate(),
|
||||
|
|
|
|||
|
|
@ -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 { Container } from '@/components/Container/Container'
|
||||
import { Section } from '@/components/Section/Section'
|
||||
|
|
@ -13,7 +13,7 @@ import { ContentWithSlider } from '@/compositions/ContentWithSlider/ContentWithS
|
|||
import { EventRow } from '@/components/EventRow/EventRow'
|
||||
import { highlightLink } from '@/utils/dto/highlight'
|
||||
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 { CollapsibleImageWithText } from '@/compositions/CollapsibleImageWithText/CollapsibleImageWithText'
|
||||
import { MoreInformation } from '@/pageComponents/Home/MoreInformation'
|
||||
|
|
@ -23,7 +23,7 @@ import { Link, PopupButton } from '@/components/PopupButton/PopupButton'
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
type HomeViewProps = {
|
||||
events: Event[],
|
||||
events: EventOccurrence[],
|
||||
worship: Worship[],
|
||||
blog: Blog[],
|
||||
highlights: Highlight[],
|
||||
|
|
@ -178,7 +178,7 @@ export const HomeView = ({
|
|||
title={'Veranstaltungen'}
|
||||
/>
|
||||
<Events
|
||||
events={transformEvents(events)}
|
||||
events={transformOccurrences(events)}
|
||||
n={6}
|
||||
schema={"contrast"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { TwoColumnText } from '@/components/TwoColumnText/TwoColumnText'
|
|||
import { Events } from '@/compositions/Events/Events'
|
||||
import { MarginBottom } from '@/components/Margin/MarbinBottom'
|
||||
import { ContactPersonList } from '@/components/ContactPerson/ContactPersonList'
|
||||
import { Event, Worship } from '@/payload-types'
|
||||
import { transformEvents } from '@/utils/dto/events'
|
||||
import { EventOccurrence, Worship } from '@/payload-types'
|
||||
import { transformOccurrences } from '@/utils/dto/events'
|
||||
import { tranformWorship } from '@/utils/dto/worship'
|
||||
import { TextDiv } from '@/components/Text/TextDiv'
|
||||
import { Gallery, GalleryItem } from '@/components/Gallery/Gallery'
|
||||
|
|
@ -29,7 +29,7 @@ type ParishProps = {
|
|||
description: string
|
||||
}[],
|
||||
contact: string
|
||||
events: Event[],
|
||||
events: EventOccurrence[],
|
||||
worship: Worship[]
|
||||
announcement?: string,
|
||||
calendar?: string,
|
||||
|
|
@ -72,7 +72,7 @@ export const Parish = (
|
|||
<Row>
|
||||
<Col>
|
||||
<Title title={"Veranstaltungen"} size={"md"} color={"contrast"}/>
|
||||
<Events events={transformEvents(events)} n={4} schema={"contrast"}/>
|
||||
<Events events={transformOccurrences(events)} n={4} schema={"contrast"}/>
|
||||
|
||||
<MarginBottom />
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export interface Config {
|
|||
blog: Blog;
|
||||
highlight: Highlight;
|
||||
event: Event;
|
||||
eventOccurrence: EventOccurrence;
|
||||
classifieds: Classified;
|
||||
contactPerson: ContactPerson;
|
||||
locations: Location;
|
||||
|
|
@ -105,6 +106,7 @@ export interface Config {
|
|||
blog: BlogSelect<false> | BlogSelect<true>;
|
||||
highlight: HighlightSelect<false> | HighlightSelect<true>;
|
||||
event: EventSelect<false> | EventSelect<true>;
|
||||
eventOccurrence: EventOccurrenceSelect<false> | EventOccurrenceSelect<true>;
|
||||
classifieds: ClassifiedsSelect<false> | ClassifiedsSelect<true>;
|
||||
contactPerson: ContactPersonSelect<false> | ContactPersonSelect<true>;
|
||||
locations: LocationsSelect<false> | LocationsSelect<true>;
|
||||
|
|
@ -144,6 +146,7 @@ export interface Config {
|
|||
jobs: {
|
||||
tasks: {
|
||||
generateRecurringMasses: TaskGenerateRecurringMasses;
|
||||
generateEventOccurrences: TaskGenerateEventOccurrences;
|
||||
inline: {
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
|
|
@ -1062,16 +1065,23 @@ export interface Event {
|
|||
title: string;
|
||||
date: string;
|
||||
location: string | Location;
|
||||
parish?: (string | Parish)[] | null;
|
||||
group?: (string | Group)[] | null;
|
||||
contact?: (string | null) | ContactPerson;
|
||||
shortDescription: string;
|
||||
description: string;
|
||||
contact?: (string | null) | ContactPerson;
|
||||
rsvpLink?: string | null;
|
||||
parish?: (string | Parish)[] | null;
|
||||
group?: (string | Group)[] | null;
|
||||
photo?: (string | null) | Media;
|
||||
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;
|
||||
isRecurring: boolean;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
|
|
@ -1094,6 +1104,19 @@ export interface Location {
|
|||
updatedAt: 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.
|
||||
*
|
||||
|
|
@ -1300,7 +1323,7 @@ export interface PayloadJob {
|
|||
| {
|
||||
executedAt: string;
|
||||
completedAt: string;
|
||||
taskSlug: 'inline' | 'generateRecurringMasses';
|
||||
taskSlug: 'inline' | 'generateRecurringMasses' | 'generateEventOccurrences';
|
||||
taskID: string;
|
||||
input?:
|
||||
| {
|
||||
|
|
@ -1333,7 +1356,7 @@ export interface PayloadJob {
|
|||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
taskSlug?: ('inline' | 'generateRecurringMasses') | null;
|
||||
taskSlug?: ('inline' | 'generateRecurringMasses' | 'generateEventOccurrences') | null;
|
||||
queue?: string | null;
|
||||
waitUntil?: string | null;
|
||||
processing?: boolean | null;
|
||||
|
|
@ -1392,6 +1415,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'event';
|
||||
value: string | Event;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'eventOccurrence';
|
||||
value: string | EventOccurrence;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'classifieds';
|
||||
value: string | Classified;
|
||||
|
|
@ -1787,20 +1814,33 @@ export interface EventSelect<T extends boolean = true> {
|
|||
title?: T;
|
||||
date?: T;
|
||||
location?: T;
|
||||
parish?: T;
|
||||
group?: T;
|
||||
contact?: T;
|
||||
shortDescription?: T;
|
||||
description?: T;
|
||||
contact?: T;
|
||||
rsvpLink?: T;
|
||||
parish?: T;
|
||||
group?: T;
|
||||
photo?: T;
|
||||
flyer?: T;
|
||||
recurrenceType?: T;
|
||||
endDate?: T;
|
||||
cancelled?: T;
|
||||
isRecurring?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: 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
|
||||
* via the `definition` "classifieds_select".
|
||||
|
|
@ -2638,6 +2678,21 @@ export interface TaskGenerateRecurringMasses {
|
|||
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
|
||||
* via the `definition` "auth".
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { de } from '@payloadcms/translations/languages/de'
|
|||
import { Parish } from '@/collections/Parish'
|
||||
import { Groups } from '@/collections/Groups'
|
||||
import { Events } from '@/collections/Events'
|
||||
import { EventOccurrences } from '@/collections/EventOccurrences'
|
||||
import { Announcements } from '@/collections/Announcements'
|
||||
import { Blog } from '@/collections/Blog'
|
||||
import { Highlight } from '@/collections/Highlight'
|
||||
|
|
@ -44,6 +45,7 @@ import { Pages } from '@/collections/Pages'
|
|||
import { Prayers } from '@/collections/Prayers'
|
||||
import { siteConfig } from '@/config/site'
|
||||
import { generateRecurringMassesTask } from '@/jobs/generateRecurringMasses'
|
||||
import { generateEventOccurrencesTask } from '@/jobs/generateEventOccurrences'
|
||||
import { searchPlugin } from '@payloadcms/plugin-search'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
|
|
@ -90,6 +92,7 @@ export default buildConfig({
|
|||
Blog,
|
||||
Highlight,
|
||||
Events,
|
||||
EventOccurrences,
|
||||
Classifieds,
|
||||
ContactPerson,
|
||||
Locations,
|
||||
|
|
@ -107,7 +110,7 @@ export default buildConfig({
|
|||
FooterGlobal,
|
||||
],
|
||||
jobs: {
|
||||
tasks: [generateRecurringMassesTask],
|
||||
tasks: [generateRecurringMassesTask, generateEventOccurrencesTask],
|
||||
autoRun: [
|
||||
{
|
||||
// every 15 minutes (6-field cron, seconds first)
|
||||
|
|
|
|||
|
|
@ -1,23 +1,44 @@
|
|||
import { Event } from '@/payload-types'
|
||||
import { Event, EventOccurrence } from '@/payload-types'
|
||||
|
||||
type EventWithId = {
|
||||
id: string,
|
||||
date: string,
|
||||
title: string,
|
||||
href: string,
|
||||
location: string,
|
||||
type EventRowItem = {
|
||||
id: string
|
||||
date: string
|
||||
title: string
|
||||
href: string
|
||||
location: string
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export const transformEvents = (events: Event[]): EventWithId[] => {
|
||||
return events.map(e => {
|
||||
return {
|
||||
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,
|
||||
}
|
||||
})
|
||||
export const transformOccurrences = (
|
||||
occurrences: EventOccurrence[],
|
||||
): EventRowItem[] => {
|
||||
return occurrences
|
||||
.map((o) => {
|
||||
const event = typeof o.event === 'object' ? o.event : undefined
|
||||
if (!event) return undefined
|
||||
return {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
Loading…
Reference in a new issue