mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 18:34:13 +00:00
- 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>
311 lines
8.9 KiB
TypeScript
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 })
|
|
}
|
|
}
|