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 Anmeldung:** https://pl.c2sgmbh.de/api/newsletter/subscribe (POST, öffentlich)
|
||||||
- **Newsletter Bestätigung:** https://pl.c2sgmbh.de/api/newsletter/confirm (GET/POST)
|
- **Newsletter Bestätigung:** https://pl.c2sgmbh.de/api/newsletter/confirm (GET/POST)
|
||||||
- **Newsletter Abmeldung:** https://pl.c2sgmbh.de/api/newsletter/unsubscribe (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
|
## Security-Features
|
||||||
|
|
||||||
|
|
@ -575,6 +576,48 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10;
|
||||||
| CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration |
|
| CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration |
|
||||||
| CookieInventory | cookie-inventory | Cookie-Inventar |
|
| CookieInventory | cookie-inventory | Cookie-Inventar |
|
||||||
| ConsentLogs | consent-logs | Consent-Protokollierung |
|
| 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
|
## 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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -64,6 +65,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20251212_211506_add_products_collections.up,
|
up: migration_20251212_211506_add_products_collections.up,
|
||||||
down: migration_20251212_211506_add_products_collections.down,
|
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;
|
portfolios: Portfolio;
|
||||||
'product-categories': ProductCategory;
|
'product-categories': ProductCategory;
|
||||||
products: Product;
|
products: Product;
|
||||||
|
timelines: Timeline;
|
||||||
'cookie-configurations': CookieConfiguration;
|
'cookie-configurations': CookieConfiguration;
|
||||||
'cookie-inventory': CookieInventory;
|
'cookie-inventory': CookieInventory;
|
||||||
'consent-logs': ConsentLog;
|
'consent-logs': ConsentLog;
|
||||||
|
|
@ -117,6 +118,7 @@ export interface Config {
|
||||||
portfolios: PortfoliosSelect<false> | PortfoliosSelect<true>;
|
portfolios: PortfoliosSelect<false> | PortfoliosSelect<true>;
|
||||||
'product-categories': ProductCategoriesSelect<false> | ProductCategoriesSelect<true>;
|
'product-categories': ProductCategoriesSelect<false> | ProductCategoriesSelect<true>;
|
||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
|
timelines: TimelinesSelect<false> | TimelinesSelect<true>;
|
||||||
'cookie-configurations': CookieConfigurationsSelect<false> | CookieConfigurationsSelect<true>;
|
'cookie-configurations': CookieConfigurationsSelect<false> | CookieConfigurationsSelect<true>;
|
||||||
'cookie-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
|
'cookie-inventory': CookieInventorySelect<false> | CookieInventorySelect<true>;
|
||||||
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
'consent-logs': ConsentLogsSelect<false> | ConsentLogsSelect<true>;
|
||||||
|
|
@ -1803,6 +1805,135 @@ export interface Product {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* Cookie-Banner Konfiguration pro Tenant
|
||||||
*
|
*
|
||||||
|
|
@ -2456,6 +2587,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'products';
|
relationTo: 'products';
|
||||||
value: number | Product;
|
value: number | Product;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'timelines';
|
||||||
|
value: number | Timeline;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'cookie-configurations';
|
relationTo: 'cookie-configurations';
|
||||||
value: number | CookieConfiguration;
|
value: number | CookieConfiguration;
|
||||||
|
|
@ -3517,6 +3652,75 @@ export interface ProductsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "cookie-configurations_select".
|
* via the `definition` "cookie-configurations_select".
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ import { Portfolios } from './collections/Portfolios'
|
||||||
import { ProductCategories } from './collections/ProductCategories'
|
import { ProductCategories } from './collections/ProductCategories'
|
||||||
import { Products } from './collections/Products'
|
import { Products } from './collections/Products'
|
||||||
|
|
||||||
|
// Timeline Collection
|
||||||
|
import { Timelines } from './collections/Timelines'
|
||||||
|
|
||||||
// Consent Management Collections
|
// Consent Management Collections
|
||||||
import { CookieConfigurations } from './collections/CookieConfigurations'
|
import { CookieConfigurations } from './collections/CookieConfigurations'
|
||||||
import { CookieInventory } from './collections/CookieInventory'
|
import { CookieInventory } from './collections/CookieInventory'
|
||||||
|
|
@ -152,6 +155,8 @@ export default buildConfig({
|
||||||
// Products
|
// Products
|
||||||
ProductCategories,
|
ProductCategories,
|
||||||
Products,
|
Products,
|
||||||
|
// Timelines
|
||||||
|
Timelines,
|
||||||
// Consent Management
|
// Consent Management
|
||||||
CookieConfigurations,
|
CookieConfigurations,
|
||||||
CookieInventory,
|
CookieInventory,
|
||||||
|
|
@ -200,6 +205,8 @@ export default buildConfig({
|
||||||
// Product Collections
|
// Product Collections
|
||||||
'product-categories': {},
|
'product-categories': {},
|
||||||
products: {},
|
products: {},
|
||||||
|
// Timeline Collection
|
||||||
|
timelines: {},
|
||||||
// Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben
|
// Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben
|
||||||
'cookie-configurations': { customTenantField: true },
|
'cookie-configurations': { customTenantField: true },
|
||||||
'cookie-inventory': { 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.describe('Tenant Validation', () => {
|
||||||
test('Rejects invalid tenant ID format', async ({ request }) => {
|
test('Rejects invalid tenant ID format', async ({ request }) => {
|
||||||
const response = await request.get('/api/news?tenant=invalid')
|
const response = await request.get('/api/news?tenant=invalid')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue