mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
- /api/news: List endpoint with filtering by type, category, featured, search, date - /api/news/[slug]: Detail endpoint with related posts and navigation - Required tenant ID for strict tenant isolation (security fix) - Related posts filtered by same type AND category for relevance - Navigation (prev/next) filtered by same type - Archive with pagination (500/page, max 10k posts) instead of hard limit - Rate limiting, IP blocking, caching headers included 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
247 lines
7.4 KiB
TypeScript
247 lines
7.4 KiB
TypeScript
// 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=<id> 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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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 })
|
|
}
|
|
}
|