church-website/src/collections/Events.ts
2026-07-16 13:27:37 +02:00

448 lines
15 KiB
TypeScript

import { CollectionConfig } from 'payload'
import { isPublishedPublic } from '@/collections/access/public'
import {
canCreateAssignedEvent,
canMutateAssignedEvent,
defaultToOnlyAssigned,
filterEventsToAssigned,
getAssignedIds,
hasAssignedMatch,
hideUnlessAssignedAny,
toIdArray,
unassignedAdditions,
} from '@/collections/access/assigned'
import { regenerateOccurrencesForEvent } from '@/jobs/generateEventOccurrences'
export const Events: CollectionConfig = {
slug: 'event',
labels: {
singular: {
de: 'Veranstaltung',
},
plural: {
de: 'Veranstaltungen',
},
},
fields: [
{
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: 'endDateTime',
type: 'date',
label: {
de: 'Enduhrzeit',
},
admin: {
date: {
pickerAppearance: 'dayAndTime',
timeIntervals: 15,
timeFormat: 'HH:mm',
},
},
},
{
name: 'cancelled',
type: 'checkbox',
required: true,
label: {
de: 'Abgesagt',
},
defaultValue: false,
},
{
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: 'parish',
type: 'relationship',
relationTo: 'parish',
hasMany: true,
label: {
de: 'Gemeinde',
},
defaultValue: defaultToOnlyAssigned('parishes'),
// A Gläubiger may only ADD their own parishes — unless they are
// assigned to a group; group members may attach any parish.
// Connections already on the doc may always be kept.
validate: (value: unknown, options: any) => {
const user = options.req?.user
if (!user || user.roles !== 'user') return true
if (getAssignedIds(user, 'groups').length > 0) return true
if (unassignedAdditions(user, 'parishes', value, options.previousValue).length > 0) {
return 'Sie können nur Gemeinden hinzufügen, die Ihnen zugewiesen sind.'
}
return true
},
},
{
name: 'group',
type: 'relationship',
relationTo: 'group',
hasMany: true,
label: {
de: 'Gruppe',
},
defaultValue: defaultToOnlyAssigned('groups'),
// Combined rule for parish AND group (validated once, here):
// a Gläubiger's event needs at least one connection, and at
// least one of them must be an own assignment. Additional
// foreign connections are allowed.
validate: (value: unknown, options: any) => {
const user = options.req?.user
// No user = local API (jobs, scripts); unauthenticated REST
// writes are already blocked by the collection access.
if (!user || user.roles !== 'user') {
return true
}
const groupIds = toIdArray(value)
const parishIds = toIdArray(options.data?.parish)
// Parish-assigned users may attach any group; otherwise only own groups may be added.
if (
getAssignedIds(user, 'parishes').length === 0 &&
unassignedAdditions(user, 'groups', value, options.previousValue).length > 0
) {
return 'Sie können nur Gruppen hinzufügen, die Ihnen zugewiesen sind.'
}
if (groupIds.length === 0 && parishIds.length === 0) {
return 'Sie müssen die Veranstaltung mit mindestens einer Gruppe oder Gemeinde verknüpfen.'
}
if (
!hasAssignedMatch(user, groupIds, 'groups') &&
!hasAssignedMatch(user, parishIds, 'parishes')
) {
return 'Mindestens eine der ausgewählten Gruppen oder Gemeinden muss Ihnen zugewiesen sein.'
}
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: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Alle 2 Wochen', value: 'biweekly' },
{ label: 'Monatlich (gleicher Kalendertag)', value: 'monthlyByDate' },
{ label: 'Monatlich (n-ter Wochentag)', value: 'monthlyByWeekday' },
{ label: 'Benutzerdefiniert (mehrere Regeln)', value: 'custom' },
],
admin: {
description:
'Bei wiederkehrenden Veranstaltungen werden automatisch Termine für die nächsten Wochen erzeugt. Wochentag, Kalendertag und Uhrzeit werden aus dem Datumsfeld übernommen.',
components: {
Field:
'@/admin/components/RecurrenceTypeField/RecurrenceTypeField#RecurrenceTypeField',
},
},
},
{
name: 'recurrenceRules',
type: 'array',
label: { de: 'Wiederholungsregeln' },
labels: {
singular: { de: 'Regel' },
plural: { de: 'Regeln' },
},
admin: {
condition: (_data, siblingData) =>
siblingData?.recurrenceType === 'custom',
description:
'Mehrere Regeln werden kombiniert (z. B. „3. Dienstag und 4. Donnerstag jeden Monat").',
},
validate: (value, { siblingData }: { siblingData: any }) => {
if (siblingData?.recurrenceType !== 'custom') return true
if (!Array.isArray(value) || value.length === 0) {
return 'Mindestens eine Regel erforderlich, wenn Wiederholung = Benutzerdefiniert.'
}
const rules = value as Array<{
frequency?: string
weekday?: string | null
weekOfMonth?: string | null
dayOfMonth?: number | null
}>
for (let i = 0; i < rules.length; i++) {
const r = rules[i]
if (r?.frequency === 'weekly' && !r.weekday) {
return `Regel ${i + 1}: Wochentag fehlt.`
}
if (r?.frequency === 'monthlyByWeekday' && (!r.weekday || !r.weekOfMonth)) {
return `Regel ${i + 1}: Wochentag und Position im Monat sind erforderlich.`
}
if (r?.frequency === 'monthlyByDate' && typeof r.dayOfMonth !== 'number') {
return `Regel ${i + 1}: Kalendertag fehlt.`
}
}
return true
},
fields: [
{
name: 'frequency',
type: 'select',
required: true,
label: { de: 'Art' },
options: [
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Monatlich (Kalendertag)', value: 'monthlyByDate' },
{ label: 'Monatlich (n-ter Wochentag)', value: 'monthlyByWeekday' },
],
},
{
name: 'weekday',
type: 'select',
label: { de: 'Wochentag' },
options: [
{ label: 'Montag', value: 'monday' },
{ label: 'Dienstag', value: 'tuesday' },
{ label: 'Mittwoch', value: 'wednesday' },
{ label: 'Donnerstag', value: 'thursday' },
{ label: 'Freitag', value: 'friday' },
{ label: 'Samstag', value: 'saturday' },
{ label: 'Sonntag', value: 'sunday' },
],
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'weekly' ||
siblingData?.frequency === 'monthlyByWeekday',
},
},
{
name: 'weekOfMonth',
type: 'select',
label: { de: 'Position im Monat' },
options: [
{ label: '1.', value: 'first' },
{ label: '2.', value: 'second' },
{ label: '3.', value: 'third' },
{ label: '4.', value: 'fourth' },
{ label: 'Letzter', value: 'last' },
],
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByWeekday',
},
},
{
name: 'dayOfMonth',
type: 'number',
label: { de: 'Kalendertag' },
min: 1,
max: 31,
admin: {
condition: (_data, siblingData) =>
siblingData?.frequency === 'monthlyByDate',
},
},
],
},
{
name: 'endDate',
type: 'date',
label: {
de: 'Enddatum',
},
admin: {
date: {
pickerAppearance: 'dayOnly',
},
description: 'Optional. Nach diesem Datum werden keine weiteren Termine erzeugt.',
},
},
],
},
{
label: { de: 'Nächste Termine' },
fields: [
{
name: 'nextOccurrences',
type: 'ui',
admin: {
components: {
Field: {
path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable',
},
},
},
},
],
},
],
},
],
admin: {
useAsTitle: 'title',
hidden: hideUnlessAssignedAny(['groups', 'parishes']),
baseFilter: filterEventsToAssigned(),
livePreview: {
url: ({ data }) => `/api/draft?url=/veranstaltungen/${data.id}`,
},
},
versions: {
drafts: {
validate: true
},
},
access: {
read: isPublishedPublic(),
create: canCreateAssignedEvent(),
update: canMutateAssignedEvent(),
delete: canMutateAssignedEvent(),
},
hooks: {
afterChange: [
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 },
'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,
})
},
],
},
}