mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
- 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>
439 lines
12 KiB
TypeScript
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 })
|
|
}
|
|
}
|