cms.c2sgmbh/src/app/(frontend)/api/news/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

439 lines
12 KiB
TypeScript

// src/app/(frontend)/api/news/route.ts
// Dedizierte News-API mit erweiterten Features für Frontend-News-Seiten
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import type { Where } from 'payload'
import config from '@payload-config'
import type { Category, Media, Post } from '@/payload-types'
type Locale = 'de' | 'en' | 'all'
import {
searchLimiter,
rateLimitHeaders,
getClientIpFromRequest,
isIpBlocked,
} from '@/lib/security'
// Validation constants
const MAX_LIMIT = 50
const DEFAULT_LIMIT = 12
const NEWS_RATE_LIMIT = 30
// Valid post types for news
const NEWS_TYPES = ['news', 'press', 'announcement', 'blog'] as const
type NewsType = (typeof NEWS_TYPES)[number]
interface NewsQueryParams {
tenantId?: number
type?: NewsType | NewsType[]
categorySlug?: string
featured?: boolean
search?: string
year?: number
month?: number
locale: Locale
page: number
limit: number
excludeIds?: number[]
}
async function getNews(payload: Awaited<ReturnType<typeof getPayload>>, params: NewsQueryParams) {
const {
tenantId,
type,
categorySlug,
featured,
search,
year,
month,
locale,
page,
limit,
excludeIds,
} = params
// Build where clause
const where: Where = {
status: { equals: 'published' },
}
// Tenant filter
if (tenantId) {
where.tenant = { equals: tenantId }
}
// Type filter (single or multiple)
if (type) {
if (Array.isArray(type)) {
where.type = { in: type }
} else {
where.type = { equals: type }
}
}
// Featured filter
if (featured !== undefined) {
where.isFeatured = { equals: featured }
}
// Search filter
if (search) {
where.or = [
{ title: { contains: search } },
{ excerpt: { contains: search } },
]
}
// Date filters
if (year) {
const startDate = new Date(year, month ? month - 1 : 0, 1)
const endDate = month
? new Date(year, month, 0, 23, 59, 59) // Last day of month
: new Date(year, 11, 31, 23, 59, 59) // Last day of year
where.publishedAt = {
greater_than_equal: startDate.toISOString(),
less_than_equal: endDate.toISOString(),
}
}
// Exclude specific IDs (useful for "more articles" sections)
if (excludeIds && excludeIds.length > 0) {
where.id = { not_in: excludeIds }
}
// Category filter via join
let categoryId: number | undefined
if (categorySlug) {
const categoryResult = await payload.find({
collection: 'categories',
where: {
slug: { equals: categorySlug },
...(tenantId ? { tenant: { equals: tenantId } } : {}),
},
limit: 1,
locale,
})
if (categoryResult.docs.length > 0) {
categoryId = categoryResult.docs[0].id
where.categories = { contains: categoryId }
} else {
// Category not found - return empty result
return {
docs: [],
page: 1,
totalPages: 0,
totalDocs: 0,
hasNextPage: false,
hasPrevPage: false,
}
}
}
// Execute query
return payload.find({
collection: 'posts',
where: where as Where,
sort: '-publishedAt',
page,
limit,
locale,
depth: 2, // Load relations (categories, media)
})
}
// Helper to get available categories for a tenant
async function getCategories(
payload: Awaited<ReturnType<typeof getPayload>>,
tenantId?: number,
locale: Locale = 'de'
) {
const where: Where = {}
if (tenantId) {
where.tenant = { equals: tenantId }
}
const result = await payload.find({
collection: 'categories',
where: where as Where,
sort: 'name',
limit: 100,
locale,
})
return result.docs.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
}))
}
// Helper to get archive data (years/months with post counts)
// Uses pagination to handle large datasets without memory issues
async function getArchive(
payload: Awaited<ReturnType<typeof getPayload>>,
tenantId: number // Now required
) {
const where: Where = {
status: { equals: 'published' },
publishedAt: { exists: true },
tenant: { equals: tenantId },
}
// Group by year and month using pagination
const archive: Record<number, Record<number, number>> = {}
let page = 1
const pageSize = 500
let hasMore = true
while (hasMore) {
const result = await payload.find({
collection: 'posts',
where: where as Where,
sort: '-publishedAt',
page,
limit: pageSize,
depth: 0,
})
for (const post of result.docs) {
if (post.publishedAt) {
const date = new Date(post.publishedAt)
const year = date.getFullYear()
const month = date.getMonth() + 1
if (!archive[year]) {
archive[year] = {}
}
archive[year][month] = (archive[year][month] || 0) + 1
}
}
hasMore = result.hasNextPage
page++
// Safety limit: max 20 pages (10,000 posts)
if (page > 20) break
}
// Transform to sorted array
return Object.entries(archive)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([year, months]) => ({
year: Number(year),
months: Object.entries(months)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([month, count]) => ({
month: Number(month),
count,
})),
total: Object.values(months).reduce((sum, count) => sum + count, 0),
}))
}
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, NEWS_RATE_LIMIT) }
)
}
// Parse query parameters
const { searchParams } = new URL(request.url)
// Basic params
const tenantParam = searchParams.get('tenant')
const pageParam = searchParams.get('page')
const limitParam = searchParams.get('limit')
const localeParam = searchParams.get('locale')?.trim()
// Filter params
const typeParam = searchParams.get('type')?.trim()
const typesParam = searchParams.get('types')?.trim() // Comma-separated for multiple
const categoryParam = searchParams.get('category')?.trim()
const featuredParam = searchParams.get('featured')
const searchParam = searchParams.get('search')?.trim()
const yearParam = searchParams.get('year')
const monthParam = searchParams.get('month')
const excludeParam = searchParams.get('exclude')?.trim() // Comma-separated IDs
// Meta params
const includeCategories = searchParams.get('includeCategories') === 'true'
const includeArchive = searchParams.get('includeArchive') === 'true'
// Validate locale
const validLocales: Locale[] = ['de', 'en']
const locale: Locale = localeParam && validLocales.includes(localeParam as Locale) ? (localeParam as Locale) : 'de'
// Validate and parse types
let types: NewsType | NewsType[] | undefined
if (typesParam) {
const typeList = typesParam.split(',').map((t) => t.trim()) as NewsType[]
const invalidTypes = typeList.filter((t) => !NEWS_TYPES.includes(t))
if (invalidTypes.length > 0) {
return NextResponse.json(
{ error: `Invalid types: ${invalidTypes.join(', ')}. Valid: ${NEWS_TYPES.join(', ')}` },
{ status: 400 }
)
}
types = typeList
} else if (typeParam) {
if (!NEWS_TYPES.includes(typeParam as NewsType)) {
return NextResponse.json(
{ error: `Invalid type. Must be one of: ${NEWS_TYPES.join(', ')}` },
{ status: 400 }
)
}
types = typeParam as NewsType
}
// Parse numeric parameters
const page = Math.max(1, parseInt(pageParam || '1', 10) || 1)
const limit = Math.min(
Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT),
MAX_LIMIT
)
const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined
const year = yearParam ? parseInt(yearParam, 10) : undefined
const month = monthParam ? parseInt(monthParam, 10) : undefined
// Validate tenant ID - REQUIRED for tenant isolation
if (!tenantParam) {
return NextResponse.json(
{ error: 'Tenant ID is required. Use ?tenant=<id> to specify the tenant.' },
{ status: 400 }
)
}
if (isNaN(tenantId!) || tenantId! < 1) {
return NextResponse.json({ error: 'Invalid tenant ID' }, { status: 400 })
}
// Validate year/month
if (year && (year < 2000 || year > 2100)) {
return NextResponse.json({ error: 'Invalid year' }, { status: 400 })
}
if (month && (month < 1 || month > 12)) {
return NextResponse.json({ error: 'Invalid month (1-12)' }, { status: 400 })
}
// Parse featured
const featured = featuredParam === 'true' ? true : featuredParam === 'false' ? false : undefined
// Parse exclude IDs
const excludeIds = excludeParam
? excludeParam.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id))
: undefined
// Get payload instance
const payload = await getPayload({ config })
// Execute main query
const result = await getNews(payload, {
tenantId,
type: types,
categorySlug: categoryParam,
featured,
search: searchParam,
year,
month,
locale,
page,
limit,
excludeIds,
})
// Transform posts
const transformedDocs = result.docs.map((post: Post) => {
const featuredImage = post.featuredImage as Media | null
const categories = (post.categories || []) as Category[]
return {
id: post.id,
title: post.title,
slug: post.slug,
type: post.type || 'blog',
excerpt: post.excerpt || null,
author: post.author || null,
publishedAt: post.publishedAt || null,
isFeatured: post.isFeatured || false,
featuredImage: featuredImage
? {
url: featuredImage.url,
alt: featuredImage.alt || post.title,
width: featuredImage.width,
height: featuredImage.height,
// Responsive sizes for frontend
sizes: featuredImage.sizes || null,
}
: null,
categories: categories
.filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat)
.map((cat) => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
})),
seo: post.seo
? {
metaTitle: post.seo.metaTitle || post.title,
metaDescription: post.seo.metaDescription || post.excerpt || null,
}
: null,
}
})
// Build response
const response: Record<string, unknown> = {
docs: transformedDocs,
pagination: {
page: result.page,
limit,
totalPages: result.totalPages,
totalDocs: result.totalDocs,
hasNextPage: result.hasNextPage,
hasPrevPage: result.hasPrevPage,
},
filters: {
type: types,
category: categoryParam,
featured,
search: searchParam,
year,
month,
locale,
tenant: tenantId,
},
}
// Include categories if requested
if (includeCategories) {
response.categories = await getCategories(payload, tenantId, locale)
}
// Include archive if requested
if (includeArchive) {
response.archive = await getArchive(payload, tenantId!)
}
return NextResponse.json(response, {
headers: {
...rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT),
'Cache-Control': 'public, max-age=60, s-maxage=120',
},
})
} catch (error) {
console.error('[News API] Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}