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