cms.c2sgmbh/src/lib/search.ts
Martin Porwoll ce4962e74b feat: BullMQ queue system for email and PDF processing
- Add BullMQ-based job queue with Redis backend
- Implement email worker with tenant-specific SMTP support
- Add PDF worker with Playwright for HTML/URL-to-PDF generation
- Create /api/generate-pdf endpoint with job status polling
- Fix TypeScript errors in Tenants, TenantBreadcrumb, TenantDashboard
- Fix type casts in auditAuthEvents and audit-service
- Remove credentials from ecosystem.config.cjs (now loaded via dotenv)
- Fix ESM __dirname issue with fileURLToPath for PM2 compatibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 22:59:17 +00:00

676 lines
17 KiB
TypeScript

// 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<T> {
value: T
expiresAt: number
}
class TTLCache<K, V> {
private cache = new Map<K, CacheEntry<V>>()
private readonly ttlMs: number
private cleanupInterval: ReturnType<typeof setInterval> | 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<string, SearchResult>(60)
export const suggestionCache = new TTLCache<string, Suggestion[]>(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<string, unknown>
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<string, unknown>
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, unknown>): 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<SearchResult> {
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<ReturnType<typeof payload.find>>
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<Suggestion[]> {
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<PaginatedPosts> {
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,
}
}