From 51c340e9e74777b953cd3e77ad4c4ac9ccb2ea63 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Mon, 1 Dec 2025 08:19:35 +0000 Subject: [PATCH] feat: add i18n, SEO, and frontend infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Localization: - Add middleware for locale detection/routing - Add [locale] dynamic route structure - Add i18n utility library (DE/EN support) SEO & Discovery: - Add robots.ts for search engine directives - Add sitemap.ts for XML sitemap generation - Add structuredData.ts for JSON-LD schemas Utilities: - Add search.ts for full-text search functionality - Add tenantAccess.ts for multi-tenant access control - Add envValidation.ts for environment validation Frontend: - Update layout.tsx with locale support - Update page.tsx for localized content - Add API routes for frontend functionality - Add instrumentation.ts for monitoring 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(frontend)/[locale]/layout.tsx | 30 + src/app/(frontend)/[locale]/page.tsx | 100 ++++ src/app/(frontend)/api/posts/route.ts | 136 +++++ src/app/(frontend)/api/search/route.ts | 118 ++++ .../api/search/suggestions/route.ts | 112 ++++ src/app/(frontend)/layout.tsx | 15 +- src/app/(frontend)/page.tsx | 63 +-- src/app/robots.ts | 40 ++ src/app/sitemap.ts | 87 +++ src/instrumentation.ts | 24 + src/lib/envValidation.ts | 73 +++ src/lib/i18n.ts | 84 +++ src/lib/search.ts | 526 ++++++++++++++++++ src/lib/structuredData.ts | 480 ++++++++++++++++ src/lib/tenantAccess.ts | 83 +++ src/middleware.ts | 101 ++++ 16 files changed, 2006 insertions(+), 66 deletions(-) create mode 100644 src/app/(frontend)/[locale]/layout.tsx create mode 100644 src/app/(frontend)/[locale]/page.tsx create mode 100644 src/app/(frontend)/api/posts/route.ts create mode 100644 src/app/(frontend)/api/search/route.ts create mode 100644 src/app/(frontend)/api/search/suggestions/route.ts create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/instrumentation.ts create mode 100644 src/lib/envValidation.ts create mode 100644 src/lib/i18n.ts create mode 100644 src/lib/search.ts create mode 100644 src/lib/structuredData.ts create mode 100644 src/lib/tenantAccess.ts create mode 100644 src/middleware.ts diff --git a/src/app/(frontend)/[locale]/layout.tsx b/src/app/(frontend)/[locale]/layout.tsx new file mode 100644 index 0000000..cc869ae --- /dev/null +++ b/src/app/(frontend)/[locale]/layout.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { notFound } from 'next/navigation' +import { isValidLocale, locales, getLocaleDirection, type Locale } from '@/lib/i18n' + +export function generateStaticParams() { + return locales.map((locale) => ({ locale })) +} + +interface LocaleLayoutProps { + children: React.ReactNode + params: Promise<{ locale: string }> +} + +export default async function LocaleLayout({ children, params }: LocaleLayoutProps) { + const { locale } = await params + + if (!isValidLocale(locale)) { + notFound() + } + + const direction = getLocaleDirection(locale as Locale) + + return ( + + +
{children}
+ + + ) +} diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx new file mode 100644 index 0000000..be172d8 --- /dev/null +++ b/src/app/(frontend)/[locale]/page.tsx @@ -0,0 +1,100 @@ +import { headers as getHeaders } from 'next/headers' +import Image from 'next/image' +import { getPayload } from 'payload' +import React from 'react' +import { fileURLToPath } from 'url' +import { notFound } from 'next/navigation' + +import config from '@/payload.config' +import { isValidLocale, localeNames, type Locale } from '@/lib/i18n' +import '../styles.css' + +interface HomePageProps { + params: Promise<{ locale: string }> +} + +export default async function HomePage({ params }: HomePageProps) { + const { locale } = await params + + if (!isValidLocale(locale)) { + notFound() + } + + const headers = await getHeaders() + const payloadConfig = await config + const payload = await getPayload({ config: payloadConfig }) + const { user } = await payload.auth({ headers }) + + const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}` + + // Localized content + const content = { + de: { + welcome: 'Willkommen zu Ihrem neuen Projekt.', + welcomeBack: 'Willkommen zurück,', + adminPanel: 'Zum Admin-Panel', + documentation: 'Dokumentation', + updatePage: 'Diese Seite bearbeiten in', + }, + en: { + welcome: 'Welcome to your new project.', + welcomeBack: 'Welcome back,', + adminPanel: 'Go to admin panel', + documentation: 'Documentation', + updatePage: 'Update this page by editing', + }, + } + + const t = content[locale as Locale] + + return ( +
+
+ + + Payload Logo + + {!user &&

{t.welcome}

} + {user && ( +

+ {t.welcomeBack} {user.email} +

+ )} +
+ + {t.adminPanel} + + + {t.documentation} + +
+
+

+ Current locale: {localeNames[locale as Locale].native} +

+
+
+
+

{t.updatePage}

+ + app/(frontend)/[locale]/page.tsx + +
+
+ ) +} diff --git a/src/app/(frontend)/api/posts/route.ts b/src/app/(frontend)/api/posts/route.ts new file mode 100644 index 0000000..401665e --- /dev/null +++ b/src/app/(frontend)/api/posts/route.ts @@ -0,0 +1,136 @@ +// src/app/(frontend)/api/posts/route.ts +// Posts listing with category and type filters + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { getPostsByCategory, checkRateLimit } from '@/lib/search' + +// Validation constants +const MAX_LIMIT = 50 +const DEFAULT_LIMIT = 10 + +export async function GET(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + + const rateLimit = checkRateLimit(ip) + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfter || 60), + 'X-RateLimit-Remaining': '0', + }, + } + ) + } + + // Parse query parameters + const { searchParams } = new URL(request.url) + const category = searchParams.get('category')?.trim() + const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined + const tenantParam = searchParams.get('tenant') + const pageParam = searchParams.get('page') + const limitParam = searchParams.get('limit') + const localeParam = searchParams.get('locale')?.trim() + + // Validate locale + const validLocales = ['de', 'en'] + const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' + + // Validate type + const validTypes = ['blog', 'news', 'press', 'announcement'] + if (type && !validTypes.includes(type)) { + return NextResponse.json( + { error: `Invalid type. Must be one of: ${validTypes.join(', ')}` }, + { status: 400 } + ) + } + + // 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 + + // Validate tenant ID if provided + if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 } + ) + } + + // Get payload instance + const payload = await getPayload({ config }) + + // Get posts + const result = await getPostsByCategory(payload, { + tenantId, + categorySlug: category, + type, + locale, + page, + limit, + }) + + // Transform response + const response = { + docs: result.docs.map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt || null, + publishedAt: post.publishedAt || null, + type: (post as typeof post & { type?: string }).type || 'blog', + featuredImage: post.featuredImage && typeof post.featuredImage === 'object' + ? { + url: post.featuredImage.url, + alt: post.featuredImage.alt, + width: post.featuredImage.width, + height: post.featuredImage.height, + } + : null, + category: post.category && typeof post.category === 'object' + ? { name: post.category.name, slug: post.category.slug } + : null, + })), + pagination: { + page: result.page, + limit, + totalPages: result.totalPages, + totalDocs: result.totalDocs, + hasNextPage: result.hasNextPage, + hasPrevPage: result.hasPrevPage, + }, + filters: { + category, + type, + locale, + tenant: tenantId, + }, + } + + return NextResponse.json(response, { + headers: { + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }) + } catch (error) { + console.error('[Posts API] Error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/(frontend)/api/search/route.ts b/src/app/(frontend)/api/search/route.ts new file mode 100644 index 0000000..43270ac --- /dev/null +++ b/src/app/(frontend)/api/search/route.ts @@ -0,0 +1,118 @@ +// src/app/(frontend)/api/search/route.ts +// Main search API endpoint + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { searchPosts, checkRateLimit } from '@/lib/search' + +// Validation constants +const MIN_QUERY_LENGTH = 2 +const MAX_QUERY_LENGTH = 100 +const MAX_LIMIT = 50 +const DEFAULT_LIMIT = 10 + +export async function GET(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + + const rateLimit = checkRateLimit(ip) + + if (!rateLimit.allowed) { + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfter || 60), + 'X-RateLimit-Remaining': '0', + }, + } + ) + } + + // Parse query parameters + const { searchParams } = new URL(request.url) + const query = searchParams.get('q')?.trim() || '' + const category = searchParams.get('category')?.trim() + const type = searchParams.get('type')?.trim() as 'blog' | 'news' | 'press' | 'announcement' | undefined + const tenantParam = searchParams.get('tenant') + const limitParam = searchParams.get('limit') + const offsetParam = searchParams.get('offset') + const localeParam = searchParams.get('locale')?.trim() + + // Validate locale + const validLocales = ['de', 'en'] + const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' + + // Validate query + if (query && query.length < MIN_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query must be at least ${MIN_QUERY_LENGTH} characters` }, + { status: 400 } + ) + } + + if (query && query.length > MAX_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query must be at most ${MAX_QUERY_LENGTH} characters` }, + { status: 400 } + ) + } + + // Validate type + const validTypes = ['blog', 'news', 'press', 'announcement'] + if (type && !validTypes.includes(type)) { + return NextResponse.json( + { error: `Invalid type. Must be one of: ${validTypes.join(', ')}` }, + { status: 400 } + ) + } + + // Parse numeric parameters + const limit = Math.min( + Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), + MAX_LIMIT + ) + const offset = Math.max(0, parseInt(offsetParam || '0', 10) || 0) + const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined + + // Validate tenant ID if provided + if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 } + ) + } + + // Get payload instance + const payload = await getPayload({ config }) + + // Execute search + const result = await searchPosts(payload, { + query, + tenantId, + categorySlug: category, + type, + locale, + limit, + offset, + }) + + return NextResponse.json(result, { + headers: { + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + }) + } catch (error) { + console.error('[Search API] Error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/(frontend)/api/search/suggestions/route.ts b/src/app/(frontend)/api/search/suggestions/route.ts new file mode 100644 index 0000000..55033aa --- /dev/null +++ b/src/app/(frontend)/api/search/suggestions/route.ts @@ -0,0 +1,112 @@ +// src/app/(frontend)/api/search/suggestions/route.ts +// Auto-complete suggestions endpoint + +import { NextRequest, NextResponse } from 'next/server' +import { getPayload } from 'payload' +import config from '@payload-config' +import { getSearchSuggestions, checkRateLimit } from '@/lib/search' + +// Validation constants +const MIN_QUERY_LENGTH = 2 +const MAX_QUERY_LENGTH = 50 +const MAX_LIMIT = 10 +const DEFAULT_LIMIT = 5 + +export async function GET(request: NextRequest) { + try { + // Rate limiting + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + + const rateLimit = checkRateLimit(ip) + + if (!rateLimit.allowed) { + return NextResponse.json( + { suggestions: [] }, + { + status: 429, + headers: { + 'Retry-After': String(rateLimit.retryAfter || 60), + 'X-RateLimit-Remaining': '0', + }, + } + ) + } + + // Parse query parameters + const { searchParams } = new URL(request.url) + const query = searchParams.get('q')?.trim() || '' + const category = searchParams.get('category')?.trim() + const tenantParam = searchParams.get('tenant') + const limitParam = searchParams.get('limit') + const localeParam = searchParams.get('locale')?.trim() + + // Validate locale + const validLocales = ['de', 'en'] + const locale = localeParam && validLocales.includes(localeParam) ? localeParam : 'de' + + // Validate query + if (!query || query.length < MIN_QUERY_LENGTH) { + return NextResponse.json( + { suggestions: [] }, + { + headers: { + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + } + ) + } + + if (query.length > MAX_QUERY_LENGTH) { + return NextResponse.json( + { error: `Query must be at most ${MAX_QUERY_LENGTH} characters` }, + { status: 400 } + ) + } + + // Parse numeric parameters + const limit = Math.min( + Math.max(1, parseInt(limitParam || String(DEFAULT_LIMIT), 10) || DEFAULT_LIMIT), + MAX_LIMIT + ) + const tenantId = tenantParam ? parseInt(tenantParam, 10) : undefined + + // Validate tenant ID if provided + if (tenantParam && (isNaN(tenantId!) || tenantId! < 1)) { + return NextResponse.json( + { error: 'Invalid tenant ID' }, + { status: 400 } + ) + } + + // Get payload instance + const payload = await getPayload({ config }) + + // Get suggestions + const suggestions = await getSearchSuggestions(payload, { + query, + tenantId, + categorySlug: category, + locale, + limit, + }) + + return NextResponse.json( + { suggestions }, + { + headers: { + 'X-RateLimit-Remaining': String(rateLimit.remaining), + 'Cache-Control': 'public, max-age=60, s-maxage=60', + }, + } + ) + } catch (error) { + console.error('[Suggestions API] Error:', error) + return NextResponse.json( + { suggestions: [] }, + { status: 500 } + ) + } +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index e7681f7..2538e1a 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -2,18 +2,15 @@ import React from 'react' import './styles.css' export const metadata = { - description: 'A blank template using Payload in a Next.js app.', - title: 'Payload Blank Template', + description: 'Multi-Tenant CMS für mehrere Websites', + title: 'Payload CMS', } export default async function RootLayout(props: { children: React.ReactNode }) { const { children } = props - return ( - - -
{children}
- - - ) + // This layout wraps both localized and non-localized routes + // The [locale]/layout.tsx handles the html/body for localized routes + // For API routes etc, this provides basic wrapper + return <>{children} } diff --git a/src/app/(frontend)/page.tsx b/src/app/(frontend)/page.tsx index d0e8d64..633b5be 100644 --- a/src/app/(frontend)/page.tsx +++ b/src/app/(frontend)/page.tsx @@ -1,59 +1,8 @@ -import { headers as getHeaders } from 'next/headers.js' -import Image from 'next/image' -import { getPayload } from 'payload' -import React from 'react' -import { fileURLToPath } from 'url' +import { redirect } from 'next/navigation' +import { defaultLocale } from '@/lib/i18n' -import config from '@/payload.config' -import './styles.css' - -export default async function HomePage() { - const headers = await getHeaders() - const payloadConfig = await config - const payload = await getPayload({ config: payloadConfig }) - const { user } = await payload.auth({ headers }) - - const fileURL = `vscode://file/${fileURLToPath(import.meta.url)}` - - return ( -
-
- - - Payload Logo - - {!user &&

Welcome to your new project.

} - {user &&

Welcome back, {user.email}

} -
- - Go to admin panel - - - Documentation - -
-
-
-

Update this page by editing

- - app/(frontend)/page.tsx - -
-
- ) +// This page catches requests to / and redirects to the default locale +// The middleware should normally handle this, but this is a fallback +export default function RootPage() { + redirect(`/${defaultLocale}`) } diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..547a27c --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,40 @@ +import { MetadataRoute } from 'next' + +/** + * robots.txt Generierung + * + * Steuert das Crawling-Verhalten von Suchmaschinen. + * Für Multi-Tenant können individuelle Regeln pro Domain erstellt werden. + */ +export default function robots(): MetadataRoute.Robots { + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de' + + return { + rules: [ + { + userAgent: '*', + allow: '/', + disallow: [ + '/admin', + '/admin/*', + '/api/*', + '/_next/*', + '/media/*', // Optional: Media-Ordner ausschließen + ], + }, + // Spezifische Regeln für bekannte Bots + { + userAgent: 'Googlebot', + allow: '/', + disallow: ['/admin', '/api'], + }, + { + userAgent: 'Bingbot', + allow: '/', + disallow: ['/admin', '/api'], + }, + ], + sitemap: `${baseUrl}/sitemap.xml`, + host: baseUrl, + } +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..e42c2f9 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,87 @@ +import { MetadataRoute } from 'next' + +// Force dynamic rendering - don't generate at build time +export const dynamic = 'force-dynamic' + +/** + * Dynamische Sitemap-Generierung + * + * Diese Funktion wird zur Laufzeit aufgerufen und generiert die Sitemap + * basierend auf den aktuellen Inhalten der Datenbank. + */ +export default async function sitemap(): Promise { + const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || 'https://pl.c2sgmbh.de' + + // Basis-Sitemap mit Startseite + const sitemapEntries: MetadataRoute.Sitemap = [ + { + url: baseUrl, + lastModified: new Date(), + changeFrequency: 'daily', + priority: 1.0, + }, + ] + + // Dynamische Inhalte nur zur Laufzeit laden, nicht beim Build + if (process.env.NODE_ENV === 'production' && typeof window === 'undefined') { + try { + // Lazy import um Build-Zeit-Probleme zu vermeiden + const { getPayload } = await import('payload') + const config = (await import('@payload-config')).default + + const payload = await getPayload({ config }) + + // Alle veröffentlichten Seiten abrufen + const pages = await payload.find({ + collection: 'pages', + where: { status: { equals: 'published' } }, + limit: 1000, + depth: 0, + }) + + for (const page of pages.docs) { + if (page.slug === 'home' || page.slug === 'startseite' || page.slug === '/') { + continue + } + sitemapEntries.push({ + url: `${baseUrl}/${page.slug}`, + lastModified: new Date(page.updatedAt), + changeFrequency: 'weekly', + priority: 0.8, + }) + } + + // Alle veröffentlichten Blog-Posts abrufen + const posts = await payload.find({ + collection: 'posts', + where: { status: { equals: 'published' } }, + limit: 1000, + depth: 0, + }) + + for (const post of posts.docs) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const postType = (post as any).type || 'blog' + const urlPrefix = + postType === 'news' + ? 'news' + : postType === 'press' + ? 'presse' + : postType === 'announcement' + ? 'aktuelles' + : 'blog' + + sitemapEntries.push({ + url: `${baseUrl}/${urlPrefix}/${post.slug}`, + lastModified: new Date(post.updatedAt), + changeFrequency: 'monthly', + priority: 0.6, + }) + } + } catch (error) { + console.warn('[Sitemap] Could not fetch dynamic content:', error) + } + } + + return sitemapEntries +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..399c6cd --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,24 @@ +/** + * Next.js Instrumentation Hook + * + * Wird beim Server-Start ausgeführt. Initialisiert Scheduled Jobs. + * https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + */ +export async function register() { + // Nur auf dem Server ausführen, nicht im Edge Runtime + if (process.env.NEXT_RUNTIME === 'nodejs') { + const { getPayload } = await import('payload') + const config = await import('./payload.config') + const { initScheduledJobs } = await import('./jobs/scheduler') + + // Payload initialisieren + const payload = await getPayload({ + config: config.default, + }) + + // Scheduled Jobs starten + initScheduledJobs(payload) + + console.log('[Instrumentation] Payload und Scheduled Jobs initialisiert.') + } +} diff --git a/src/lib/envValidation.ts b/src/lib/envValidation.ts new file mode 100644 index 0000000..70470b3 --- /dev/null +++ b/src/lib/envValidation.ts @@ -0,0 +1,73 @@ +// src/lib/envValidation.ts + +/** + * Zentrale Validierung aller erforderlichen Environment-Variablen. + * Wird beim Server-Start aufgerufen und beendet den Prozess bei fehlenden Werten. + */ + +interface RequiredEnvVars { + PAYLOAD_SECRET: string + DATABASE_URI: string + CONSENT_LOGGING_API_KEY: string + IP_ANONYMIZATION_PEPPER: string +} + +const FORBIDDEN_VALUES = [ + '', + 'default-pepper-change-me', + 'change-me', + 'your-secret-here', + 'xxx', +] + +function validateEnvVar(name: string, value: string | undefined): string { + if (!value || value.trim() === '') { + throw new Error( + `FATAL: Environment variable ${name} is required but not set. ` + + `Server cannot start without this value.`, + ) + } + + if (FORBIDDEN_VALUES.includes(value.trim().toLowerCase())) { + throw new Error( + `FATAL: Environment variable ${name} has an insecure default value. ` + + `Please set a secure random value.`, + ) + } + + return value.trim() +} + +/** + * Validiert alle erforderlichen Environment-Variablen. + * Wirft einen Fehler und beendet den Server-Start, wenn Variablen fehlen. + */ +export function validateRequiredEnvVars(): RequiredEnvVars { + return { + PAYLOAD_SECRET: validateEnvVar('PAYLOAD_SECRET', process.env.PAYLOAD_SECRET), + DATABASE_URI: validateEnvVar('DATABASE_URI', process.env.DATABASE_URI), + CONSENT_LOGGING_API_KEY: validateEnvVar( + 'CONSENT_LOGGING_API_KEY', + process.env.CONSENT_LOGGING_API_KEY, + ), + IP_ANONYMIZATION_PEPPER: validateEnvVar( + 'IP_ANONYMIZATION_PEPPER', + process.env.IP_ANONYMIZATION_PEPPER, + ), + } +} + +/** + * Lazy-initialized Environment-Variablen. + * Wird erst beim ersten Zugriff validiert (vermeidet Build-Probleme). + */ +let _cachedEnv: RequiredEnvVars | null = null + +export const env: RequiredEnvVars = new Proxy({} as RequiredEnvVars, { + get(_, prop: keyof RequiredEnvVars) { + if (!_cachedEnv) { + _cachedEnv = validateRequiredEnvVars() + } + return _cachedEnv[prop] + }, +}) diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..74f5697 --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,84 @@ +// src/lib/i18n.ts +// Frontend i18n configuration and utilities + +export const locales = ['de', 'en'] as const +export type Locale = (typeof locales)[number] +export const defaultLocale: Locale = 'de' + +/** + * Validate if a string is a valid locale + */ +export function isValidLocale(locale: string): locale is Locale { + return locales.includes(locale as Locale) +} + +/** + * Get locale from pathname + * Returns the locale if found, otherwise returns the default locale + */ +export function getLocaleFromPathname(pathname: string): Locale { + const segments = pathname.split('/').filter(Boolean) + const firstSegment = segments[0] + + if (firstSegment && isValidLocale(firstSegment)) { + return firstSegment + } + + return defaultLocale +} + +/** + * Remove locale prefix from pathname + */ +export function removeLocaleFromPathname(pathname: string): string { + const locale = getLocaleFromPathname(pathname) + const segments = pathname.split('/').filter(Boolean) + + if (segments[0] === locale) { + segments.shift() + } + + return '/' + segments.join('/') +} + +/** + * Add locale prefix to pathname + */ +export function addLocaleToPathname(pathname: string, locale: Locale): string { + const cleanPathname = removeLocaleFromPathname(pathname) + + // For default locale, we could optionally skip the prefix + // But for consistency, we'll always include it + return `/${locale}${cleanPathname === '/' ? '' : cleanPathname}` +} + +/** + * Get alternate language links for SEO (hreflang) + */ +export function getAlternateLinks( + pathname: string, + baseUrl: string, +): { locale: Locale; url: string }[] { + const cleanPathname = removeLocaleFromPathname(pathname) + + return locales.map((locale) => ({ + locale, + url: `${baseUrl}/${locale}${cleanPathname === '/' ? '' : cleanPathname}`, + })) +} + +/** + * Locale display names for UI + */ +export const localeNames: Record = { + de: { native: 'Deutsch', english: 'German' }, + en: { native: 'English', english: 'English' }, +} + +/** + * Get locale direction (for RTL support in future) + */ +export function getLocaleDirection(locale: Locale): 'ltr' | 'rtl' { + // Both German and English are LTR + return 'ltr' +} diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..9305511 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,526 @@ +// src/lib/search.ts +// Shared search library with caching, rate limiting, and search functions + +import type { Payload, Where } from 'payload' +import type { Post, Category } from '../payload-types' + +// ============================================================================ +// Types +// ============================================================================ + +export interface SearchParams { + query: string + tenantId?: number + categorySlug?: string + type?: 'blog' | 'news' | 'press' | 'announcement' + locale?: string + limit?: number + offset?: number +} + +export interface SearchResult { + results: SearchResultItem[] + total: number + query: string + filters: { + category?: string + type?: string + locale?: string + } + pagination: { + limit: number + offset: number + hasMore: boolean + } +} + +export interface SearchResultItem { + id: number + title: string + slug: string + excerpt: string | null + publishedAt: string | null + type: string + category: { + name: string + slug: string + } | null +} + +export interface SuggestionParams { + query: string + tenantId?: number + categorySlug?: string + locale?: string + limit?: number +} + +export interface Suggestion { + title: string + slug: string + type: string +} + +export interface CategoryFilterParams { + tenantId?: number + categorySlug?: string + type?: 'blog' | 'news' | 'press' | 'announcement' + locale?: string + page?: number + limit?: number +} + +export interface PaginatedPosts { + docs: Post[] + totalDocs: number + page: number + totalPages: number + hasNextPage: boolean + hasPrevPage: boolean +} + +export interface RateLimitResult { + allowed: boolean + remaining: number + retryAfter?: number +} + +// ============================================================================ +// TTL Cache Implementation +// ============================================================================ + +interface CacheEntry { + value: T + expiresAt: number +} + +class TTLCache { + private cache = new Map>() + private readonly ttlMs: number + private cleanupInterval: ReturnType | null = null + + constructor(ttlSeconds: number = 60) { + this.ttlMs = ttlSeconds * 1000 + // Cleanup expired entries every minute + this.cleanupInterval = setInterval(() => this.cleanup(), 60000) + } + + get(key: K): V | undefined { + const entry = this.cache.get(key) + if (!entry) return undefined + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key) + return undefined + } + + return entry.value + } + + set(key: K, value: V): void { + this.cache.set(key, { + value, + expiresAt: Date.now() + this.ttlMs, + }) + } + + delete(key: K): boolean { + return this.cache.delete(key) + } + + clear(): void { + this.cache.clear() + } + + private cleanup(): void { + const now = Date.now() + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key) + } + } + } + + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + } + this.cache.clear() + } +} + +// Cache instances (60 second TTL) +export const searchCache = new TTLCache(60) +export const suggestionCache = new TTLCache(60) + +// ============================================================================ +// Rate Limiting +// ============================================================================ + +interface RateLimitEntry { + count: number + windowStart: number +} + +const rateLimitStore = new Map() +const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 30 // 30 requests per minute + +// Cleanup rate limit entries every 5 minutes +setInterval(() => { + const now = Date.now() + for (const [key, entry] of rateLimitStore.entries()) { + if (now - entry.windowStart > RATE_LIMIT_WINDOW_MS * 2) { + rateLimitStore.delete(key) + } + } +}, 300000) + +export function checkRateLimit(ip: string): RateLimitResult { + const now = Date.now() + const entry = rateLimitStore.get(ip) + + if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { + // New window + rateLimitStore.set(ip, { count: 1, windowStart: now }) + return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - 1 } + } + + if (entry.count >= RATE_LIMIT_MAX_REQUESTS) { + const retryAfter = Math.ceil((entry.windowStart + RATE_LIMIT_WINDOW_MS - now) / 1000) + return { allowed: false, remaining: 0, retryAfter } + } + + entry.count++ + return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - entry.count } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Extract plain text from Lexical JSONB content + */ +export function extractTextFromLexical(content: unknown): string { + if (!content || typeof content !== 'object') return '' + + const extractFromNode = (node: unknown): string => { + if (!node || typeof node !== 'object') return '' + + const n = node as Record + let text = '' + + // Get text content + if (typeof n.text === 'string') { + text += n.text + ' ' + } + + // Recursively process children + if (Array.isArray(n.children)) { + for (const child of n.children) { + text += extractFromNode(child) + } + } + + return text + } + + const c = content as Record + if (c.root && typeof c.root === 'object') { + return extractFromNode(c.root).trim() + } + + return '' +} + +/** + * Build Payload where clause for search + */ +export function buildSearchWhere( + query: string, + filters: { + tenantId?: number + categorySlug?: string + type?: string + }, +): Where { + const conditions: Where[] = [ + // Only published posts + { status: { equals: 'published' } }, + ] + + // Tenant filter + if (filters.tenantId) { + conditions.push({ tenant: { equals: filters.tenantId } }) + } + + // Category filter (requires lookup or slug field) + // Note: We'll handle this separately since category is a relationship + + // Type filter + if (filters.type) { + conditions.push({ type: { equals: filters.type } }) + } + + // Search in title and excerpt using ILIKE (case-insensitive contains) + if (query) { + conditions.push({ + or: [{ title: { contains: query } }, { excerpt: { contains: query } }], + }) + } + + return { and: conditions } +} + +/** + * Generate cache key from search parameters + */ +function generateCacheKey(prefix: string, params: Record): string { + const sortedEntries = Object.entries(params) + .filter(([, v]) => v !== undefined && v !== null) + .sort(([a], [b]) => a.localeCompare(b)) + return `${prefix}:${JSON.stringify(sortedEntries)}` +} + +// ============================================================================ +// Search Functions +// ============================================================================ + +/** + * Search posts with ILIKE (Phase 1) + * Future: Add FTS support with USE_FTS=true + */ +export async function searchPosts( + payload: Payload, + params: SearchParams, +): Promise { + const { query, tenantId, categorySlug, type, locale = 'de', limit = 10, offset = 0 } = params + + // Check cache + const cacheKey = generateCacheKey('search', { query, tenantId, categorySlug, type, locale, limit, offset }) + const cached = searchCache.get(cacheKey) + if (cached) { + return cached + } + + // Build where clause + const whereConditions: Where[] = [{ status: { equals: 'published' } }] + + if (tenantId) { + whereConditions.push({ tenant: { equals: tenantId } }) + } + + if (type) { + whereConditions.push({ type: { equals: type } }) + } + + // Search in title and excerpt + if (query && query.length >= 2) { + whereConditions.push({ + or: [{ title: { contains: query } }, { excerpt: { contains: query } }], + }) + } + + // Category filter - need to lookup category first if categorySlug provided + let categoryId: number | undefined + if (categorySlug) { + const categoryResult = await payload.find({ + collection: 'categories', + where: { + slug: { equals: categorySlug }, + ...(tenantId ? { tenant: { equals: tenantId } } : {}), + }, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + limit: 1, + }) + if (categoryResult.docs.length > 0) { + categoryId = categoryResult.docs[0].id + whereConditions.push({ category: { equals: categoryId } }) + } + } + + const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0] + + // Execute search + const result = await payload.find({ + collection: 'posts', + where, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + limit, + page: Math.floor(offset / limit) + 1, + sort: '-publishedAt', + depth: 1, // Include category relation + }) + + // Transform results + const searchResult: SearchResult = { + results: result.docs.map((post) => ({ + id: post.id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt || null, + publishedAt: post.publishedAt || null, + type: (post as Post & { type?: string }).type || 'blog', + category: + post.category && typeof post.category === 'object' + ? { name: post.category.name, slug: post.category.slug } + : null, + })), + total: result.totalDocs, + query, + filters: { + category: categorySlug, + type, + locale, + }, + pagination: { + limit, + offset, + hasMore: result.hasNextPage, + }, + } + + // Cache result + searchCache.set(cacheKey, searchResult) + + return searchResult +} + +/** + * Get search suggestions (auto-complete) + */ +export async function getSearchSuggestions( + payload: Payload, + params: SuggestionParams, +): Promise { + const { query, tenantId, categorySlug, locale = 'de', limit = 5 } = params + + if (!query || query.length < 2) { + return [] + } + + // Check cache + const cacheKey = generateCacheKey('suggestions', { query, tenantId, categorySlug, locale, limit }) + const cached = suggestionCache.get(cacheKey) + if (cached) { + return cached + } + + // Build where clause + const whereConditions: Where[] = [ + { status: { equals: 'published' } }, + // Prefix search on title + { title: { contains: query } }, + ] + + if (tenantId) { + whereConditions.push({ tenant: { equals: tenantId } }) + } + + // Category filter + if (categorySlug) { + const categoryResult = await payload.find({ + collection: 'categories', + where: { + slug: { equals: categorySlug }, + ...(tenantId ? { tenant: { equals: tenantId } } : {}), + }, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + limit: 1, + }) + if (categoryResult.docs.length > 0) { + whereConditions.push({ category: { equals: categoryResult.docs[0].id } }) + } + } + + const where: Where = { and: whereConditions } + + // Execute query + const result = await payload.find({ + collection: 'posts', + where, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + limit, + sort: '-publishedAt', + depth: 0, + }) + + // Transform to suggestions + const suggestions: Suggestion[] = result.docs.map((post) => ({ + title: post.title, + slug: post.slug, + type: (post as Post & { type?: string }).type || 'blog', + })) + + // Cache result + suggestionCache.set(cacheKey, suggestions) + + return suggestions +} + +/** + * Get posts by category with pagination + */ +export async function getPostsByCategory( + payload: Payload, + params: CategoryFilterParams, +): Promise { + const { tenantId, categorySlug, type, locale = 'de', page = 1, limit = 10 } = params + + // Build where clause + const whereConditions: Where[] = [{ status: { equals: 'published' } }] + + if (tenantId) { + whereConditions.push({ tenant: { equals: tenantId } }) + } + + if (type) { + whereConditions.push({ type: { equals: type } }) + } + + // Category filter + if (categorySlug) { + const categoryResult = await payload.find({ + collection: 'categories', + where: { + slug: { equals: categorySlug }, + ...(tenantId ? { tenant: { equals: tenantId } } : {}), + }, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + limit: 1, + }) + if (categoryResult.docs.length > 0) { + whereConditions.push({ category: { equals: categoryResult.docs[0].id } }) + } + } + + const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0] + + // Execute query + const result = await payload.find({ + collection: 'posts', + where, + locale: locale as 'de' | 'en', + fallbackLocale: 'de', + page, + limit, + sort: '-publishedAt', + depth: 1, + }) + + return { + docs: result.docs, + totalDocs: result.totalDocs, + page: result.page || page, + totalPages: result.totalPages, + hasNextPage: result.hasNextPage, + hasPrevPage: result.hasPrevPage, + } +} diff --git a/src/lib/structuredData.ts b/src/lib/structuredData.ts new file mode 100644 index 0000000..2f49bdd --- /dev/null +++ b/src/lib/structuredData.ts @@ -0,0 +1,480 @@ +/** + * Structured Data (JSON-LD) Helpers + * + * Diese Funktionen generieren Schema.org-konforme JSON-LD Daten + * für verschiedene Content-Typen. + * + * Verwendung in Next.js: + * ```tsx + * import { generateArticleSchema } from '@/lib/structuredData' + * + * export default function BlogPost({ post }) { + * return ( + * <> + *