452 lines
15 KiB
TypeScript
452 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'
|
|
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,
|
|
})
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|