cms.c2sgmbh/src/lib/services/RulesEngine.ts
Martin Porwoll 74b251edea feat(Community): add Community Inbox View, Rules Engine, and YouTube OAuth
Community Management Phase 1 completion:
- Add Community Inbox admin view with filters, stats, and reply functionality
- Add Rules Engine service for automated interaction processing
- Add YouTube OAuth flow (auth, callback, token refresh)
- Add Comment Sync cron job (every 15 minutes)
- Add Community Export API (PDF/Excel/CSV)
- Fix database schema for community_rules hasMany fields
- Fix access control in communityAccess.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 16:26:08 +00:00

325 lines
8.8 KiB
TypeScript

/**
* Rules Engine
*
* Wertet Community-Regeln aus und wendet automatische Aktionen an.
* Wird beim Import neuer Interaktionen aufgerufen.
*/
import type { Payload } from 'payload'
interface RuleAction {
action: string
value?: string
targetUser?: number | { id: number }
targetTemplate?: number | { id: number }
}
interface RuleTrigger {
type: string
keywords?: { keyword: string; matchType: string }[]
sentimentValues?: string[]
influencerMinFollowers?: number
}
interface Rule {
id: number
name: string
priority: number
isActive: boolean
platforms?: Array<{ id: number } | number>
channel?: { id: number } | number
trigger: RuleTrigger
actions: RuleAction[]
stats?: {
timesTriggered?: number
lastTriggeredAt?: string
}
}
interface Interaction {
id: number
platform?: { id: number } | number
message?: string
author?: {
name?: string
subscriberCount?: number
isSubscriber?: boolean
}
analysis?: {
sentiment?: string
suggestedTemplate?: number
}
flags?: {
isMedicalQuestion?: boolean
requiresEscalation?: boolean
isSpam?: boolean
isFromInfluencer?: boolean
}
priority?: string
linkedContent?: { id: number } | number
}
interface EvaluationResult {
appliedRules: string[]
changes: Record<string, unknown>
}
export class RulesEngine {
private payload: Payload
constructor(payload: Payload) {
this.payload = payload
}
/**
* Wertet alle aktiven Regeln für eine Interaktion aus
*/
async evaluateRules(interactionId: number): Promise<EvaluationResult> {
const appliedRules: string[] = []
const changes: Record<string, unknown> = {}
// Interaktion laden
const interaction = (await this.payload.findByID({
collection: 'community-interactions',
id: interactionId,
depth: 2,
})) as Interaction | null
if (!interaction) {
throw new Error('Interaction not found')
}
// Aktive Regeln nach Priorität sortiert laden
const rules = await this.payload.find({
collection: 'community-rules',
where: {
isActive: { equals: true },
},
sort: 'priority',
limit: 100,
})
for (const doc of rules.docs) {
const rule = doc as unknown as Rule
// Platform-Filter prüfen
if (rule.platforms && rule.platforms.length > 0) {
const platformIds = rule.platforms.map((p) => (typeof p === 'object' ? p.id : p))
const interactionPlatformId =
typeof interaction.platform === 'object' ? interaction.platform.id : interaction.platform
if (!platformIds.includes(interactionPlatformId as number)) {
continue
}
}
// Trigger prüfen
const triggered = await this.checkTrigger(rule, interaction)
if (triggered) {
// Aktionen anwenden
const ruleChanges = await this.applyActions(rule, interaction)
Object.assign(changes, ruleChanges)
appliedRules.push(rule.name)
// Stats aktualisieren
await this.payload.update({
collection: 'community-rules',
id: rule.id,
data: {
stats: {
timesTriggered: (rule.stats?.timesTriggered || 0) + 1,
lastTriggeredAt: new Date().toISOString(),
},
},
})
}
}
// Änderungen an der Interaktion speichern
if (Object.keys(changes).length > 0) {
await this.payload.update({
collection: 'community-interactions',
id: interactionId,
data: changes,
})
}
return { appliedRules, changes }
}
/**
* Prüft ob ein Trigger zutrifft
*/
private async checkTrigger(rule: Rule, interaction: Interaction): Promise<boolean> {
const { trigger } = rule
const message = interaction.message?.toLowerCase() || ''
switch (trigger.type) {
case 'keyword':
if (!trigger.keywords?.length) return false
return trigger.keywords.some(({ keyword, matchType }) => {
const kw = keyword.toLowerCase()
switch (matchType) {
case 'exact':
return message === kw
case 'regex':
try {
return new RegExp(keyword, 'i').test(interaction.message || '')
} catch {
return false
}
case 'contains':
default:
return message.includes(kw)
}
})
case 'sentiment':
if (!trigger.sentimentValues?.length) return false
return trigger.sentimentValues.includes(interaction.analysis?.sentiment || '')
case 'influencer':
const minFollowers = trigger.influencerMinFollowers || 10000
return (interaction.author?.subscriberCount || 0) >= minFollowers
case 'medical':
return interaction.flags?.isMedicalQuestion === true
case 'new_subscriber':
return interaction.author?.isSubscriber === true
case 'negative_sentiment':
return interaction.analysis?.sentiment === 'negative'
case 'positive_sentiment':
return interaction.analysis?.sentiment === 'positive'
case 'all':
// Immer auslösen (für Default-Regeln)
return true
default:
return false
}
}
/**
* Wendet die Aktionen einer Regel an
*/
private async applyActions(
rule: Rule,
interaction: Interaction
): Promise<Record<string, unknown>> {
const changes: Record<string, unknown> = {}
for (const action of rule.actions) {
switch (action.action) {
case 'set_priority':
if (action.value) {
changes.priority = action.value
}
break
case 'assign_to':
const userId = typeof action.targetUser === 'object' ? action.targetUser.id : action.targetUser
if (userId) {
changes.assignedTo = userId
}
break
case 'apply_template':
const templateId =
typeof action.targetTemplate === 'object' ? action.targetTemplate.id : action.targetTemplate
if (templateId) {
// Nested update für analysis.suggestedTemplate
const currentAnalysis = interaction.analysis || {}
changes.analysis = {
...currentAnalysis,
suggestedTemplate: templateId,
}
}
break
case 'flag':
// Flags werden als separate Objekte aktualisiert
const currentFlags = interaction.flags || {}
if (action.value === 'medical') {
changes.flags = { ...currentFlags, isMedicalQuestion: true }
} else if (action.value === 'escalation') {
changes.flags = { ...currentFlags, requiresEscalation: true }
} else if (action.value === 'spam') {
changes.flags = { ...currentFlags, isSpam: true }
} else if (action.value === 'influencer') {
changes.flags = { ...currentFlags, isFromInfluencer: true }
}
break
case 'set_status':
if (action.value) {
changes.status = action.value
}
break
case 'notify':
await this.sendNotification(action, interaction, rule)
break
case 'auto_reply':
// Auto-Reply könnte hier implementiert werden
// Für jetzt nur loggen
console.log(`[RulesEngine] Auto-reply triggered for interaction ${interaction.id}`)
break
}
}
return changes
}
/**
* Sendet eine Benachrichtigung
*/
private async sendNotification(
action: RuleAction,
interaction: Interaction,
rule: Rule
): Promise<void> {
const userId = typeof action.targetUser === 'object' ? action.targetUser.id : action.targetUser
if (!userId) return
try {
// Prüfen ob yt-notifications Collection existiert
await this.payload.create({
collection: 'yt-notifications',
data: {
user: userId,
type: 'community_rule_triggered',
title: `🔔 Rule "${rule.name}" triggered`,
message: `New interaction from ${interaction.author?.name || 'Unknown'}: "${(interaction.message || '').substring(0, 100)}..."`,
relatedContent:
typeof interaction.linkedContent === 'object'
? interaction.linkedContent.id
: interaction.linkedContent,
priority: interaction.priority === 'urgent' ? 'urgent' : 'normal',
isRead: false,
},
})
} catch (error) {
// Collection existiert möglicherweise nicht - nur loggen, nicht crashen
console.warn('[RulesEngine] Failed to create notification:', error)
}
}
}
/**
* Singleton-Instanz für einfachen Zugriff
*/
let rulesEngineInstance: RulesEngine | null = null
export function getRulesEngine(payload: Payload): RulesEngine {
if (!rulesEngineInstance) {
rulesEngineInstance = new RulesEngine(payload)
}
return rulesEngineInstance
}