// src/lib/search.ts // Shared search library with caching, rate limiting, and search functions // Supports both ILIKE (default) and PostgreSQL Full-Text Search (FTS) import type { Payload, Where } from 'payload' import type { Post, Category } from '../payload-types' import { sql } from '@payloadcms/db-postgres/drizzle' // FTS Feature Flag - aktivieren mit USE_FTS=true in .env const USE_FTS = process.env.USE_FTS === 'true' // Type für raw SQL Ergebnisse interface FtsCountRow { total: string } interface FtsIdRow { id: number rank: number } // ============================================================================ // 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 categories: Array<{ name: string slug: string }> } 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 } // ============================================================================ // 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) // ============================================================================ // 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)}` } // ============================================================================ // Full-Text Search (FTS) Functions // ============================================================================ /** * PostgreSQL FTS-Konfiguration nach Sprache */ function getFtsConfig(locale: string): string { return locale === 'en' ? 'english' : 'german' } /** * Bereitet Suchquery für FTS vor * Konvertiert "foo bar" zu "foo:* & bar:*" für Prefix-Suche */ function prepareFtsQuery(query: string): string { return query .trim() .split(/\s+/) .filter(term => term.length >= 2) .map(term => `${term}:*`) .join(' & ') } /** * Escapet einen String für sichere Verwendung in SQL * Verhindert SQL Injection bei dynamischen Werten */ function escapeSqlString(str: string): string { return str.replace(/'/g, "''") } /** * FTS-basierte Suche mit PostgreSQL to_tsvector * Verwendet die GIN-Indexes auf posts_locales */ async function searchWithFts( payload: Payload, params: SearchParams, ): Promise<{ postIds: number[]; totalCount: number }> { const { query, tenantId, type, locale = 'de', limit = 10, offset = 0 } = params if (!query || query.length < 2) { return { postIds: [], totalCount: 0 } } const ftsConfig = getFtsConfig(locale) const ftsQuery = prepareFtsQuery(query) if (!ftsQuery) { return { postIds: [], totalCount: 0 } } // Guard: Check if payload.db exists (may be missing in test mocks) if (!payload.db || !payload.db.drizzle) { console.warn('[FTS Search] payload.db.drizzle not available, returning empty results') return { postIds: [], totalCount: 0 } } // Drizzle-ORM für raw SQL Query const db = payload.db.drizzle // Escape Werte für sichere SQL-Queries const safeFtsQuery = escapeSqlString(ftsQuery) const safeLocale = escapeSqlString(locale) // Baue die SQL Query für FTS const conditions: string[] = [] // FTS-Bedingung (Title + Excerpt) conditions.push(`to_tsvector('${ftsConfig}', COALESCE(pl.title, '') || ' ' || COALESCE(pl.excerpt, '')) @@ to_tsquery('${ftsConfig}', '${safeFtsQuery}')`) // Locale-Filter conditions.push(`pl._locale = '${safeLocale}'`) // Status-Filter (nur published) conditions.push(`p.status = 'published'`) // Tenant-Filter if (tenantId && Number.isInteger(tenantId)) { conditions.push(`p.tenant_id = ${tenantId}`) } // Type-Filter if (type) { const safeType = escapeSqlString(type) conditions.push(`p.type = '${safeType}'`) } const whereClause = conditions.join(' AND ') // Count Query für Pagination const countQuery = ` SELECT COUNT(DISTINCT p.id) as total FROM posts p INNER JOIN posts_locales pl ON p.id = pl._parent_id WHERE ${whereClause} ` // Main Query mit Relevanz-Sortierung // Note: SELECT DISTINCT requires all ORDER BY columns to be in SELECT list const mainQuery = ` SELECT DISTINCT p.id, ts_rank( to_tsvector('${ftsConfig}', COALESCE(pl.title, '') || ' ' || COALESCE(pl.excerpt, '')), to_tsquery('${ftsConfig}', '${safeFtsQuery}') ) as rank, p.published_at FROM posts p INNER JOIN posts_locales pl ON p.id = pl._parent_id WHERE ${whereClause} ORDER BY rank DESC, p.published_at DESC NULLS LAST LIMIT ${Math.min(limit, 100)} OFFSET ${Math.max(0, offset)} ` try { // Execute queries mit drizzle-orm sql template const [countResult, mainResult] = await Promise.all([ db.execute(sql.raw(countQuery)), db.execute(sql.raw(mainQuery)), ]) // Extrahiere die Ergebnisse - node-postgres gibt { rows: [...] } zurück const countRows = (countResult as unknown as { rows: FtsCountRow[] }).rows || [] const totalCount = countRows.length > 0 ? parseInt(countRows[0].total, 10) : 0 const mainRows = (mainResult as unknown as { rows: FtsIdRow[] }).rows || [] const postIds = mainRows.map((row) => row.id) return { postIds, totalCount } } catch (error) { console.error('[FTS Search] SQL Error:', error) // Fallback zu leeren Ergebnissen bei Fehler return { postIds: [], totalCount: 0 } } } // ============================================================================ // Search Functions // ============================================================================ /** * Search posts with ILIKE or FTS (when USE_FTS=true) * FTS uses PostgreSQL to_tsvector for better performance and relevance */ 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, fts: USE_FTS }) const cached = searchCache.get(cacheKey) if (cached) { return cached } // Category lookup (benötigt für beide Methoden) 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 } } let result: Awaited> let totalDocs: number // Verwende FTS wenn aktiviert und Query vorhanden if (USE_FTS && query && query.length >= 2) { // FTS-basierte Suche const ftsResult = await searchWithFts(payload, { ...params, categorySlug: undefined }) if (ftsResult.postIds.length === 0) { // Keine FTS-Ergebnisse result = { docs: [], totalDocs: 0, page: 1, totalPages: 0, hasNextPage: false, hasPrevPage: false, limit, pagingCounter: 1 } totalDocs = 0 } else { // Lade Posts nach FTS-IDs (mit Category-Filter wenn nötig) const whereConditions: Where[] = [ { id: { in: ftsResult.postIds } }, ] if (categoryId) { whereConditions.push({ category: { equals: categoryId } }) } result = await payload.find({ collection: 'posts', where: whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0], locale: locale as 'de' | 'en', fallbackLocale: 'de', limit: ftsResult.postIds.length, // Alle gefundenen IDs laden sort: '-publishedAt', depth: 1, }) // Sortiere nach FTS-Reihenfolge (Relevanz) const orderedDocs = ftsResult.postIds .map(id => result.docs.find(doc => doc.id === id)) .filter((doc): doc is Post => doc !== undefined) result = { ...result, docs: orderedDocs } totalDocs = categoryId ? result.totalDocs : ftsResult.totalCount } } else { // Standard ILIKE-basierte Suche 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 mit ILIKE if (query && query.length >= 2) { whereConditions.push({ or: [{ title: { contains: query } }, { excerpt: { contains: query } }], }) } if (categoryId) { whereConditions.push({ category: { equals: categoryId } }) } const where: Where = whereConditions.length > 1 ? { and: whereConditions } : whereConditions[0] 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, }) totalDocs = result.totalDocs } // Transform results - Cast docs to Post[] for proper typing const posts = result.docs as Post[] const searchResult: SearchResult = { results: posts.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', categories: Array.isArray(post.categories) ? post.categories .filter((cat): cat is Category => cat !== null && typeof cat === 'object' && 'name' in cat) .map((cat) => ({ name: cat.name, slug: cat.slug })) : [], })), total: 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, } }