// 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 type { Where } from 'payload' import config from '@payload-config' import type { Media } from '@/payload-types' type Locale = 'de' | 'en' | 'all' 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] 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= 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: Locale[] = ['de', 'en'] const locale: Locale = localeParam && validLocales.includes(localeParam as Locale) ? (localeParam as Locale) : '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: Where = { 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: where as 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 | 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 }) } }