Files
anouma/collections/Events.ts
T
maroandClaude Sonnet 5 1cd15aff25 Add email verification, personal calendar feed, and full SEO implementation
- Customer accounts now require email verification (hashed, single-use,
  time-limited tokens) before they can request/confirm bookings, with
  resend flows on login/account/booking widget and rate limiting.
- Admins get a private, rotatable iCalendar (ICS) subscription feed of
  their confirmed bookings and public events, timezone-correct for
  Europe/Berlin including DST, never exposing meeting passwords.
- Adds a full SEO layer: per-page canonical/OG/Twitter metadata with
  CMS-editable overrides and content-derived fallbacks, a dynamic
  sitemap.xml and robots.txt driven by real published content, JSON-LD
  (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service,
  Event, BlogPosting) that never fabricates data, and a CMS-managed
  redirect table for changed slugs.
- Global ANOUMA-naming audit: the brand name is never used to label
  personal account/calendar areas anywhere in the app, CMS, or emails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 22:57:31 +02:00

250 lines
6.8 KiB
TypeScript

import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
export const EVENT_CATEGORIES = [
{ label: "Prozessbegleitung", value: "prozessbegleitung" },
{ label: "Doula", value: "doula" },
{ label: "Erdenkinder", value: "erdenkinder" },
{ label: "Mädchenkreis", value: "maedchenkreis" },
{ label: "Singen im Kreis", value: "singen-im-kreis" },
{ label: "Singen für Schwangere", value: "singen-fuer-schwangere" },
{ label: "Singen für Mamas mit Baby", value: "singen-fuer-mamas-mit-baby" },
{ label: "Sonstiges", value: "sonstiges" },
] as const;
// Auto-generates the meeting password for new online events, and
// regenerates it whenever the admin ticks "Neues Passwort generieren".
// Never derived from the event id/slug — always a fresh random value.
const setMeetingPassword: CollectionBeforeChangeHook = ({ data }) => {
if (!data?.isOnline) return data;
if (!data.meetingPassword || data.regeneratePassword) {
data.meetingPassword = generateMeetingPassword();
}
data.regeneratePassword = false;
return data;
};
export const Events: CollectionConfig = {
slug: "events",
labels: {
singular: "Termin",
plural: "Termine",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "date", "category", "_status"],
group: "Inhalte",
description: "Termine und Veranstaltungen — erscheinen automatisch unter /termine.",
},
versions: {
drafts: {
autosave: { interval: 1500 },
},
},
access: {
read: publishedOrAdmin,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
hooks: {
beforeChange: [setMeetingPassword],
},
fields: [
{
name: "title",
type: "text",
label: "Titel",
required: true,
},
slugField(),
{
name: "description",
type: "richText",
label: "Beschreibung",
},
{
type: "row",
fields: [
{
name: "date",
type: "date",
label: "Datum",
required: true,
admin: {
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
width: "34%",
},
},
{
name: "startTime",
type: "date",
label: "Startzeit",
admin: {
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
width: "33%",
},
},
{
name: "endTime",
type: "date",
label: "Endzeit",
admin: {
date: { pickerAppearance: "timeOnly", displayFormat: "HH:mm" },
width: "33%",
},
},
],
},
{
name: "location",
type: "text",
label: "Ort",
},
{
name: "category",
type: "select",
label: "Kategorie",
required: true,
defaultValue: "sonstiges",
options: [...EVENT_CATEGORIES],
},
{
name: "image",
type: "upload",
relationTo: "media",
label: "Bild",
},
{
type: "row",
fields: [
{
name: "maxParticipants",
type: "number",
label: "Maximale Teilnehmerzahl",
min: 1,
admin: { width: "50%" },
},
{
name: "registrationRequired",
type: "checkbox",
label: "Anmeldung erforderlich",
defaultValue: false,
admin: { width: "50%" },
},
],
},
{
name: "registrationInfo",
type: "text",
label: "Anmeldelink bzw. Kontaktmöglichkeit",
admin: {
description: "z. B. eine E-Mail-Adresse, ein Link oder ein Hinweistext.",
},
},
{
type: "collapsible",
label: "Online-Termin (Video-Call)",
fields: [
{
name: "isOnline",
type: "checkbox",
label: "Online-Termin",
defaultValue: false,
admin: {
description: "Aktiviert Meeting-Passwort, Beitrittsseite und Erinnerungs-E-Mails.",
},
},
{
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
description: "Wird automatisch erzeugt. Ist nicht Teil des Meeting-Links.",
},
},
{
name: "regeneratePassword",
type: "checkbox",
label: "Neues Passwort generieren (beim Speichern)",
defaultValue: false,
admin: {
condition: (data) => Boolean(data?.isOnline),
},
},
{
type: "row",
admin: { condition: (data) => Boolean(data?.isOnline) },
fields: [
{
name: "reminder60Enabled",
type: "checkbox",
label: "Erinnerung 60 Minuten vorher",
defaultValue: true,
admin: { width: "34%" },
},
{
name: "reminder30Enabled",
type: "checkbox",
label: "Erinnerung 30 Minuten vorher",
defaultValue: true,
admin: { width: "33%" },
},
{
name: "hostReminderEnabled",
type: "checkbox",
label: "Auch Host per E-Mail erinnern",
defaultValue: true,
admin: { width: "33%" },
},
],
},
{
type: "row",
admin: { condition: (data) => Boolean(data?.isOnline) },
fields: [
{
name: "hostReminder60Sent",
type: "checkbox",
label: "Host-Reminder 60 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
{
name: "hostReminder30Sent",
type: "checkbox",
label: "Host-Reminder 30 Min. gesendet",
defaultValue: false,
admin: { readOnly: true, width: "50%" },
},
],
},
],
},
{
name: "isPrivateBooking",
type: "checkbox",
label: "Aus privater Buchungsanfrage erzeugt",
defaultValue: false,
admin: {
position: "sidebar",
readOnly: true,
description: "Wird automatisch gesetzt, wenn dieser Termin aus einer bestätigten Einzelbuchung entstanden ist. Solche Termine bleiben unveröffentlicht.",
},
},
{
name: "bookingRequest",
type: "relationship",
relationTo: "booking-requests",
label: "Zugehörige Buchungsanfrage",
admin: { position: "sidebar", readOnly: true },
},
seoFields(),
],
};