// src/app/(frontend)/api/news/[slug]/route.ts // Single news article detail with related posts import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import config from '@payload-config' import type { Category, Media, Post } from '@/payload-types' import { searchLimiter, rateLimitHeaders, getClientIpFromRequest, isIpBlocked, } from '@/lib/security' const NEWS_RATE_LIMIT = 30 const RELATED_POSTS_LIMIT = 4 interface RouteParams { params: Promise<{ slug: string }> } export async function GET(request: NextRequest, { params }: RouteParams) { 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) } ) } // Get slug from params const { slug } = await params if (!slug) { return NextResponse.json({ error: 'Slug is required' }, { status: 400 }) } // Parse query parameters const { searchParams } = new URL(request.url) const tenantParam = searchParams.get('tenant') const localeParam = searchParams.get('locale')?.trim() const includeRelated = searchParams.get('includeRelated') !== 'false' // Default true // Validate locale const validLocales = ['de', 'en'] const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' // Parse tenant ID - REQUIRED for tenant isolation const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined 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 }) } // Get payload instance const payload = await getPayload({ config }) // Build where clause (tenant is now required) const where: Record = { slug: { equals: slug }, status: { equals: 'published' }, tenant: { equals: tenantId }, } // Find the post const result = await payload.find({ collection: 'posts', where, limit: 1, locale, depth: 2, }) if (result.docs.length === 0) { return NextResponse.json( { error: 'Article not found' }, { status: 404 } ) } const post = result.docs[0] as Post const featuredImage = post.featuredImage as Media | null const ogImage = post.seo?.ogImage as Media | null const categories = (post.categories || []) as Category[] // Transform the main article const article = { id: post.id, title: post.title, slug: post.slug, type: post.type || 'blog', excerpt: post.excerpt || null, content: post.content, // Full rich text content 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, 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: { metaTitle: post.seo?.metaTitle || post.title, metaDescription: post.seo?.metaDescription || post.excerpt || null, ogImage: ogImage ? { url: ogImage.url, width: ogImage.width, height: ogImage.height, } : featuredImage ? { url: featuredImage.url, width: featuredImage.width, height: featuredImage.height, } : null, }, } // Build response const response: Record = { article, locale, } // Get related posts if requested (filtered by same type AND category for relevance) if (includeRelated && categories.length > 0) { const categoryIds = categories .filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'id' in cat) .map((cat) => cat.id) const relatedResult = await payload.find({ collection: 'posts', where: { id: { not_equals: post.id }, status: { equals: 'published' }, type: { equals: post.type || 'blog' }, // Same type for better relevance categories: { in: categoryIds }, tenant: { equals: tenantId }, // Tenant is now required }, sort: '-publishedAt', limit: RELATED_POSTS_LIMIT, locale, depth: 1, }) response.relatedPosts = relatedResult.docs.map((relatedPost: Post) => { const relatedImage = relatedPost.featuredImage as Media | null return { id: relatedPost.id, title: relatedPost.title, slug: relatedPost.slug, type: relatedPost.type || 'blog', excerpt: relatedPost.excerpt || null, publishedAt: relatedPost.publishedAt || null, featuredImage: relatedImage ? { url: relatedImage.url, alt: relatedImage.alt || relatedPost.title, width: relatedImage.width, height: relatedImage.height, } : null, } }) } // Get previous and next posts for navigation (same tenant, same type for consistency) const [prevResult, nextResult] = await Promise.all([ payload.find({ collection: 'posts', where: { status: { equals: 'published' }, type: { equals: post.type || 'blog' }, tenant: { equals: tenantId }, publishedAt: { less_than: post.publishedAt || new Date().toISOString() }, }, sort: '-publishedAt', limit: 1, locale, depth: 0, }), payload.find({ collection: 'posts', where: { status: { equals: 'published' }, type: { equals: post.type || 'blog' }, tenant: { equals: tenantId }, publishedAt: { greater_than: post.publishedAt || new Date().toISOString() }, }, sort: 'publishedAt', limit: 1, locale, depth: 0, }), ]) response.navigation = { previous: prevResult.docs[0] ? { title: prevResult.docs[0].title, slug: prevResult.docs[0].slug, } : null, next: nextResult.docs[0] ? { title: nextResult.docs[0].title, slug: nextResult.docs[0].slug, } : null, } return NextResponse.json(response, { headers: { ...rateLimitHeaders(rateLimit, NEWS_RATE_LIMIT), 'Cache-Control': 'public, max-age=60, s-maxage=300', }, }) } catch (error) { console.error('[News Detail API] Error:', error) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }