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' import { revalidateTagHook } from '@/utils/revalidate' 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: '5.', value: 'fifth' }, { 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', ) } }, revalidateTagHook('events'), ], 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, }) }, ], }, }