diff --git a/src/admin/components/NextOccurrencesTable/NextOccurrencesTable.tsx b/src/admin/components/NextOccurrencesTable/NextOccurrencesTable.tsx new file mode 100644 index 0000000..346ba25 --- /dev/null +++ b/src/admin/components/NextOccurrencesTable/NextOccurrencesTable.tsx @@ -0,0 +1,166 @@ +'use client' + +import { CheckboxInput, useDocumentInfo } from '@payloadcms/ui' +import { useCallback, useEffect, useState } from 'react' + +type Occurrence = { + id: string + date: string + cancelled: boolean +} + +const LIMIT = 10 + +const formatDate = (iso: string): string => + new Date(iso).toLocaleString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +export const NextOccurrencesTable = () => { + const { id } = useDocumentInfo() + const [occurrences, setOccurrences] = useState([]) + const [loading, setLoading] = useState(false) + const [pendingIds, setPendingIds] = useState>(new Set()) + const [errorIds, setErrorIds] = useState>(new Set()) + const [fetchError, setFetchError] = useState(null) + + useEffect(() => { + if (!id) return + let cancelled = false + setLoading(true) + setFetchError(null) + + const params = new URLSearchParams({ + 'where[event][equals]': String(id), + 'where[date][greater_than_equal]': new Date().toISOString(), + sort: 'date', + limit: String(LIMIT), + depth: '0', + }) + + fetch(`/api/eventOccurrence?${params.toString()}`, { credentials: 'include' }) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then((json: { docs: Occurrence[] }) => { + if (cancelled) return + setOccurrences(json.docs) + }) + .catch((err: Error) => { + if (cancelled) return + setFetchError(err.message) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [id]) + + const toggle = useCallback(async (occ: Occurrence) => { + const next = !occ.cancelled + setOccurrences((prev) => + prev.map((o) => (o.id === occ.id ? { ...o, cancelled: next } : o)), + ) + setPendingIds((prev) => new Set(prev).add(occ.id)) + setErrorIds((prev) => { + const s = new Set(prev) + s.delete(occ.id) + return s + }) + + try { + const res = await fetch(`/api/eventOccurrence/${occ.id}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cancelled: next }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch { + setOccurrences((prev) => + prev.map((o) => (o.id === occ.id ? { ...o, cancelled: occ.cancelled } : o)), + ) + setErrorIds((prev) => new Set(prev).add(occ.id)) + } finally { + setPendingIds((prev) => { + const s = new Set(prev) + s.delete(occ.id) + return s + }) + } + }, []) + + if (!id) { + return ( +
+ Veranstaltung erst speichern, um Termine zu verwalten. +
+ ) + } + + if (loading) { + return
Wird geladen…
+ } + + if (fetchError) { + return ( +
+ Fehler beim Laden: {fetchError} +
+ ) + } + + if (occurrences.length === 0) { + return ( +
+ Keine zukünftigen Termine vorhanden. +
+ ) + } + + return ( +
+ + + + + + + + + {occurrences.map((occ) => ( + + + + + ))} + +
DatumAbgesagt
{formatDate(occ.date)} + toggle(occ)} + readOnly={pendingIds.has(occ.id)} + /> + {errorIds.has(occ.id) && ( +
+ Speichern fehlgeschlagen. +
+ )} +
+
+ ) +} + +export default NextOccurrencesTable diff --git a/src/app/(home)/[[...slug]]/page.tsx b/src/app/(home)/[[...slug]]/page.tsx index 8adf17e..5d99038 100644 --- a/src/app/(home)/[[...slug]]/page.tsx +++ b/src/app/(home)/[[...slug]]/page.tsx @@ -4,9 +4,8 @@ import { Blocks } from '@/compositions/Blocks/Blocks' import { Metadata } from 'next' import { AdminMenu } from '@/components/AdminMenu/AdminMenu' import { Section } from '@/components/Section/Section' -import { isAuthenticated } from '@/utils/auth' +import { getRequestAuth } from '@/utils/auth' import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave' -import { draftMode } from 'next/headers' type Props = { params: Promise<{ slug?: string[] }> @@ -26,9 +25,8 @@ export async function generateMetadata({ params }: Props): Promise { export default async function DynamicPage({ params }: Props) { const slug = (await params).slug - const { isEnabled: isDraft } = await draftMode() + const { authenticated, isDraft } = await getRequestAuth() const page = await fetchPageBySlug(slug?.join('/') || "", isDraft) - const authenticated = await isAuthenticated() if (!page) { notFound() diff --git a/src/app/(home)/blog/[id]/page.tsx b/src/app/(home)/blog/[id]/page.tsx index 8b8a5f5..b3ecf1b 100644 --- a/src/app/(home)/blog/[id]/page.tsx +++ b/src/app/(home)/blog/[id]/page.tsx @@ -9,27 +9,19 @@ import { HR } from '@/components/HorizontalRule/HorizontalRule' import Image from 'next/image' import styles from './styles.module.scss' import { Blocks } from '@/compositions/Blocks/Blocks' -import { isAuthenticated } from '@/utils/auth' +import { canView, getRequestAuth } from '@/utils/auth' import { AdminMenu } from '@/components/AdminMenu/AdminMenu' import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave' -import { draftMode } from 'next/headers' import { fetchBlog } from '@/fetch/blog' export default async function BlogPage({ params }: { params: Promise<{id: string}>}){ const id = (await params).id; - const { isEnabled: isDraft } = await draftMode() + const { authenticated, isDraft } = await getRequestAuth() const data = await fetchBlog(id, isDraft) as Blog; const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url; - const authenticated = await isAuthenticated(); - if(!data) { - notFound(); - } - - if(!authenticated && data._status !== "published") { - notFound(); - } + if (!canView(data, authenticated)) notFound() // determine if some margin at the bottom should be added const length = data.content.content.length; diff --git a/src/app/(home)/gemeinde/[slug]/page.tsx b/src/app/(home)/gemeinde/[slug]/page.tsx index 77bf057..6caac50 100644 --- a/src/app/(home)/gemeinde/[slug]/page.tsx +++ b/src/app/(home)/gemeinde/[slug]/page.tsx @@ -6,25 +6,17 @@ import { fetchParish } from '@/fetch/parish' import { fetchLastAnnouncement } from '@/fetch/announcement' import { getPhoto, transformGallery } from '@/utils/dto/gallery' import { fetchLastCalendar } from '@/fetch/calendar' -import { isAuthenticated } from '@/utils/auth' +import { canView, getRequestAuth } from '@/utils/auth' import { AdminMenu } from '@/components/AdminMenu/AdminMenu' import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave' -import { draftMode } from 'next/headers' export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) { const slug = (await params).slug; - const { isEnabled: isDraft } = await draftMode() + const { authenticated, isDraft } = await getRequestAuth() const parish = await fetchParish(slug, isDraft); - const authenticated = await isAuthenticated(); - if(!parish) { - notFound(); - } - - if(!authenticated && parish._status !== "published") { - notFound(); - } + if (!canView(parish, authenticated)) notFound() const { id, diff --git a/src/app/(home)/gruppe/[slug]/page.tsx b/src/app/(home)/gruppe/[slug]/page.tsx index 6341b60..14e7fb2 100644 --- a/src/app/(home)/gruppe/[slug]/page.tsx +++ b/src/app/(home)/gruppe/[slug]/page.tsx @@ -11,28 +11,19 @@ import { Col } from '@/components/Flex/Col' import { Row } from '@/components/Flex/Row' import { Blocks } from '@/compositions/Blocks/Blocks' import { getPhoto } from '@/utils/dto/gallery' -import { isAuthenticated } from '@/utils/auth' +import { canView, getRequestAuth } from '@/utils/auth' import { AdminMenu } from '@/components/AdminMenu/AdminMenu' import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents' import { RichText } from '@/components/Text/RichText' import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave' -import { draftMode } from 'next/headers' export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) { const slug = (await params).slug - const { isEnabled: isDraft } = await draftMode() + const { authenticated, isDraft } = await getRequestAuth() const group = await fetchGroup(slug, isDraft) - if(!group) { - notFound(); - } - - const authenticated = await isAuthenticated(); - - if(!authenticated && group._status !== "published") { - notFound(); - } + if (!canView(group, authenticated)) notFound() const {id, shortDescription, photo, name, content, text } = group const media = getPhoto("tablet", photo) diff --git a/src/app/(home)/veranstaltungen/[eventId]/[occurrenceId]/page.tsx b/src/app/(home)/veranstaltungen/[eventId]/[occurrenceId]/page.tsx index 099369a..ee2209e 100644 --- a/src/app/(home)/veranstaltungen/[eventId]/[occurrenceId]/page.tsx +++ b/src/app/(home)/veranstaltungen/[eventId]/[occurrenceId]/page.tsx @@ -1,14 +1,13 @@ 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 { canView, getRequestAuth } from '@/utils/auth' import { fetchOccurrenceById, fetchUpcomingOccurrences, } from '@/fetch/eventOccurrences' import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave' -import { transformOccurrences } from '@/utils/dto/events' +import { eventToPageProps, transformOccurrences } from '@/utils/dto/events' export default async function Page({ params, @@ -16,22 +15,15 @@ export default async function Page({ params: Promise<{ eventId: string; occurrenceId: string }> }) { const { eventId, occurrenceId } = await params - const { isEnabled: isDraft } = await draftMode() + const { authenticated, isDraft } = await getRequestAuth() const occurrence = await fetchOccurrenceById(occurrenceId, isDraft) if (!occurrence) notFound() const event = typeof occurrence.event === 'object' ? occurrence.event : undefined - if (!event) notFound() + if (!canView(event, authenticated)) 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({ @@ -45,19 +37,7 @@ export default async function Page({ <> {isDraft && } }) { const { eventId } = await params - const { isEnabled: isDraft } = await draftMode() - const authenticated = await isAuthenticated() + const { authenticated, isDraft } = await getRequestAuth() const event = await fetchEventById(eventId, isDraft) - if (!event) notFound() - if (!authenticated && event._status !== 'published') notFound() + if (!canView(event, authenticated)) 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 occurrences = await fetchUpcomingOrPastOccurrences({ eventId, limit: 5 }) + const upcomingOccurrences = transformOccurrences(occurrences.docs) const photo = getPhoto('tablet', event.photo) return ( <> {isDraft && } ) diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index a382a27..121a6a4 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable' import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client' import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo' @@ -33,6 +34,7 @@ export const importMap = { "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc, "@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634, "@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634, "/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c, diff --git a/src/collections/Events.ts b/src/collections/Events.ts index 3e48fe2..b98cea1 100644 --- a/src/collections/Events.ts +++ b/src/collections/Events.ts @@ -226,6 +226,22 @@ export const Events: CollectionConfig = { }, ], }, + { + label: { de: 'Nächste Termine' }, + fields: [ + { + name: 'nextOccurrences', + type: 'ui', + admin: { + components: { + Field: { + path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable', + }, + }, + }, + }, + ], + }, ], }, ], @@ -265,13 +281,31 @@ export const Events: CollectionConfig = { }, hooks: { afterChange: [ - async ({ doc, req }) => { + async ({ doc, previousDoc, req }) => { try { await regenerateOccurrencesForEvent({ event: doc, payload: req.payload, req, }) + + // Propagate event.cancelled to all future occurrences when it flips, + // so toggling the event-level flag acts as a master switch. Skipped + // when unchanged so unrelated saves don't overwrite per-occurrence + // cancellations set from the "Nächste Termine" tab. + if (doc.cancelled !== previousDoc?.cancelled) { + await req.payload.update({ + collection: 'eventOccurrence', + where: { + and: [ + { event: { equals: doc.id } }, + { date: { greater_than_equal: new Date().toISOString() } }, + ], + }, + data: { cancelled: doc.cancelled }, + req, + }) + } } catch (err) { req.payload.logger.error( { err, eventId: doc.id }, diff --git a/src/fetch/eventOccurrences.ts b/src/fetch/eventOccurrences.ts index 8931153..8d31ac4 100644 --- a/src/fetch/eventOccurrences.ts +++ b/src/fetch/eventOccurrences.ts @@ -60,6 +60,66 @@ export async function fetchUpcomingOccurrences( }) as Promise> } +/** + * Fetch past event occurrences (most recent first), joined to their parent + * event. Always filters to occurrences whose parent event is published. + */ +export async function fetchPastOccurrences( + args?: ListArgs, +): Promise> { + const { + parishId, + groupId, + eventId, + limit = 30, + page = 0, + fromDate, + toDate = new Date(), + } = args || {} + + const query: any = { + and: [ + { date: { less_than: toDate.toISOString() } }, + { 'event._status': { equals: 'published' } }, + ], + } + + if (fromDate) { + query.and.push({ date: { greater_than_equal: fromDate.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> +} + +/** + * Fetch upcoming occurrences, falling back to past occurrences if there + * are no upcoming ones. + */ +export async function fetchUpcomingOrPastOccurrences( + args?: ListArgs, +): Promise> { + const upcoming = await fetchUpcomingOccurrences(args) + if (upcoming.docs.length > 0) return upcoming + return fetchPastOccurrences(args) +} + /** * Fetch a single occurrence by id with its parent event populated. * Returns undefined if not found or if the id is malformed. diff --git a/src/jobs/generateEventOccurrences.ts b/src/jobs/generateEventOccurrences.ts index 32c48f4..88c29b8 100644 --- a/src/jobs/generateEventOccurrences.ts +++ b/src/jobs/generateEventOccurrences.ts @@ -67,6 +67,29 @@ export const regenerateOccurrencesForEvent = async ({ }): Promise => { const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK) + // Snapshot cancelled dates so manual cancellations survive the wipe/regen. + // Matching by ISO date works because regen derives timestamps from the same + // event.date stepped by whole calendar days — shifts in event.date + // intentionally drop stale cancellations. + const existing = await payload.find({ + collection: 'eventOccurrence', + where: { + and: [ + { event: { equals: event.id } }, + { date: { greater_than_equal: now.toISOString() } }, + ], + }, + depth: 0, + limit: 1000, + pagination: false, + req, + }) + const cancelledDates = new Set( + existing.docs + .filter((occ) => occ.cancelled === true && typeof occ.date === 'string') + .map((occ) => new Date(occ.date as string).toISOString()), + ) + const wipe = await payload.delete({ collection: 'eventOccurrence', where: { @@ -100,7 +123,7 @@ export const regenerateOccurrencesForEvent = async ({ data: { event: event.id, date: eventDate.toISOString(), - cancelled: false, + cancelled: cancelledDates.has(eventDate.toISOString()), generated: true, }, req, @@ -120,12 +143,13 @@ export const regenerateOccurrencesForEvent = async ({ const cursor = new Date(eventDate) while (cursor.getTime() <= effectiveEnd.getTime()) { if (cursor.getTime() >= now.getTime()) { + const iso = cursor.toISOString() await payload.create({ collection: 'eventOccurrence', data: { event: event.id, - date: cursor.toISOString(), - cancelled: false, + date: iso, + cancelled: cancelledDates.has(iso), generated: true, }, req, diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 1db3b39..fb03228 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,4 +1,4 @@ -import { cookies } from 'next/headers' +import { cookies, draftMode } from 'next/headers' /** * Check if the current user is (trying to be) authenticated by @@ -15,4 +15,33 @@ export const isAuthenticated = async (): Promise => { } return true; +} + +/** + * Resolves the per-request auth context used by server pages that need both + * authentication state and Next's draft-mode flag. + */ +export const getRequestAuth = async (): Promise<{ authenticated: boolean; isDraft: boolean }> => { + const [authenticated, { isEnabled: isDraft }] = await Promise.all([ + isAuthenticated(), + draftMode(), + ]) + return { authenticated, isDraft } +} + +type Publishable = { _status?: ('draft' | 'published') | null } + +/** + * Determines if the given entity can be viewed based on its status and authentication state. + * + * @param {T | null | undefined} entity - The entity to check, which may be null or undefined. + * @param {boolean} authenticated - Indicates whether the user is authenticated. + * @return {entity is T} Returns true if the entity can be viewed, otherwise false. + */ +export function canView( + entity: T | null | undefined, + authenticated: boolean, +): entity is T { + if (!entity) return false + return authenticated || entity._status === 'published' } \ No newline at end of file diff --git a/src/utils/dto/events.ts b/src/utils/dto/events.ts index 9fc0cb2..142b924 100644 --- a/src/utils/dto/events.ts +++ b/src/utils/dto/events.ts @@ -1,4 +1,37 @@ +import type { ComponentProps } from 'react' import { Event, EventOccurrence } from '@/payload-types' +import type { EventPage } from '@/pageComponents/Event/Event' + +export const getEventGroupSlug = (event: Event): string | undefined => { + const firstGroup = event.group?.[0] + if (typeof firstGroup !== 'object' || firstGroup === null) return undefined + return firstGroup.slug ?? undefined +} + +type EventPageProps = ComponentProps +type EventPagePropsAdapted = Omit< + EventPageProps, + 'isAuthenticated' | 'upcomingOccurrences' | 'photo' +> + +export const eventToPageProps = ( + event: Event, + occurrence?: EventOccurrence, +): EventPagePropsAdapted => ({ + id: event.id, + title: event.title, + date: occurrence?.date ?? event.date, + createdAt: event.createdAt, + cancelled: Boolean(event.cancelled || occurrence?.cancelled), + recurrenceType: event.recurrenceType, + location: event.location, + description: event.description, + shortDescription: event.shortDescription, + group: getEventGroupSlug(event), + contact: event.contact || undefined, + rsvpLink: event.rsvpLink || undefined, + flyer: typeof event.flyer === 'object' ? event.flyer || undefined : undefined, +}) type EventRowItem = { id: string