cms.c2sgmbh/src/app/(frontend)/api/timelines/route.ts
Martin Porwoll 9016d3c06c fix: resolve all TypeScript errors in production code
- Add Where type imports and proper type assertions in API routes
- Add Locale type definitions for locale validation
- Fix email-logs/stats route with proper EmailLog typing
- Fix newsletter-service interests type and null checks
- Remove invalid contact field from OpenAPI metadata
- Fix formSubmissionOverrides type casting in payload.config
- Fix vcard route Team type casting

All 24 TypeScript errors in src/ are now resolved.
Test files have separate type issues that don't affect production.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 09:08:16 +00:00

311 lines
8.9 KiB
TypeScript

// 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=<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: 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<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 })
}
}