feat: add Timeline Collection for complex chronological events

Add dedicated Timeline Collection for managing complex timeline events:

- Collection: Multiple types (history, milestones, releases, career, events, process)
- Events: Flexible date handling (year, month+year, full date, ranges, custom text)
- Categories: milestone, founding, product, team, award, partnership, expansion, technology
- Importance levels: highlight, normal, minor
- Display options: layouts (vertical, alternating, horizontal, compact), sorting, year grouping
- Media: Image and gallery support per event
- Localization: Full support for DE/EN
- SEO: Meta fields for each timeline

API Features:
- Public endpoint at /api/timelines with tenant isolation
- Rate limiting and IP blocking
- Filter by type, slug, category, importance
- Locale parameter support
- Date formatting and sorting
- Optional grouping by year

Database: 8 tables created via migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-13 10:22:56 +00:00
parent e8532b388d
commit 3f61050fb3
9 changed files with 23291 additions and 1 deletions

View file

@ -300,6 +300,7 @@ PGPASSWORD="$DB_PASSWORD" psql -h 10.10.181.101 -U payload -d payload_db -c "\dt
- **Newsletter Anmeldung:** https://pl.c2sgmbh.de/api/newsletter/subscribe (POST, öffentlich)
- **Newsletter Bestätigung:** https://pl.c2sgmbh.de/api/newsletter/confirm (GET/POST)
- **Newsletter Abmeldung:** https://pl.c2sgmbh.de/api/newsletter/unsubscribe (GET/POST)
- **Timeline API:** https://pl.c2sgmbh.de/api/timelines (GET, öffentlich, tenant required)
## Security-Features
@ -575,6 +576,48 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
| CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration |
| CookieInventory | cookie-inventory | Cookie-Inventar |
| ConsentLogs | consent-logs | Consent-Protokollierung |
| Timelines | timelines | Chronologische Events (Geschichte, Meilensteine) |
## Timeline Collection
Dedizierte Collection für komplexe chronologische Darstellungen:
**Timeline-Typen:**
- `history` - Unternehmensgeschichte
- `milestones` - Projektmeilensteine
- `releases` - Produkt-Releases
- `career` - Karriere/Lebenslauf
- `events` - Ereignisse
- `process` - Prozess/Ablauf
**Event-Features:**
- Flexible Datumsformate (Jahr, Monat+Jahr, vollständig, Zeitraum, Freitext)
- Kategorien (Meilenstein, Gründung, Produkt, Team, Auszeichnung, etc.)
- Wichtigkeitsstufen (Highlight, Normal, Minor)
- Bilder und Galerien
- Links und Metadaten
- Rich-Text-Beschreibungen
**Display-Optionen:**
- Layouts: vertikal, alternierend, horizontal, kompakt
- Sortierung: aufsteigend/absteigend
- Gruppierung nach Jahr
- Verschiedene Marker-Stile
**API-Endpoint:**
```bash
# Liste aller Timelines eines Tenants
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1"
# Nach Typ filtern
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&type=history"
# Einzelne Timeline
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&slug=company-history"
# Mit Sprache
curl "https://pl.c2sgmbh.de/api/timelines?tenant=1&locale=en"
```
## FormSubmissions CRM-Workflow

View file

@ -0,0 +1,321 @@
// src/app/(frontend)/api/timelines/route.ts
// Dedizierte Timeline-API für Frontend-Anwendungen
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Media } from '@/payload-types'
import {
searchLimiter,
rateLimitHeaders,
getClientIpFromRequest,
isIpBlocked,
} from '@/lib/security'
// Validation constants
const TIMELINE_RATE_LIMIT = 30
// Valid timeline types
const TIMELINE_TYPES = ['history', 'milestones', 'releases', 'career', 'events', 'process'] as const
type TimelineType = (typeof TIMELINE_TYPES)[number]
// Event category for filtering
const EVENT_CATEGORIES = [
'milestone',
'founding',
'product',
'team',
'award',
'partnership',
'expansion',
'technology',
'other',
] as const
interface TimelineEvent {
dateType: string
year?: number
month?: string
day?: number
endYear?: number
endMonth?: string
ongoing?: boolean
customDate?: string
title: string
subtitle?: string
description?: unknown
shortDescription?: string
image?: Media | number
gallery?: Array<{ image: Media | number; caption?: string }>
category?: string
importance?: string
icon?: string
color?: string
links?: Array<{ label: string; url: string; type: string }>
metadata?: unknown
}
// Helper to format date for display
function formatEventDate(event: TimelineEvent): string {
const months = [
'',
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
]
switch (event.dateType) {
case 'year':
return event.year?.toString() || ''
case 'monthYear':
return `${months[parseInt(event.month || '0')]} ${event.year}`
case 'fullDate':
return `${event.day}. ${months[parseInt(event.month || '0')]} ${event.year}`
case 'range': {
const start = event.year?.toString() || ''
const end = event.ongoing ? 'heute' : event.endYear?.toString() || ''
return `${start} - ${end}`
}
case 'custom':
return event.customDate || ''
default:
return event.year?.toString() || ''
}
}
// Helper to get sortable date value
function getEventSortValue(event: TimelineEvent): number {
const year = event.year || 0
const month = parseInt(event.month || '1')
const day = event.day || 1
return year * 10000 + month * 100 + day
}
export async function GET(request: NextRequest) {
try {
// IP-Blocklist prüfen
const ip = getClientIpFromRequest(request)
if (isIpBlocked(ip)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Rate limiting
const rateLimit = await searchLimiter.check(ip)
if (!rateLimit.allowed) {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429, headers: rateLimitHeaders(rateLimit, TIMELINE_RATE_LIMIT) }
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
const tenantParam = searchParams.get('tenant')
const slugParam = searchParams.get('slug')?.trim()
const typeParam = searchParams.get('type')?.trim()
const localeParam = searchParams.get('locale')?.trim()
const categoryParam = searchParams.get('category')?.trim()
const importanceParam = searchParams.get('importance')?.trim()
// Validate tenant - REQUIRED for tenant isolation
if (!tenantParam) {
return NextResponse.json(
{ error: 'Tenant ID is required. Use ?tenant=<id> to specify the tenant.' },
{ status: 400 }
)
}
const tenantId = parseInt(tenantParam, 10)
if (isNaN(tenantId) || tenantId < 1) {
return NextResponse.json({ error: 'Invalid tenant ID' }, { status: 400 })
}
// Validate locale
const validLocales = ['de', 'en']
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de'
// Validate type if provided
if (typeParam && !TIMELINE_TYPES.includes(typeParam as TimelineType)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${TIMELINE_TYPES.join(', ')}` },
{ status: 400 }
)
}
// Build where clause
const where: Record<string, unknown> = {
status: { equals: 'published' },
tenant: { equals: tenantId },
}
// Filter by slug (single timeline)
if (slugParam) {
where.slug = { equals: slugParam }
}
// Filter by type
if (typeParam) {
where.type = { equals: typeParam }
}
// Get payload instance
const payload = await getPayload({ config })
// Execute query
const result = await payload.find({
collection: 'timelines',
where,
sort: '-updatedAt',
limit: slugParam ? 1 : 100, // Single or list
locale,
depth: 2, // Load media relations
})
if (slugParam && result.docs.length === 0) {
return NextResponse.json({ error: 'Timeline not found' }, { status: 404 })
}
// Transform timelines
const transformedDocs = result.docs.map((timeline) => {
// Filter and sort events
let events = (timeline.events || []) as TimelineEvent[]
// Filter by category if specified
if (categoryParam) {
events = events.filter((e) => e.category === categoryParam)
}
// Filter by importance if specified
if (importanceParam) {
events = events.filter((e) => e.importance === importanceParam)
}
// Sort events based on display options
const sortOrder = (timeline.displayOptions as { sortOrder?: string })?.sortOrder || 'desc'
events.sort((a, b) => {
const diff = getEventSortValue(a) - getEventSortValue(b)
return sortOrder === 'desc' ? -diff : diff
})
// Group by year if enabled
const groupByYear = (timeline.displayOptions as { groupByYear?: boolean })?.groupByYear
let groupedEvents: Record<number, typeof transformedEvents> | null = null
// Transform events
const transformedEvents = events.map((event) => {
const image = event.image as Media | null
const gallery = (event.gallery || []).map((item) => {
const galleryImage = item.image as Media | null
return {
url: galleryImage?.url || null,
alt: galleryImage?.alt || '',
caption: item.caption || null,
}
})
return {
dateDisplay: formatEventDate(event),
dateType: event.dateType,
year: event.year,
month: event.month,
day: event.day,
endYear: event.endYear,
endMonth: event.endMonth,
ongoing: event.ongoing,
customDate: event.customDate,
title: event.title,
subtitle: event.subtitle || null,
description: event.description || null,
shortDescription: event.shortDescription || null,
image: image
? {
url: image.url,
alt: image.alt || event.title,
width: image.width,
height: image.height,
}
: null,
gallery: gallery.length > 0 ? gallery : null,
category: event.category || null,
importance: event.importance || 'normal',
icon: event.icon || null,
color: event.color || null,
links: event.links || [],
metadata: event.metadata || null,
}
})
// Group by year if enabled
if (groupByYear) {
groupedEvents = {}
for (const event of transformedEvents) {
const year = event.year || 0
if (!groupedEvents[year]) {
groupedEvents[year] = []
}
groupedEvents[year].push(event)
}
}
return {
id: timeline.id,
name: timeline.name,
slug: timeline.slug,
description: timeline.description || null,
type: timeline.type,
displayOptions: timeline.displayOptions,
events: groupByYear ? null : transformedEvents,
eventsByYear: groupedEvents,
eventCount: transformedEvents.length,
seo: timeline.seo || null,
}
})
// Single timeline response
if (slugParam) {
return NextResponse.json(
{
timeline: transformedDocs[0],
locale,
},
{
headers: {
...rateLimitHeaders(rateLimit, TIMELINE_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=300',
},
}
)
}
// List response
return NextResponse.json(
{
docs: transformedDocs,
total: result.totalDocs,
filters: {
tenant: tenantId,
type: typeParam || null,
locale,
},
},
{
headers: {
...rateLimitHeaders(rateLimit, TIMELINE_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=120',
},
}
)
} catch (error) {
console.error('[Timeline API] Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View file

@ -0,0 +1,506 @@
import type { CollectionConfig } from 'payload'
/**
* Timelines Collection
*
* Dedizierte Collection für komplexe Timeline-Ereignisse
* - Unternehmensgeschichte
* - Projektmeilensteine
* - Produkt-Releases
* - Historische Ereignisse
*
* Multi-Tenant-fähig mit flexiblen Kategorisierungen
*/
export const Timelines: CollectionConfig = {
slug: 'timelines',
labels: {
singular: 'Timeline',
plural: 'Timelines',
},
admin: {
useAsTitle: 'name',
group: 'Inhalte',
defaultColumns: ['name', 'type', 'status', 'updatedAt'],
description: 'Chronologische Darstellungen für Unternehmensgeschichte, Meilensteine, etc.',
},
access: {
read: () => true, // Öffentlich lesbar
},
fields: [
// Timeline-Metadaten
{
name: 'name',
type: 'text',
required: true,
label: 'Timeline-Name',
localized: true,
admin: {
description: 'Interner Name zur Identifikation (z.B. "Unternehmensgeschichte")',
},
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
label: 'Slug',
admin: {
description: 'URL-freundlicher Identifier (z.B. "company-history")',
},
},
{
name: 'description',
type: 'textarea',
label: 'Beschreibung',
localized: true,
admin: {
description: 'Optionale Beschreibung der Timeline',
},
},
{
name: 'type',
type: 'select',
required: true,
defaultValue: 'history',
label: 'Timeline-Typ',
options: [
{ label: 'Unternehmensgeschichte', value: 'history' },
{ label: 'Projektmeilensteine', value: 'milestones' },
{ label: 'Produkt-Releases', value: 'releases' },
{ label: 'Karriere/Lebenslauf', value: 'career' },
{ label: 'Ereignisse', value: 'events' },
{ label: 'Prozess/Ablauf', value: 'process' },
],
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
label: 'Status',
options: [
{ label: 'Entwurf', value: 'draft' },
{ label: 'Veröffentlicht', value: 'published' },
{ label: 'Archiviert', value: 'archived' },
],
admin: {
position: 'sidebar',
},
},
// Display-Optionen
{
name: 'displayOptions',
type: 'group',
label: 'Darstellungsoptionen',
fields: [
{
name: 'layout',
type: 'select',
defaultValue: 'vertical',
label: 'Layout',
options: [
{ label: 'Vertikal (Standard)', value: 'vertical' },
{ label: 'Alternierend (links/rechts)', value: 'alternating' },
{ label: 'Horizontal (Zeitleiste)', value: 'horizontal' },
{ label: 'Kompakt (Liste)', value: 'compact' },
],
},
{
name: 'sortOrder',
type: 'select',
defaultValue: 'desc',
label: 'Sortierung',
options: [
{ label: 'Neueste zuerst', value: 'desc' },
{ label: 'Älteste zuerst', value: 'asc' },
],
},
{
name: 'showConnector',
type: 'checkbox',
defaultValue: true,
label: 'Verbindungslinie anzeigen',
},
{
name: 'showImages',
type: 'checkbox',
defaultValue: true,
label: 'Bilder anzeigen',
},
{
name: 'groupByYear',
type: 'checkbox',
defaultValue: false,
label: 'Nach Jahr gruppieren',
},
{
name: 'markerStyle',
type: 'select',
defaultValue: 'dot',
label: 'Marker-Stil',
options: [
{ label: 'Punkt', value: 'dot' },
{ label: 'Nummer', value: 'number' },
{ label: 'Icon', value: 'icon' },
{ label: 'Datum', value: 'date' },
],
},
{
name: 'colorScheme',
type: 'select',
defaultValue: 'primary',
label: 'Farbschema',
options: [
{ label: 'Primär (Brand)', value: 'primary' },
{ label: 'Sekundär', value: 'secondary' },
{ label: 'Neutral (Grau)', value: 'neutral' },
{ label: 'Bunt (je nach Kategorie)', value: 'colorful' },
],
},
],
},
// Timeline-Einträge
{
name: 'events',
type: 'array',
required: true,
minRows: 1,
label: 'Ereignisse',
admin: {
description: 'Die einzelnen Einträge der Timeline',
initCollapsed: false,
},
fields: [
// Datum-Optionen
{
type: 'row',
fields: [
{
name: 'dateType',
type: 'select',
defaultValue: 'year',
label: 'Datumstyp',
admin: {
width: '30%',
},
options: [
{ label: 'Nur Jahr', value: 'year' },
{ label: 'Monat & Jahr', value: 'monthYear' },
{ label: 'Vollständiges Datum', value: 'fullDate' },
{ label: 'Zeitraum', value: 'range' },
{ label: 'Freitext', value: 'custom' },
],
},
{
name: 'year',
type: 'number',
label: 'Jahr',
admin: {
width: '20%',
condition: (_, siblingData) =>
['year', 'monthYear', 'fullDate'].includes(siblingData?.dateType),
},
min: 1900,
max: 2100,
},
{
name: 'month',
type: 'select',
label: 'Monat',
admin: {
width: '25%',
condition: (_, siblingData) =>
['monthYear', 'fullDate'].includes(siblingData?.dateType),
},
options: [
{ label: 'Januar', value: '1' },
{ label: 'Februar', value: '2' },
{ label: 'März', value: '3' },
{ label: 'April', value: '4' },
{ label: 'Mai', value: '5' },
{ label: 'Juni', value: '6' },
{ label: 'Juli', value: '7' },
{ label: 'August', value: '8' },
{ label: 'September', value: '9' },
{ label: 'Oktober', value: '10' },
{ label: 'November', value: '11' },
{ label: 'Dezember', value: '12' },
],
},
{
name: 'day',
type: 'number',
label: 'Tag',
admin: {
width: '15%',
condition: (_, siblingData) => siblingData?.dateType === 'fullDate',
},
min: 1,
max: 31,
},
],
},
{
type: 'row',
fields: [
{
name: 'endYear',
type: 'number',
label: 'Ende Jahr',
admin: {
width: '25%',
condition: (_, siblingData) => siblingData?.dateType === 'range',
},
min: 1900,
max: 2100,
},
{
name: 'endMonth',
type: 'select',
label: 'Ende Monat',
admin: {
width: '25%',
condition: (_, siblingData) => siblingData?.dateType === 'range',
},
options: [
{ label: 'Januar', value: '1' },
{ label: 'Februar', value: '2' },
{ label: 'März', value: '3' },
{ label: 'April', value: '4' },
{ label: 'Mai', value: '5' },
{ label: 'Juni', value: '6' },
{ label: 'Juli', value: '7' },
{ label: 'August', value: '8' },
{ label: 'September', value: '9' },
{ label: 'Oktober', value: '10' },
{ label: 'November', value: '11' },
{ label: 'Dezember', value: '12' },
],
},
{
name: 'ongoing',
type: 'checkbox',
label: 'Laufend (bis heute)',
admin: {
width: '25%',
condition: (_, siblingData) => siblingData?.dateType === 'range',
},
},
],
},
{
name: 'customDate',
type: 'text',
label: 'Datum (Freitext)',
admin: {
description: 'z.B. "Frühjahr 2024", "Q1 2023", "1990er Jahre"',
condition: (_, siblingData) => siblingData?.dateType === 'custom',
},
},
// Inhalt
{
name: 'title',
type: 'text',
required: true,
label: 'Titel',
localized: true,
},
{
name: 'subtitle',
type: 'text',
label: 'Untertitel',
localized: true,
admin: {
description: 'Optional: z.B. Rolle, Position, Ort',
},
},
{
name: 'description',
type: 'richText',
label: 'Beschreibung',
localized: true,
},
{
name: 'shortDescription',
type: 'textarea',
label: 'Kurzbeschreibung',
localized: true,
admin: {
description: 'Für kompakte Ansichten (max. 200 Zeichen empfohlen)',
},
},
// Medien
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Bild',
},
{
name: 'gallery',
type: 'array',
label: 'Galerie',
admin: {
description: 'Zusätzliche Bilder für dieses Ereignis',
},
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: true,
},
{
name: 'caption',
type: 'text',
label: 'Bildunterschrift',
localized: true,
},
],
},
// Kategorisierung
{
name: 'category',
type: 'select',
label: 'Kategorie',
admin: {
description: 'Zur Filterung und farblichen Unterscheidung',
},
options: [
{ label: 'Meilenstein', value: 'milestone' },
{ label: 'Gründung/Start', value: 'founding' },
{ label: 'Produkt', value: 'product' },
{ label: 'Team/Personal', value: 'team' },
{ label: 'Auszeichnung', value: 'award' },
{ label: 'Partnerschaft', value: 'partnership' },
{ label: 'Expansion', value: 'expansion' },
{ label: 'Technologie', value: 'technology' },
{ label: 'Sonstiges', value: 'other' },
],
},
{
name: 'importance',
type: 'select',
defaultValue: 'normal',
label: 'Wichtigkeit',
options: [
{ label: 'Highlight', value: 'highlight' },
{ label: 'Normal', value: 'normal' },
{ label: 'Minor', value: 'minor' },
],
admin: {
description: 'Highlights werden visuell hervorgehoben',
},
},
// Icon & Styling
{
name: 'icon',
type: 'text',
label: 'Icon',
admin: {
description: 'Emoji oder Lucide Icon-Name (z.B. "rocket", "award", "users")',
},
},
{
name: 'color',
type: 'text',
label: 'Farbe (optional)',
admin: {
description: 'Überschreibt die Kategorie-Farbe (z.B. "#FF5733" oder "blue")',
},
},
// Links
{
name: 'links',
type: 'array',
label: 'Links',
fields: [
{
name: 'label',
type: 'text',
required: true,
label: 'Link-Text',
localized: true,
},
{
name: 'url',
type: 'text',
required: true,
label: 'URL',
},
{
name: 'type',
type: 'select',
defaultValue: 'internal',
label: 'Link-Typ',
options: [
{ label: 'Intern', value: 'internal' },
{ label: 'Extern', value: 'external' },
{ label: 'Download', value: 'download' },
],
},
],
},
// Zusätzliche Daten
{
name: 'metadata',
type: 'json',
label: 'Zusätzliche Daten (JSON)',
admin: {
description: 'Für spezielle Anwendungsfälle (z.B. Statistiken, Kennzahlen)',
},
},
],
},
// SEO
{
name: 'seo',
type: 'group',
label: 'SEO',
admin: {
position: 'sidebar',
},
fields: [
{
name: 'metaTitle',
type: 'text',
label: 'Meta-Titel',
localized: true,
},
{
name: 'metaDescription',
type: 'textarea',
label: 'Meta-Beschreibung',
localized: true,
},
],
},
],
hooks: {
beforeChange: [
({ data }) => {
// Auto-generate slug from name if not provided
if (data && !data.slug && data.name) {
data.slug = data.name
.toLowerCase()
.replace(/[äöüß]/g, (match: string) => {
const map: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }
return map[match] || match
})
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
return data
},
],
},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,168 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_timelines_events_links_type" AS ENUM('internal', 'external', 'download');
CREATE TYPE "public"."enum_timelines_events_date_type" AS ENUM('year', 'monthYear', 'fullDate', 'range', 'custom');
CREATE TYPE "public"."enum_timelines_events_month" AS ENUM('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12');
CREATE TYPE "public"."enum_timelines_events_end_month" AS ENUM('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12');
CREATE TYPE "public"."enum_timelines_events_category" AS ENUM('milestone', 'founding', 'product', 'team', 'award', 'partnership', 'expansion', 'technology', 'other');
CREATE TYPE "public"."enum_timelines_events_importance" AS ENUM('highlight', 'normal', 'minor');
CREATE TYPE "public"."enum_timelines_type" AS ENUM('history', 'milestones', 'releases', 'career', 'events', 'process');
CREATE TYPE "public"."enum_timelines_status" AS ENUM('draft', 'published', 'archived');
CREATE TYPE "public"."enum_timelines_display_options_layout" AS ENUM('vertical', 'alternating', 'horizontal', 'compact');
CREATE TYPE "public"."enum_timelines_display_options_sort_order" AS ENUM('desc', 'asc');
CREATE TYPE "public"."enum_timelines_display_options_marker_style" AS ENUM('dot', 'number', 'icon', 'date');
CREATE TYPE "public"."enum_timelines_display_options_color_scheme" AS ENUM('primary', 'secondary', 'neutral', 'colorful');
CREATE TABLE "timelines_events_gallery" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"image_id" integer NOT NULL
);
CREATE TABLE "timelines_events_gallery_locales" (
"caption" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "timelines_events_links" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"url" varchar NOT NULL,
"type" "enum_timelines_events_links_type" DEFAULT 'internal'
);
CREATE TABLE "timelines_events_links_locales" (
"label" varchar NOT NULL,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "timelines_events" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"date_type" "enum_timelines_events_date_type" DEFAULT 'year',
"year" numeric,
"month" "enum_timelines_events_month",
"day" numeric,
"end_year" numeric,
"end_month" "enum_timelines_events_end_month",
"ongoing" boolean,
"custom_date" varchar,
"image_id" integer,
"category" "enum_timelines_events_category",
"importance" "enum_timelines_events_importance" DEFAULT 'normal',
"icon" varchar,
"color" varchar,
"metadata" jsonb
);
CREATE TABLE "timelines_events_locales" (
"title" varchar NOT NULL,
"subtitle" varchar,
"description" jsonb,
"short_description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" varchar NOT NULL
);
CREATE TABLE "timelines" (
"id" serial PRIMARY KEY NOT NULL,
"tenant_id" integer,
"slug" varchar NOT NULL,
"type" "enum_timelines_type" DEFAULT 'history' NOT NULL,
"status" "enum_timelines_status" DEFAULT 'draft' NOT NULL,
"display_options_layout" "enum_timelines_display_options_layout" DEFAULT 'vertical',
"display_options_sort_order" "enum_timelines_display_options_sort_order" DEFAULT 'desc',
"display_options_show_connector" boolean DEFAULT true,
"display_options_show_images" boolean DEFAULT true,
"display_options_group_by_year" boolean DEFAULT false,
"display_options_marker_style" "enum_timelines_display_options_marker_style" DEFAULT 'dot',
"display_options_color_scheme" "enum_timelines_display_options_color_scheme" DEFAULT 'primary',
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "timelines_locales" (
"name" varchar NOT NULL,
"description" varchar,
"seo_meta_title" varchar,
"seo_meta_description" varchar,
"id" serial PRIMARY KEY NOT NULL,
"_locale" "_locales" NOT NULL,
"_parent_id" integer NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "timelines_id" integer;
ALTER TABLE "timelines_events_gallery" ADD CONSTRAINT "timelines_events_gallery_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "timelines_events_gallery" ADD CONSTRAINT "timelines_events_gallery_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines_events"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines_events_gallery_locales" ADD CONSTRAINT "timelines_events_gallery_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines_events_gallery"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines_events_links" ADD CONSTRAINT "timelines_events_links_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines_events"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines_events_links_locales" ADD CONSTRAINT "timelines_events_links_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines_events_links"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines_events" ADD CONSTRAINT "timelines_events_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "timelines_events" ADD CONSTRAINT "timelines_events_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines_events_locales" ADD CONSTRAINT "timelines_events_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines_events"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "timelines" ADD CONSTRAINT "timelines_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "timelines_locales" ADD CONSTRAINT "timelines_locales_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."timelines"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "timelines_events_gallery_order_idx" ON "timelines_events_gallery" USING btree ("_order");
CREATE INDEX "timelines_events_gallery_parent_id_idx" ON "timelines_events_gallery" USING btree ("_parent_id");
CREATE INDEX "timelines_events_gallery_image_idx" ON "timelines_events_gallery" USING btree ("image_id");
CREATE UNIQUE INDEX "timelines_events_gallery_locales_locale_parent_id_unique" ON "timelines_events_gallery_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "timelines_events_links_order_idx" ON "timelines_events_links" USING btree ("_order");
CREATE INDEX "timelines_events_links_parent_id_idx" ON "timelines_events_links" USING btree ("_parent_id");
CREATE UNIQUE INDEX "timelines_events_links_locales_locale_parent_id_unique" ON "timelines_events_links_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "timelines_events_order_idx" ON "timelines_events" USING btree ("_order");
CREATE INDEX "timelines_events_parent_id_idx" ON "timelines_events" USING btree ("_parent_id");
CREATE INDEX "timelines_events_image_idx" ON "timelines_events" USING btree ("image_id");
CREATE UNIQUE INDEX "timelines_events_locales_locale_parent_id_unique" ON "timelines_events_locales" USING btree ("_locale","_parent_id");
CREATE INDEX "timelines_tenant_idx" ON "timelines" USING btree ("tenant_id");
CREATE UNIQUE INDEX "timelines_slug_idx" ON "timelines" USING btree ("slug");
CREATE INDEX "timelines_updated_at_idx" ON "timelines" USING btree ("updated_at");
CREATE INDEX "timelines_created_at_idx" ON "timelines" USING btree ("created_at");
CREATE UNIQUE INDEX "timelines_locales_locale_parent_id_unique" ON "timelines_locales" USING btree ("_locale","_parent_id");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_timelines_fk" FOREIGN KEY ("timelines_id") REFERENCES "public"."timelines"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_timelines_id_idx" ON "payload_locked_documents_rels" USING btree ("timelines_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "timelines_events_gallery" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_events_gallery_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_events_links" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_events_links_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_events" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_events_locales" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "timelines_locales" DISABLE ROW LEVEL SECURITY;
DROP TABLE "timelines_events_gallery" CASCADE;
DROP TABLE "timelines_events_gallery_locales" CASCADE;
DROP TABLE "timelines_events_links" CASCADE;
DROP TABLE "timelines_events_links_locales" CASCADE;
DROP TABLE "timelines_events" CASCADE;
DROP TABLE "timelines_events_locales" CASCADE;
DROP TABLE "timelines" CASCADE;
DROP TABLE "timelines_locales" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_timelines_fk";
DROP INDEX "payload_locked_documents_rels_timelines_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "timelines_id";
DROP TYPE "public"."enum_timelines_events_links_type";
DROP TYPE "public"."enum_timelines_events_date_type";
DROP TYPE "public"."enum_timelines_events_month";
DROP TYPE "public"."enum_timelines_events_end_month";
DROP TYPE "public"."enum_timelines_events_category";
DROP TYPE "public"."enum_timelines_events_importance";
DROP TYPE "public"."enum_timelines_type";
DROP TYPE "public"."enum_timelines_status";
DROP TYPE "public"."enum_timelines_display_options_layout";
DROP TYPE "public"."enum_timelines_display_options_sort_order";
DROP TYPE "public"."enum_timelines_display_options_marker_style";
DROP TYPE "public"."enum_timelines_display_options_color_scheme";`)
}

View file

@ -9,6 +9,7 @@ import * as migration_20251210_071506_add_team_collection from './20251210_07150
import * as migration_20251210_073811_add_services_collections from './20251210_073811_add_services_collections';
import * as migration_20251210_090000_enhance_form_submissions from './20251210_090000_enhance_form_submissions';
import * as migration_20251212_211506_add_products_collections from './20251212_211506_add_products_collections';
import * as migration_20251213_100753_add_timelines_collection from './20251213_100753_add_timelines_collection';
export const migrations = [
{
@ -64,6 +65,11 @@ export const migrations = [
{
up: migration_20251212_211506_add_products_collections.up,
down: migration_20251212_211506_add_products_collections.down,
name: '20251212_211506_add_products_collections'
name: '20251212_211506_add_products_collections',
},
{
up: migration_20251213_100753_add_timelines_collection.up,
down: migration_20251213_100753_add_timelines_collection.down,
name: '20251213_100753_add_timelines_collection'
},
];

View file

@ -84,6 +84,7 @@ export interface Config {
portfolios: Portfolio;
'product-categories': ProductCategory;
products: Product;
timelines: Timeline;
'cookie-configurations': CookieConfiguration;
'cookie-inventory': CookieInventory;
'consent-logs': ConsentLog;
@ -117,6 +118,7 @@ export interface Config {
portfolios: PortfoliosSelect<false> | PortfoliosSelect<true>;
'product-categories': ProductCategoriesSelect<false> | ProductCategoriesSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
timelines: TimelinesSelect<false> | TimelinesSelect<true>;
'cookie-configurations': CookieConfigurationsSelect<false> | CookieConfigurationsSelect<true>;
'cookie-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
@ -1803,6 +1805,135 @@ export interface Product {
updatedAt: string;
createdAt: string;
}
/**
* Chronologische Darstellungen für Unternehmensgeschichte, Meilensteine, etc.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "timelines".
*/
export interface Timeline {
id: number;
tenant?: (number | null) | Tenant;
/**
* Interner Name zur Identifikation (z.B. "Unternehmensgeschichte")
*/
name: string;
/**
* URL-freundlicher Identifier (z.B. "company-history")
*/
slug: string;
/**
* Optionale Beschreibung der Timeline
*/
description?: string | null;
type: 'history' | 'milestones' | 'releases' | 'career' | 'events' | 'process';
status: 'draft' | 'published' | 'archived';
displayOptions?: {
layout?: ('vertical' | 'alternating' | 'horizontal' | 'compact') | null;
sortOrder?: ('desc' | 'asc') | null;
showConnector?: boolean | null;
showImages?: boolean | null;
groupByYear?: boolean | null;
markerStyle?: ('dot' | 'number' | 'icon' | 'date') | null;
colorScheme?: ('primary' | 'secondary' | 'neutral' | 'colorful') | null;
};
/**
* Die einzelnen Einträge der Timeline
*/
events: {
dateType?: ('year' | 'monthYear' | 'fullDate' | 'range' | 'custom') | null;
year?: number | null;
month?: ('1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12') | null;
day?: number | null;
endYear?: number | null;
endMonth?: ('1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12') | null;
ongoing?: boolean | null;
/**
* z.B. "Frühjahr 2024", "Q1 2023", "1990er Jahre"
*/
customDate?: string | null;
title: string;
/**
* Optional: z.B. Rolle, Position, Ort
*/
subtitle?: string | null;
description?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
/**
* Für kompakte Ansichten (max. 200 Zeichen empfohlen)
*/
shortDescription?: string | null;
image?: (number | null) | Media;
/**
* Zusätzliche Bilder für dieses Ereignis
*/
gallery?:
| {
image: number | Media;
caption?: string | null;
id?: string | null;
}[]
| null;
/**
* Zur Filterung und farblichen Unterscheidung
*/
category?:
| ('milestone' | 'founding' | 'product' | 'team' | 'award' | 'partnership' | 'expansion' | 'technology' | 'other')
| null;
/**
* Highlights werden visuell hervorgehoben
*/
importance?: ('highlight' | 'normal' | 'minor') | null;
/**
* Emoji oder Lucide Icon-Name (z.B. "rocket", "award", "users")
*/
icon?: string | null;
/**
* Überschreibt die Kategorie-Farbe (z.B. "#FF5733" oder "blue")
*/
color?: string | null;
links?:
| {
label: string;
url: string;
type?: ('internal' | 'external' | 'download') | null;
id?: string | null;
}[]
| null;
/**
* Für spezielle Anwendungsfälle (z.B. Statistiken, Kennzahlen)
*/
metadata?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
id?: string | null;
}[];
seo?: {
metaTitle?: string | null;
metaDescription?: string | null;
};
updatedAt: string;
createdAt: string;
}
/**
* Cookie-Banner Konfiguration pro Tenant
*
@ -2456,6 +2587,10 @@ export interface PayloadLockedDocument {
relationTo: 'products';
value: number | Product;
} | null)
| ({
relationTo: 'timelines';
value: number | Timeline;
} | null)
| ({
relationTo: 'cookie-configurations';
value: number | CookieConfiguration;
@ -3517,6 +3652,75 @@ export interface ProductsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "timelines_select".
*/
export interface TimelinesSelect<T extends boolean = true> {
tenant?: T;
name?: T;
slug?: T;
description?: T;
type?: T;
status?: T;
displayOptions?:
| T
| {
layout?: T;
sortOrder?: T;
showConnector?: T;
showImages?: T;
groupByYear?: T;
markerStyle?: T;
colorScheme?: T;
};
events?:
| T
| {
dateType?: T;
year?: T;
month?: T;
day?: T;
endYear?: T;
endMonth?: T;
ongoing?: T;
customDate?: T;
title?: T;
subtitle?: T;
description?: T;
shortDescription?: T;
image?: T;
gallery?:
| T
| {
image?: T;
caption?: T;
id?: T;
};
category?: T;
importance?: T;
icon?: T;
color?: T;
links?:
| T
| {
label?: T;
url?: T;
type?: T;
id?: T;
};
metadata?: T;
id?: T;
};
seo?:
| T
| {
metaTitle?: T;
metaDescription?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "cookie-configurations_select".

View file

@ -38,6 +38,9 @@ import { Portfolios } from './collections/Portfolios'
import { ProductCategories } from './collections/ProductCategories'
import { Products } from './collections/Products'
// Timeline Collection
import { Timelines } from './collections/Timelines'
// Consent Management Collections
import { CookieConfigurations } from './collections/CookieConfigurations'
import { CookieInventory } from './collections/CookieInventory'
@ -152,6 +155,8 @@ export default buildConfig({
// Products
ProductCategories,
Products,
// Timelines
Timelines,
// Consent Management
CookieConfigurations,
CookieInventory,
@ -200,6 +205,8 @@ export default buildConfig({
// Product Collections
'product-categories': {},
products: {},
// Timeline Collection
timelines: {},
// Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben
'cookie-configurations': { customTenantField: true },
'cookie-inventory': { customTenantField: true },

View file

@ -264,6 +264,122 @@ test.describe('Cross-Tenant Access Prevention', () => {
})
})
test.describe('Timeline API Tenant Isolation', () => {
test('Timeline API requires tenant parameter', async ({ request }) => {
const response = await request.get('/api/timelines')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Tenant ID is required')
})
test('Timeline API returns different data for different tenants', async ({ request }) => {
const [response1, response4, response5] = await Promise.all([
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}`),
request.get(`/api/timelines?tenant=${TENANT_C2S}`),
request.get(`/api/timelines?tenant=${TENANT_GUNSHIN}`),
])
expect(response1.ok()).toBe(true)
expect(response4.ok()).toBe(true)
expect(response5.ok()).toBe(true)
const data1 = await response1.json()
const data4 = await response4.json()
const data5 = await response5.json()
// Each response should reflect its tenant filter
expect(data1.filters.tenant).toBe(TENANT_PORWOLL)
expect(data4.filters.tenant).toBe(TENANT_C2S)
expect(data5.filters.tenant).toBe(TENANT_GUNSHIN)
})
test('Timeline API validates tenant ID format', async ({ request }) => {
const response = await request.get('/api/timelines?tenant=invalid')
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid tenant ID')
})
test('Timeline API returns empty for non-existent tenant', async ({ request }) => {
const response = await request.get('/api/timelines?tenant=99999')
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data.docs).toEqual([])
expect(data.total).toBe(0)
})
test('Timeline API supports type filtering', async ({ request }) => {
const response = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&type=history`)
expect(response.ok()).toBe(true)
const data = await response.json()
expect(data.filters.type).toBe('history')
})
test('Timeline API rejects invalid type', async ({ request }) => {
const response = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&type=invalid`)
expect(response.status()).toBe(400)
const data = await response.json()
expect(data.error).toContain('Invalid type')
})
test('Timeline API supports locale parameter', async ({ request }) => {
const [responseDE, responseEN] = await Promise.all([
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&locale=de`),
request.get(`/api/timelines?tenant=${TENANT_PORWOLL}&locale=en`),
])
expect(responseDE.ok()).toBe(true)
expect(responseEN.ok()).toBe(true)
const dataDE = await responseDE.json()
const dataEN = await responseEN.json()
expect(dataDE.filters.locale).toBe('de')
expect(dataEN.filters.locale).toBe('en')
})
test('Timeline detail API enforces tenant isolation', async ({ request }) => {
// Get a timeline from tenant 1
const listResponse = await request.get(`/api/timelines?tenant=${TENANT_PORWOLL}`)
if (!listResponse.ok()) {
test.skip()
return
}
const listData = await listResponse.json()
if (listData.docs.length === 0) {
test.skip()
return
}
const slug = listData.docs[0].slug
// Try to access with wrong tenant - should return 404
const wrongTenantResponse = await request.get(`/api/timelines?tenant=${TENANT_C2S}&slug=${slug}`)
// Should not find the timeline (different tenant)
expect(wrongTenantResponse.status()).toBe(404)
// Same tenant should work
const correctTenantResponse = await request.get(
`/api/timelines?tenant=${TENANT_PORWOLL}&slug=${slug}`
)
expect(correctTenantResponse.ok()).toBe(true)
})
})
test.describe('Tenant Validation', () => {
test('Rejects invalid tenant ID format', async ({ request }) => {
const response = await request.get('/api/news?tenant=invalid')