mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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:
parent
e8532b388d
commit
3f61050fb3
9 changed files with 23291 additions and 1 deletions
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
321
src/app/(frontend)/api/timelines/route.ts
Normal file
321
src/app/(frontend)/api/timelines/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
506
src/collections/Timelines.ts
Normal file
506
src/collections/Timelines.ts
Normal 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
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
21919
src/migrations/20251213_100753_add_timelines_collection.json
Normal file
21919
src/migrations/20251213_100753_add_timelines_collection.json
Normal file
File diff suppressed because it is too large
Load diff
168
src/migrations/20251213_100753_add_timelines_collection.ts
Normal file
168
src/migrations/20251213_100753_add_timelines_collection.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue