mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
- 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>
676 lines
17 KiB
TypeScript
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,
|
|
}
|
|
}
|