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>
This commit is contained in:
2026-08-25 22:57:31 +02:00
co-authored by Claude Sonnet 5
parent 50c39a70e0
commit 1cd15aff25
72 changed files with 2835 additions and 257 deletions
+352
View File
@@ -78,6 +78,7 @@ export interface Config {
availability: Availability;
'availability-overrides': AvailabilityOverride;
'booking-requests': BookingRequest;
redirects: Redirect;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
@@ -95,6 +96,7 @@ export interface Config {
availability: AvailabilitySelect<false> | AvailabilitySelect<true>;
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -113,6 +115,7 @@ export interface Config {
booking: Booking;
'meeting-settings': MeetingSetting;
'booking-settings': BookingSetting;
'seo-settings': SeoSetting;
};
globalsSelect: {
home: HomeSelect<false> | HomeSelect<true>;
@@ -123,6 +126,7 @@ export interface Config {
booking: BookingSelect<false> | BookingSelect<true>;
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
};
locale: null;
widgets: {
@@ -180,6 +184,7 @@ export interface User {
id: number;
name: string;
role: 'admin';
calendarFeedTokenHash?: string | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -212,6 +217,12 @@ export interface Customer {
* Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.
*/
phone?: string | null;
/**
* Wird automatisch gesetzt, sobald der Bestätigungslink aus der E-Mail angeklickt wird. Kann hier bei Bedarf manuell gesetzt werden.
*/
emailVerified?: boolean | null;
emailVerificationTokenHash?: string | null;
emailVerificationExpires?: string | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -352,6 +363,21 @@ export interface Event {
*/
isPrivateBooking?: boolean | null;
bookingRequest?: (number | null) | BookingRequest;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -439,6 +465,21 @@ export interface Offer {
*/
price?: string | null;
durationMinutes?: number | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -477,6 +518,22 @@ export interface Post {
};
coverImage?: (number | null) | Media;
publishDate?: string | null;
author?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
@@ -533,6 +590,27 @@ export interface AvailabilityOverride {
updatedAt: string;
createdAt: string;
}
/**
* Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "redirects".
*/
export interface Redirect {
id: number;
/**
* Nur der Pfad, z. B. /angebote/doula (ohne Domain).
*/
fromPath: string;
/**
* Ziel-Pfad, z. B. /angebote/doula-begleitung.
*/
toPath: string;
type: 'permanent' | 'temporary';
enabled?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
@@ -596,6 +674,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'booking-requests';
value: number | BookingRequest;
} | null)
| ({
relationTo: 'redirects';
value: number | Redirect;
} | null);
globalSlug?: string | null;
user:
@@ -656,6 +738,7 @@ export interface PayloadMigration {
export interface UsersSelect<T extends boolean = true> {
name?: T;
role?: T;
calendarFeedTokenHash?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
@@ -680,6 +763,9 @@ export interface UsersSelect<T extends boolean = true> {
export interface CustomersSelect<T extends boolean = true> {
name?: T;
phone?: T;
emailVerified?: T;
emailVerificationTokenHash?: T;
emailVerificationExpires?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
@@ -777,6 +863,14 @@ export interface EventsSelect<T extends boolean = true> {
hostReminder30Sent?: T;
isPrivateBooking?: T;
bookingRequest?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -792,6 +886,15 @@ export interface PostsSelect<T extends boolean = true> {
content?: T;
coverImage?: T;
publishDate?: T;
author?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -812,6 +915,14 @@ export interface OffersSelect<T extends boolean = true> {
bookable?: T;
price?: T;
durationMinutes?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
_status?: T;
@@ -881,6 +992,18 @@ export interface BookingRequestsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "redirects_select".
*/
export interface RedirectsSelect<T extends boolean = true> {
fromPath?: T;
toPath?: T;
type?: T;
enabled?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
@@ -963,6 +1086,21 @@ export interface Home {
| null;
ctaTitle?: string | null;
ctaLead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -995,6 +1133,21 @@ export interface About {
closingLead?: string | null;
closingHighlight?: string | null;
closingParagraph?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1009,6 +1162,21 @@ export interface AngeboteIntro {
eyebrow?: string | null;
title: string;
lead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1023,6 +1191,21 @@ export interface AktuellesIntro {
eyebrow?: string | null;
title: string;
lead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1040,6 +1223,21 @@ export interface Contact {
email: string;
phone?: string | null;
region?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1062,6 +1260,21 @@ export interface Booking {
}[]
| null;
formNote?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1091,6 +1304,63 @@ export interface BookingSetting {
street?: string | null;
postalCode?: string | null;
city?: string | null;
region?: string | null;
/**
* Für strukturierte Daten, z. B. „Deutschland“.
*/
country?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "seo-settings".
*/
export interface SeoSetting {
id: number;
/**
* Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.
*/
siteUrl?: string | null;
/**
* Wird verwendet, wenn eine Seite keine eigene Meta-Beschreibung hat.
*/
defaultDescription?: string | null;
/**
* Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat.
*/
defaultOgImage?: (number | null) | Media;
/**
* Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.
*/
robotsIndexable?: boolean | null;
businessName?: string | null;
/**
* Kurzer, sachlicher Beschreibungstext für strukturierte Daten (nicht der Website-Text).
*/
businessDescription?: string | null;
latitude?: number | null;
longitude?: number | null;
/**
* Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten).
*/
openingHours?:
| {
days: ('Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday')[];
opens: string;
closes: string;
id?: string | null;
}[]
| null;
socialLinks?:
| {
platform: string;
url: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null;
createdAt?: string | null;
}
@@ -1119,6 +1389,14 @@ export interface HomeSelect<T extends boolean = true> {
};
ctaTitle?: T;
ctaLead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1135,6 +1413,14 @@ export interface AboutSelect<T extends boolean = true> {
closingLead?: T;
closingHighlight?: T;
closingParagraph?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1147,6 +1433,14 @@ export interface AngeboteIntroSelect<T extends boolean = true> {
eyebrow?: T;
title?: T;
lead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1159,6 +1453,14 @@ export interface AktuellesIntroSelect<T extends boolean = true> {
eyebrow?: T;
title?: T;
lead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1174,6 +1476,14 @@ export interface ContactSelect<T extends boolean = true> {
email?: T;
phone?: T;
region?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1194,6 +1504,14 @@ export interface BookingSelect<T extends boolean = true> {
id?: T;
};
formNote?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;
@@ -1219,6 +1537,40 @@ export interface BookingSettingsSelect<T extends boolean = true> {
street?: T;
postalCode?: T;
city?: T;
region?: T;
country?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "seo-settings_select".
*/
export interface SeoSettingsSelect<T extends boolean = true> {
siteUrl?: T;
defaultDescription?: T;
defaultOgImage?: T;
robotsIndexable?: T;
businessName?: T;
businessDescription?: T;
latitude?: T;
longitude?: T;
openingHours?:
| T
| {
days?: T;
opens?: T;
closes?: T;
id?: T;
};
socialLinks?:
| T
| {
platform?: T;
url?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
globalType?: T;