mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
feat: add dedicated News API with tenant isolation and security features
- /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>
This commit is contained in:
parent
da735cab46
commit
aa1f8b1054
2 changed files with 683 additions and 0 deletions
247
src/app/(frontend)/api/news/[slug]/route.ts
Normal file
247
src/app/(frontend)/api/news/[slug]/route.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// 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 })
|
||||
}
|
||||
}
|
||||
436
src/app/(frontend)/api/news/route.ts
Normal file
436
src/app/(frontend)/api/news/route.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
// 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 config from '@payload-config'
|
||||
import type { Category, Media, Post } from '@/payload-types'
|
||||
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: string
|
||||
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: Record<string, unknown> = {
|
||||
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,
|
||||
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: string = 'de'
|
||||
) {
|
||||
const where: Record<string, unknown> = {}
|
||||
if (tenantId) {
|
||||
where.tenant = { equals: tenantId }
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'categories',
|
||||
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: Record<string, unknown> = {
|
||||
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,
|
||||
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 = ['de', 'en']
|
||||
const locale = localeParam && validLocales.includes(localeParam) ? localeParam : '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 })
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue