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>
This commit is contained in:
Martin Porwoll 2026-01-15 16:26:08 +00:00
parent 40f66eda35
commit 74b251edea
19 changed files with 8961 additions and 320 deletions

View file

@ -38,9 +38,11 @@
"@payloadcms/richtext-lexical": "3.69.0", "@payloadcms/richtext-lexical": "3.69.0",
"@payloadcms/translations": "3.69.0", "@payloadcms/translations": "3.69.0",
"@payloadcms/ui": "3.69.0", "@payloadcms/ui": "3.69.0",
"@types/pdfkit": "^0.17.4",
"bullmq": "^5.65.1", "bullmq": "^5.65.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "16.4.7", "dotenv": "16.4.7",
"exceljs": "^4.4.0",
"googleapis": "^170.0.0", "googleapis": "^170.0.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"next": "15.5.9", "next": "15.5.9",
@ -48,6 +50,7 @@
"nodemailer": "^7.0.11", "nodemailer": "^7.0.11",
"payload": "3.69.0", "payload": "3.69.0",
"payload-oapi": "^0.2.5", "payload-oapi": "^0.2.5",
"pdfkit": "^0.17.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"sharp": "0.34.5" "sharp": "0.34.5"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,158 @@
/**
* Seed Community Rules
*
* Erstellt Beispiel-Regeln für das Community Management System.
*
* Usage: npx tsx scripts/seed-community-rules.ts
*/
import { getPayload } from 'payload'
import config from '../src/payload.config'
async function seedRules() {
const payload = await getPayload({ config })
const rules = [
{
name: 'Medizinische Fragen → Priorität hoch',
description: 'Erkennt medizinische Fragen anhand von Keywords und setzt hohe Priorität',
priority: 10,
isActive: true,
trigger: {
type: 'keyword',
keywords: [
{ keyword: 'arzt', matchType: 'contains' },
{ keyword: 'krankheit', matchType: 'contains' },
{ keyword: 'nebenwirkung', matchType: 'contains' },
{ keyword: 'medikament', matchType: 'contains' },
{ keyword: 'schmerzen', matchType: 'contains' },
{ keyword: 'symptom', matchType: 'contains' },
{ keyword: 'diagnose', matchType: 'contains' },
{ keyword: 'behandlung', matchType: 'contains' },
],
},
actions: [
{ action: 'set_priority', value: 'high' },
{ action: 'flag_medical' },
],
},
{
name: 'Negative Kommentare → Eskalation',
description: 'Eskaliert negative Kommentare automatisch',
priority: 20,
isActive: true,
trigger: {
type: 'sentiment',
sentimentValues: ['negative'],
},
actions: [
{ action: 'set_priority', value: 'high' },
{ action: 'escalate' },
],
},
{
name: 'Influencer → Priorität urgent',
description: 'Priorisiert Kommentare von Accounts mit 10k+ Followern',
priority: 5,
isActive: true,
trigger: {
type: 'influencer',
influencerMinFollowers: 10000,
},
actions: [{ action: 'set_priority', value: 'urgent' }],
},
{
name: 'Spam-Erkennung',
description: 'Markiert verdächtige Kommentare als Spam',
priority: 1,
isActive: true,
trigger: {
type: 'keyword',
keywords: [
{ keyword: 'gratis gewinn', matchType: 'contains' },
{ keyword: 'gewonnen', matchType: 'contains' },
{ keyword: 'link in bio', matchType: 'contains' },
{ keyword: 'check my profile', matchType: 'contains' },
{ keyword: 'dm for', matchType: 'contains' },
{ keyword: 'crypto', matchType: 'contains' },
{ keyword: 'bitcoin', matchType: 'contains' },
{ keyword: 'nft', matchType: 'contains' },
],
},
actions: [{ action: 'mark_spam' }],
},
{
name: 'Support-Anfragen',
description: 'Erkennt Support-Anfragen und setzt normale Priorität',
priority: 50,
isActive: true,
trigger: {
type: 'keyword',
keywords: [
{ keyword: 'hilfe', matchType: 'contains' },
{ keyword: 'problem', matchType: 'contains' },
{ keyword: 'funktioniert nicht', matchType: 'contains' },
{ keyword: 'fehler', matchType: 'contains' },
{ keyword: 'wie kann ich', matchType: 'contains' },
{ keyword: 'frage', matchType: 'contains' },
],
},
actions: [{ action: 'set_priority', value: 'normal' }],
},
{
name: 'Positive Feedback',
description: 'Markiert positives Feedback für spätere Testimonials',
priority: 100,
isActive: true,
trigger: {
type: 'sentiment',
sentimentValues: ['positive'],
},
actions: [{ action: 'set_priority', value: 'low' }],
},
]
console.log('🔧 Creating community rules...\n')
let created = 0
let failed = 0
for (const rule of rules) {
try {
// Prüfen ob Regel bereits existiert
const existing = await payload.find({
collection: 'community-rules',
where: { name: { equals: rule.name } },
limit: 1,
})
if (existing.docs.length > 0) {
console.log(`⏭️ Skipped (exists): ${rule.name}`)
continue
}
await payload.create({
collection: 'community-rules',
data: rule,
})
console.log(`✅ Created: ${rule.name}`)
created++
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`❌ Failed: ${rule.name} - ${msg}`)
failed++
}
}
console.log('\n' + '='.repeat(50))
console.log(`📊 Summary: ${created} created, ${failed} failed`)
console.log('='.repeat(50))
console.log('\n👉 Check: /admin/collections/community-rules\n')
process.exit(0)
}
seedRules().catch((err) => {
console.error('Fatal error:', err)
process.exit(1)
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
import React from 'react'
import { DefaultTemplate } from '@payloadcms/next/templates'
import { Gutter } from '@payloadcms/ui'
import { CommunityInbox } from './CommunityInbox'
import './inbox.scss'
export const metadata = {
title: 'Community Inbox',
description: 'Manage community interactions across all platforms',
}
export default function CommunityInboxPage() {
return (
<DefaultTemplate>
<Gutter>
<CommunityInbox />
</Gutter>
</DefaultTemplate>
)
}

View file

@ -0,0 +1,558 @@
/**
* Community Export API
*
* Exportiert Community-Interaktionen in verschiedenen Formaten.
* Unterstützt Excel, CSV und PDF.
*
* GET /api/community/export?format=excel&dateFrom=...&dateTo=...
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import ExcelJS from 'exceljs'
import PDFDocument from 'pdfkit'
import { createSafeLogger, validateCsrf } from '@/lib/security'
const logger = createSafeLogger('API:CommunityExport')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
/**
* Prüft ob User Community-Zugriff hat
*/
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) {
return true
}
const communityRoles = ['community_manager', 'community_agent', 'admin']
if (user.roles?.some((role) => communityRoles.includes(role))) {
return true
}
return false
}
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams
const format = searchParams.get('format') || 'excel'
const dateFrom = searchParams.get('dateFrom')
const dateTo = searchParams.get('dateTo')
const status = searchParams.get('status')?.split(',').filter(Boolean)
const platform = searchParams.get('platform')?.split(',').filter(Boolean)
const sentiment = searchParams.get('sentiment')?.split(',').filter(Boolean)
const channel = searchParams.get('channel')?.split(',').filter(Boolean)
const payload = await getPayload({ config })
// Authentifizierung prüfen
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
logger.warn('Unauthorized export attempt')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithCommunityAccess
// Community-Zugriff prüfen
if (!hasCommunityAccess(typedUser)) {
logger.warn('Access denied for export', { userId: typedUser.id })
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Build query
const where: Record<string, unknown> = {}
if (dateFrom || dateTo) {
where.publishedAt = {}
if (dateFrom) (where.publishedAt as Record<string, string>).greater_than_equal = dateFrom
if (dateTo) (where.publishedAt as Record<string, string>).less_than_equal = dateTo
}
if (status?.length) {
where.status = { in: status }
}
if (platform?.length) {
where['platform.slug'] = { in: platform }
}
if (sentiment?.length) {
where['analysis.sentiment'] = { in: sentiment }
}
if (channel?.length) {
where['socialAccount'] = { in: channel.map((c) => parseInt(c, 10)).filter((n) => !isNaN(n)) }
}
// Fetch interactions
const interactions = await payload.find({
collection: 'community-interactions',
where,
sort: '-publishedAt',
limit: 1000,
depth: 2,
})
logger.info('Export generated', {
format,
count: interactions.docs.length,
userId: typedUser.id,
})
// Generate export based on format
switch (format) {
case 'excel':
return generateExcel(interactions.docs)
case 'csv':
return generateCSV(interactions.docs)
case 'pdf':
return generatePDF(interactions.docs)
default:
return NextResponse.json({ error: 'Invalid format' }, { status: 400 })
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Export error:', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
// Excel Export
async function generateExcel(interactions: unknown[]): Promise<NextResponse> {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Community Hub'
workbook.created = new Date()
// Main Data Sheet
const dataSheet = workbook.addWorksheet('Interactions', {
views: [{ state: 'frozen', ySplit: 1 }],
})
dataSheet.columns = [
{ header: 'ID', key: 'id', width: 8 },
{ header: 'Datum', key: 'date', width: 12 },
{ header: 'Plattform', key: 'platform', width: 12 },
{ header: 'Kanal', key: 'channel', width: 20 },
{ header: 'Autor', key: 'author', width: 20 },
{ header: 'Follower', key: 'followers', width: 12 },
{ header: 'Nachricht', key: 'message', width: 50 },
{ header: 'Sentiment', key: 'sentiment', width: 12 },
{ header: 'Score', key: 'score', width: 8 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Priorität', key: 'priority', width: 10 },
{ header: 'Medical', key: 'medical', width: 8 },
{ header: 'Eskalation', key: 'escalation', width: 10 },
{ header: 'Influencer', key: 'influencer', width: 10 },
{ header: 'Zugewiesen', key: 'assigned', width: 20 },
{ header: 'Beantwortet', key: 'repliedAt', width: 12 },
{ header: 'Antwort', key: 'response', width: 50 },
{ header: 'Likes', key: 'likes', width: 8 },
{ header: 'Replies', key: 'replies', width: 8 },
]
// Style header row
dataSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
dataSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF3B82F6' },
}
// Add data rows
for (const item of interactions) {
const interaction = item as Record<string, unknown>
const platform = interaction.platform as Record<string, unknown> | undefined
const socialAccount = interaction.socialAccount as Record<string, unknown> | undefined
const author = interaction.author as Record<string, unknown> | undefined
const analysis = interaction.analysis as Record<string, unknown> | undefined
const flags = interaction.flags as Record<string, unknown> | undefined
const assignedTo = interaction.assignedTo as Record<string, unknown> | undefined
const response = interaction.response as Record<string, unknown> | undefined
const engagement = interaction.engagement as Record<string, unknown> | undefined
dataSheet.addRow({
id: interaction.id,
date: new Date(interaction.publishedAt as string).toLocaleDateString('de-DE'),
platform: platform?.name || '',
channel: socialAccount?.displayName || '',
author: author?.name || '',
followers: author?.subscriberCount || 0,
message: ((interaction.message as string) || '').substring(0, 500),
sentiment: analysis?.sentiment || '',
score: analysis?.sentimentScore || 0,
status: getStatusLabel(interaction.status as string),
priority: getPriorityLabel(interaction.priority as string),
medical: flags?.isMedicalQuestion ? 'Ja' : '',
escalation: flags?.requiresEscalation ? 'Ja' : '',
influencer: flags?.isFromInfluencer ? 'Ja' : '',
assigned: (assignedTo as Record<string, unknown>)?.email || '',
repliedAt: response?.sentAt
? new Date(response.sentAt as string).toLocaleDateString('de-DE')
: '',
response: ((response?.text as string) || '').substring(0, 500),
likes: engagement?.likes || 0,
replies: engagement?.replies || 0,
})
}
// Conditional formatting for priority
dataSheet.eachRow((row, rowNumber) => {
if (rowNumber > 1) {
const item = interactions[rowNumber - 2] as Record<string, unknown>
const priority = item?.priority
if (priority === 'urgent') {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFEF2F2' },
}
}
}
})
// Summary Sheet
const summarySheet = workbook.addWorksheet('Zusammenfassung')
const sentimentCounts = {
positive: interactions.filter(
(i) => (i as Record<string, unknown>).analysis &&
((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'positive'
).length,
neutral: interactions.filter(
(i) => (i as Record<string, unknown>).analysis &&
((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'neutral'
).length,
negative: interactions.filter(
(i) => (i as Record<string, unknown>).analysis &&
((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'negative'
).length,
mixed: interactions.filter(
(i) => (i as Record<string, unknown>).analysis &&
((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'mixed'
).length,
}
const statusCounts = {
new: interactions.filter((i) => (i as Record<string, unknown>).status === 'new').length,
in_review: interactions.filter((i) => (i as Record<string, unknown>).status === 'in_review').length,
waiting: interactions.filter((i) => (i as Record<string, unknown>).status === 'waiting').length,
replied: interactions.filter((i) => (i as Record<string, unknown>).status === 'replied').length,
resolved: interactions.filter((i) => (i as Record<string, unknown>).status === 'resolved').length,
archived: interactions.filter((i) => (i as Record<string, unknown>).status === 'archived').length,
spam: interactions.filter((i) => (i as Record<string, unknown>).status === 'spam').length,
}
summarySheet.addRow(['Community Report'])
summarySheet.addRow(['Erstellt am', new Date().toLocaleString('de-DE')])
if (interactions.length > 0) {
const firstItem = interactions[0] as Record<string, unknown>
const lastItem = interactions[interactions.length - 1] as Record<string, unknown>
summarySheet.addRow([
'Zeitraum',
`${new Date(lastItem.publishedAt as string).toLocaleDateString('de-DE')} - ${new Date(firstItem.publishedAt as string).toLocaleDateString('de-DE')}`,
])
} else {
summarySheet.addRow(['Zeitraum', 'N/A'])
}
summarySheet.addRow([])
summarySheet.addRow(['Gesamt Interaktionen', interactions.length])
summarySheet.addRow([])
summarySheet.addRow(['Sentiment Verteilung'])
summarySheet.addRow(['Positiv', sentimentCounts.positive])
summarySheet.addRow(['Neutral', sentimentCounts.neutral])
summarySheet.addRow(['Negativ', sentimentCounts.negative])
summarySheet.addRow(['Gemischt', sentimentCounts.mixed])
summarySheet.addRow([])
summarySheet.addRow(['Status Verteilung'])
summarySheet.addRow(['Neu', statusCounts.new])
summarySheet.addRow(['In Review', statusCounts.in_review])
summarySheet.addRow(['Warten auf Info', statusCounts.waiting])
summarySheet.addRow(['Beantwortet', statusCounts.replied])
summarySheet.addRow(['Erledigt', statusCounts.resolved])
summarySheet.addRow(['Archiviert', statusCounts.archived])
summarySheet.addRow(['Spam', statusCounts.spam])
summarySheet.addRow([])
summarySheet.addRow(['Flags'])
summarySheet.addRow([
'Medizinische Fragen',
interactions.filter(
(i) => (i as Record<string, unknown>).flags &&
((i as Record<string, unknown>).flags as Record<string, unknown>)?.isMedicalQuestion
).length,
])
summarySheet.addRow([
'Eskalationen',
interactions.filter(
(i) => (i as Record<string, unknown>).flags &&
((i as Record<string, unknown>).flags as Record<string, unknown>)?.requiresEscalation
).length,
])
summarySheet.addRow([
'Influencer',
interactions.filter(
(i) => (i as Record<string, unknown>).flags &&
((i as Record<string, unknown>).flags as Record<string, unknown>)?.isFromInfluencer
).length,
])
// Style summary
summarySheet.getRow(1).font = { bold: true, size: 16 }
summarySheet.getColumn(1).width = 25
summarySheet.getColumn(2).width = 20
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer()
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.xlsx"`,
},
})
}
// CSV Export
async function generateCSV(interactions: unknown[]): Promise<NextResponse> {
const headers = [
'ID',
'Datum',
'Plattform',
'Kanal',
'Autor',
'Follower',
'Nachricht',
'Sentiment',
'Score',
'Status',
'Priorität',
'Medical',
'Eskalation',
'Influencer',
'Zugewiesen',
'Beantwortet',
'Antwort',
'Likes',
'Replies',
]
const rows = interactions.map((item) => {
const interaction = item as Record<string, unknown>
const platform = interaction.platform as Record<string, unknown> | undefined
const socialAccount = interaction.socialAccount as Record<string, unknown> | undefined
const author = interaction.author as Record<string, unknown> | undefined
const analysis = interaction.analysis as Record<string, unknown> | undefined
const flags = interaction.flags as Record<string, unknown> | undefined
const assignedTo = interaction.assignedTo as Record<string, unknown> | undefined
const response = interaction.response as Record<string, unknown> | undefined
const engagement = interaction.engagement as Record<string, unknown> | undefined
return [
interaction.id,
new Date(interaction.publishedAt as string).toISOString(),
platform?.name || '',
socialAccount?.displayName || '',
author?.name || '',
author?.subscriberCount || 0,
`"${((interaction.message as string) || '').replace(/"/g, '""').substring(0, 500)}"`,
analysis?.sentiment || '',
analysis?.sentimentScore || 0,
interaction.status,
interaction.priority,
flags?.isMedicalQuestion ? 'Ja' : '',
flags?.requiresEscalation ? 'Ja' : '',
flags?.isFromInfluencer ? 'Ja' : '',
(assignedTo as Record<string, unknown>)?.email || '',
response?.sentAt || '',
`"${((response?.text as string) || '').replace(/"/g, '""').substring(0, 500)}"`,
engagement?.likes || 0,
engagement?.replies || 0,
]
})
const csv = [headers.join(';'), ...rows.map((row) => row.join(';'))].join('\n')
// Add BOM for Excel compatibility with German characters
const bom = '\uFEFF'
return new NextResponse(bom + csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.csv"`,
},
})
}
// PDF Export
async function generatePDF(interactions: unknown[]): Promise<NextResponse> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
margin: 50,
size: 'A4',
})
const chunks: Buffer[] = []
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
doc.on('end', () => {
const buffer = Buffer.concat(chunks)
resolve(
new NextResponse(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="community-report-${new Date().toISOString().split('T')[0]}.pdf"`,
},
})
)
})
// Title
doc.fontSize(24).text('Community Report', { align: 'center' })
doc.moveDown()
doc.fontSize(12).text(`Erstellt am: ${new Date().toLocaleString('de-DE')}`, { align: 'center' })
doc.moveDown(2)
// Summary Stats
const sentimentCounts = {
positive: interactions.filter(
(i) => ((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'positive'
).length,
negative: interactions.filter(
(i) => ((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'negative'
).length,
neutral: interactions.filter(
(i) => ((i as Record<string, unknown>).analysis as Record<string, unknown>)?.sentiment === 'neutral'
).length,
}
const statusCounts = {
new: interactions.filter((i) => (i as Record<string, unknown>).status === 'new').length,
replied: interactions.filter((i) => (i as Record<string, unknown>).status === 'replied').length,
resolved: interactions.filter((i) => (i as Record<string, unknown>).status === 'resolved').length,
}
doc.fontSize(16).text('Übersicht', { underline: true })
doc.moveDown()
doc.fontSize(12)
doc.text(`Gesamt Interaktionen: ${interactions.length}`)
doc.text(`Davon neu: ${statusCounts.new}`)
doc.text(`Davon beantwortet: ${statusCounts.replied}`)
doc.text(`Davon erledigt: ${statusCounts.resolved}`)
doc.moveDown()
doc.text(`Positives Sentiment: ${sentimentCounts.positive}`)
doc.text(`Neutrales Sentiment: ${sentimentCounts.neutral}`)
doc.text(`Negatives Sentiment: ${sentimentCounts.negative}`)
doc.moveDown()
// Flags
const medicalCount = interactions.filter(
(i) => ((i as Record<string, unknown>).flags as Record<string, unknown>)?.isMedicalQuestion
).length
const escalationCount = interactions.filter(
(i) => ((i as Record<string, unknown>).flags as Record<string, unknown>)?.requiresEscalation
).length
const influencerCount = interactions.filter(
(i) => ((i as Record<string, unknown>).flags as Record<string, unknown>)?.isFromInfluencer
).length
doc.text(`Medizinische Fragen: ${medicalCount}`)
doc.text(`Eskalationen: ${escalationCount}`)
doc.text(`Von Influencern: ${influencerCount}`)
doc.moveDown(2)
// Recent interactions
doc.fontSize(16).text('Letzte 20 Interaktionen', { underline: true })
doc.moveDown()
const recentInteractions = interactions.slice(0, 20)
for (const item of recentInteractions) {
const interaction = item as Record<string, unknown>
const platform = interaction.platform as Record<string, unknown> | undefined
const author = interaction.author as Record<string, unknown> | undefined
const analysis = interaction.analysis as Record<string, unknown> | undefined
const flags = interaction.flags as Record<string, unknown> | undefined
// Check if we need a new page
if (doc.y > 700) {
doc.addPage()
}
doc
.fontSize(10)
.fillColor('#6b7280')
.text(
`${new Date(interaction.publishedAt as string).toLocaleDateString('de-DE')} | ${platform?.name || 'Unknown'} | ${author?.name || 'Unknown'}`,
{ continued: false }
)
const message = (interaction.message as string) || ''
doc
.fontSize(11)
.fillColor('#000000')
.text(message.substring(0, 200) + (message.length > 200 ? '...' : ''), { continued: false })
const badges: string[] = []
if (analysis?.sentiment) badges.push(analysis.sentiment as string)
if (flags?.isMedicalQuestion) badges.push('Medical')
if (flags?.requiresEscalation) badges.push('Eskalation')
doc
.fontSize(9)
.fillColor('#3b82f6')
.text(`Status: ${getStatusLabel(interaction.status as string)} | ${badges.join(' | ')}`)
doc.moveDown()
}
// Footer
const pageCount = doc.bufferedPageRange().count
for (let i = 0; i < pageCount; i++) {
doc.switchToPage(i)
doc
.fontSize(8)
.fillColor('#9ca3af')
.text(`Seite ${i + 1} von ${pageCount} | Community Hub Report`, 50, doc.page.height - 50, {
align: 'center',
})
}
doc.end()
} catch (error) {
reject(error)
}
})
}
// Helper functions
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
new: 'Neu',
in_review: 'In Review',
waiting: 'Warten',
replied: 'Beantwortet',
resolved: 'Erledigt',
archived: 'Archiviert',
spam: 'Spam',
}
return labels[status] || status
}
function getPriorityLabel(priority: string): string {
const labels: Record<string, string> = {
urgent: 'Dringend',
high: 'Hoch',
normal: 'Normal',
low: 'Niedrig',
}
return labels[priority] || priority
}

View file

@ -0,0 +1,146 @@
/**
* Community Stats API
*
* Liefert Statistiken für das Community Inbox Dashboard.
*
* GET /api/community/stats
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:CommunityStats')
interface UserWithCommunityAccess {
id: number
isSuperAdmin?: boolean
is_super_admin?: boolean
roles?: string[]
}
/**
* Prüft ob User Community-Zugriff hat
*/
function hasCommunityAccess(user: UserWithCommunityAccess): boolean {
if (user.isSuperAdmin || user.is_super_admin) {
return true
}
const communityRoles = ['community_manager', 'community_agent', 'admin']
if (user.roles?.some((role) => communityRoles.includes(role))) {
return true
}
return false
}
export async function GET(req: NextRequest) {
try {
const payload = await getPayload({ config })
// Authentifizierung prüfen
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const typedUser = user as UserWithCommunityAccess
// Community-Zugriff prüfen
if (!hasCommunityAccess(typedUser)) {
logger.warn('Access denied for stats', { userId: typedUser.id })
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Parallele Abfragen für bessere Performance
const [newCount, urgentCount, waitingCount, todayCount, sentimentStats] = await Promise.all([
// Neue Interaktionen
payload.count({
collection: 'community-interactions',
where: { status: { equals: 'new' } },
}),
// Dringende Interaktionen
payload.count({
collection: 'community-interactions',
where: {
and: [
{ status: { not_equals: 'resolved' } },
{ status: { not_equals: 'archived' } },
{ status: { not_equals: 'spam' } },
{ priority: { equals: 'urgent' } },
],
},
}),
// Wartende Interaktionen
payload.count({
collection: 'community-interactions',
where: { status: { equals: 'waiting' } },
}),
// Heute eingegangen
payload.count({
collection: 'community-interactions',
where: {
createdAt: {
greater_than_equal: new Date(new Date().setHours(0, 0, 0, 0)).toISOString(),
},
},
}),
// Sentiment-Verteilung (nur unerledigte)
Promise.all([
payload.count({
collection: 'community-interactions',
where: {
and: [
{ status: { not_equals: 'resolved' } },
{ status: { not_equals: 'archived' } },
{ 'analysis.sentiment': { equals: 'positive' } },
],
},
}),
payload.count({
collection: 'community-interactions',
where: {
and: [
{ status: { not_equals: 'resolved' } },
{ status: { not_equals: 'archived' } },
{ 'analysis.sentiment': { equals: 'neutral' } },
],
},
}),
payload.count({
collection: 'community-interactions',
where: {
and: [
{ status: { not_equals: 'resolved' } },
{ status: { not_equals: 'archived' } },
{ 'analysis.sentiment': { equals: 'negative' } },
],
},
}),
]),
])
const stats = {
new: newCount.totalDocs,
urgent: urgentCount.totalDocs,
waiting: waitingCount.totalDocs,
today: todayCount.totalDocs,
sentiment: {
positive: sentimentStats[0].totalDocs,
neutral: sentimentStats[1].totalDocs,
negative: sentimentStats[2].totalDocs,
},
}
return NextResponse.json(stats)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Stats error:', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,75 @@
/**
* YouTube OAuth Auth Initiation
*
* Startet den OAuth Flow für einen Social Account.
*
* GET /api/youtube/auth?socialAccountId=123
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getAuthUrl } from '@/lib/integrations/youtube/oauth'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:YouTubeAuth')
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams
const socialAccountId = searchParams.get('socialAccountId')
if (!socialAccountId) {
return NextResponse.json(
{ error: 'socialAccountId query parameter required' },
{ status: 400 }
)
}
const payload = await getPayload({ config })
// Authentifizierung prüfen
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/login?redirect=/api/youtube/auth?socialAccountId=${socialAccountId}`
)
}
// Prüfen ob der Social Account existiert
const account = await payload.findByID({
collection: 'social-accounts',
id: parseInt(socialAccountId, 10),
})
if (!account) {
return NextResponse.json({ error: 'Social account not found' }, { status: 404 })
}
// State mit Account-ID erstellen (Base64 encoded)
const state = Buffer.from(
JSON.stringify({
socialAccountId,
userId: (user as { id: number }).id,
timestamp: Date.now(),
})
).toString('base64')
const authUrl = getAuthUrl(state)
logger.info('YouTube OAuth initiated', {
socialAccountId,
userId: (user as { id: number }).id,
})
return NextResponse.redirect(authUrl)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('YouTube auth error:', { error: errorMessage })
return NextResponse.redirect(
`${process.env.NEXT_PUBLIC_SERVER_URL}/admin/collections/social-accounts?error=${encodeURIComponent(errorMessage)}`
)
}
}

View file

@ -0,0 +1,127 @@
/**
* YouTube OAuth Callback
*
* Verarbeitet den OAuth Callback von Google und speichert die Tokens.
*
* GET /api/youtube/callback?code=...&state=...
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getTokensFromCode, getAuthenticatedChannel } from '@/lib/integrations/youtube/oauth'
import { createSafeLogger } from '@/lib/security'
const logger = createSafeLogger('API:YouTubeCallback')
interface StatePayload {
socialAccountId: string
userId: number
timestamp: number
}
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
// Fehler von Google
if (error) {
logger.error('OAuth error from Google:', { error, errorDescription })
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts?error=${encodeURIComponent(errorDescription || error)}`
)
}
// Fehlende Parameter
if (!code || !state) {
logger.warn('Missing OAuth parameters')
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts?error=missing_params`
)
}
try {
// State decodieren
let statePayload: StatePayload
try {
statePayload = JSON.parse(Buffer.from(state, 'base64').toString())
} catch {
logger.error('Invalid state parameter')
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts?error=invalid_state`
)
}
const { socialAccountId } = statePayload
// State-Alter prüfen (max 10 Minuten)
const stateAge = Date.now() - statePayload.timestamp
if (stateAge > 10 * 60 * 1000) {
logger.warn('State expired', { age: stateAge })
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts?error=state_expired`
)
}
// Tokens von Google abrufen
const tokens = await getTokensFromCode(code)
if (!tokens.access_token) {
throw new Error('No access token received from Google')
}
// Kanal-Informationen abrufen
const channel = await getAuthenticatedChannel(tokens.access_token)
if (!channel) {
throw new Error('Could not retrieve channel information')
}
const payload = await getPayload({ config })
// Social Account aktualisieren
await payload.update({
collection: 'social-accounts',
id: parseInt(socialAccountId, 10),
data: {
externalId: channel.id,
accountHandle: channel.snippet?.customUrl || `@${channel.snippet?.title}`,
accountUrl: `https://www.youtube.com/channel/${channel.id}`,
credentials: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || undefined,
tokenExpiresAt: tokens.expiry_date
? new Date(tokens.expiry_date).toISOString()
: undefined,
},
stats: {
followers: parseInt(channel.statistics?.subscriberCount || '0', 10),
totalPosts: parseInt(channel.statistics?.videoCount || '0', 10),
lastSyncedAt: new Date().toISOString(),
},
},
})
logger.info('YouTube OAuth completed', {
socialAccountId,
channelId: channel.id,
channelTitle: channel.snippet?.title,
})
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts/${socialAccountId}?success=connected`
)
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
logger.error('OAuth callback error:', { error: errorMessage })
return NextResponse.redirect(
`${baseUrl}/admin/collections/social-accounts?error=${encodeURIComponent(errorMessage)}`
)
}
}

View file

@ -0,0 +1,115 @@
/**
* YouTube Token Refresh API
*
* Erneuert den Access Token für einen Social Account.
*
* POST /api/youtube/refresh-token
* Body: { socialAccountId: number }
*/
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { refreshAccessToken } from '@/lib/integrations/youtube/oauth'
import { createSafeLogger, validateCsrf } from '@/lib/security'
const logger = createSafeLogger('API:YouTubeRefreshToken')
interface SocialAccountCredentials {
accessToken?: string
refreshToken?: string
tokenExpiresAt?: string
}
interface SocialAccount {
id: number
displayName?: string
credentials?: SocialAccountCredentials
}
export async function POST(req: NextRequest) {
try {
// CSRF-Schutz
const csrfResult = validateCsrf(req)
if (!csrfResult.valid) {
logger.warn('CSRF validation failed', { reason: csrfResult.reason })
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 })
}
const payload = await getPayload({ config })
// Authentifizierung prüfen
const { user } = await payload.auth({ headers: req.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { socialAccountId } = body
if (!socialAccountId) {
return NextResponse.json({ error: 'socialAccountId required' }, { status: 400 })
}
// Account laden
const account = (await payload.findByID({
collection: 'social-accounts',
id: socialAccountId,
})) as SocialAccount | null
if (!account) {
return NextResponse.json({ error: 'Social account not found' }, { status: 404 })
}
if (!account.credentials?.refreshToken) {
return NextResponse.json(
{ error: 'No refresh token available. Re-authenticate with YouTube.' },
{ status: 400 }
)
}
// Token erneuern
const credentials = await refreshAccessToken(account.credentials.refreshToken)
// Account aktualisieren
await payload.update({
collection: 'social-accounts',
id: socialAccountId,
data: {
credentials: {
accessToken: credentials.access_token || undefined,
refreshToken: credentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: credentials.expiry_date
? new Date(credentials.expiry_date).toISOString()
: undefined,
},
},
})
logger.info('Token refreshed', {
socialAccountId,
displayName: account.displayName,
})
return NextResponse.json({
success: true,
expiresAt: credentials.expiry_date
? new Date(credentials.expiry_date).toISOString()
: null,
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
logger.error('Token refresh error:', { error: errorMessage })
// Bei bestimmten Fehlern genauere Meldung
if (errorMessage.includes('invalid_grant')) {
return NextResponse.json(
{ error: 'Refresh token invalid or revoked. Re-authenticate with YouTube.' },
{ status: 400 }
)
}
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}

View file

@ -546,6 +546,21 @@ export const CommunityInteractions: CollectionConfig = {
console.log(`🚨 Urgent interaction: ${doc.id}`) console.log(`🚨 Urgent interaction: ${doc.id}`)
} }
}, },
// Run Rules Engine on new interactions
async ({ doc, operation, req }) => {
if (operation === 'create') {
try {
const { RulesEngine } = await import('@/lib/services/RulesEngine')
const rulesEngine = new RulesEngine(req.payload)
const result = await rulesEngine.evaluateRules(doc.id)
if (result.appliedRules.length > 0) {
console.log(`[RulesEngine] Applied ${result.appliedRules.length} rules to interaction ${doc.id}:`, result.appliedRules)
}
} catch (error) {
console.error('[RulesEngine] Error evaluating rules:', error)
}
}
},
], ],
}, },
timestamps: true, timestamps: true,

View file

@ -1,6 +1,7 @@
import cron from 'node-cron' import cron from 'node-cron'
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { runConsentRetentionJob } from './consentRetentionJob' import { runConsentRetentionJob } from './consentRetentionJob'
import { runSyncAllCommentsJob } from './syncAllComments'
/** /**
* Initialisiert alle Scheduled Jobs * Initialisiert alle Scheduled Jobs
@ -22,5 +23,24 @@ export const initScheduledJobs = (payload: Payload): void => {
}, },
) )
console.log('[Scheduler] Scheduled Jobs initialisiert.') // Comment Sync: Alle 15 Minuten
const syncInterval = process.env.COMMUNITY_SYNC_CRON || '*/15 * * * *'
cron.schedule(
syncInterval,
async () => {
console.log('[Scheduler] Starte Comment Sync Job...')
try {
await runSyncAllCommentsJob(payload)
} catch (error) {
console.error('[Scheduler] Comment Sync Job fehlgeschlagen:', error)
}
},
{
timezone: 'Europe/Berlin',
},
)
console.log('[Scheduler] Scheduled Jobs initialisiert.')
console.log(` - Consent Retention: Täglich um 03:00`)
console.log(` - Comment Sync: ${syncInterval}`)
} }

207
src/jobs/syncAllComments.ts Normal file
View file

@ -0,0 +1,207 @@
/**
* Sync All Comments Job
*
* Synchronisiert Kommentare für alle aktiven YouTube-Accounts.
* Kann als Cron-Job oder manuell ausgeführt werden.
*/
import { getPayload, Payload } from 'payload'
import config from '@payload-config'
import { refreshAccessToken, isTokenExpired } from '@/lib/integrations/youtube/oauth'
interface SocialAccount {
id: number
displayName?: string
isActive?: boolean
externalId?: string
credentials?: {
accessToken?: string
refreshToken?: string
tokenExpiresAt?: string
}
syncSettings?: {
autoSyncEnabled?: boolean
syncIntervalMinutes?: number
syncComments?: boolean
}
stats?: {
lastSyncedAt?: string
}
}
interface SyncResult {
accountId: number
displayName: string
success: boolean
created: number
updated: number
errors: string[]
}
/**
* Führt den Comment-Sync für alle aktiven Accounts durch
*/
export async function runSyncAllCommentsJob(payload: Payload): Promise<{
totalAccounts: number
successfulSyncs: number
totalNew: number
totalUpdated: number
errors: string[]
}> {
console.log('🔄 Starting scheduled comment sync...')
console.log(` Time: ${new Date().toISOString()}`)
// Aktive Accounts mit Auto-Sync laden
const accounts = await payload.find({
collection: 'social-accounts',
where: {
and: [
{ isActive: { equals: true } },
{ 'syncSettings.autoSyncEnabled': { equals: true } },
{ 'credentials.accessToken': { exists: true } },
],
},
limit: 100,
depth: 2,
})
console.log(`📊 Found ${accounts.docs.length} accounts to sync`)
let totalNew = 0
let totalUpdated = 0
let successfulSyncs = 0
const errors: string[] = []
const results: SyncResult[] = []
for (const doc of accounts.docs) {
const account = doc as unknown as SocialAccount
try {
console.log(`\n📺 Syncing: ${account.displayName || account.id}`)
// Token-Ablauf prüfen und ggf. erneuern
if (isTokenExpired(account.credentials?.tokenExpiresAt)) {
console.log(' ⏰ Token expired, refreshing...')
if (!account.credentials?.refreshToken) {
console.log(' ❌ No refresh token available')
errors.push(`${account.displayName}: No refresh token available`)
continue
}
try {
const newCredentials = await refreshAccessToken(account.credentials.refreshToken)
await payload.update({
collection: 'social-accounts',
id: account.id,
data: {
credentials: {
accessToken: newCredentials.access_token || undefined,
refreshToken: newCredentials.refresh_token || account.credentials.refreshToken,
tokenExpiresAt: newCredentials.expiry_date
? new Date(newCredentials.expiry_date).toISOString()
: undefined,
},
},
})
console.log(' ✅ Token refreshed')
} catch (refreshError) {
const msg = refreshError instanceof Error ? refreshError.message : 'Unknown error'
console.error(' ❌ Token refresh failed:', msg)
errors.push(`${account.displayName}: Token refresh failed - ${msg}`)
continue
}
}
// CommentsSyncService dynamisch importieren
const { CommentsSyncService } = await import(
'@/lib/integrations/youtube/CommentsSyncService'
)
const syncService = new CommentsSyncService(payload)
const result = await syncService.syncCommentsForAccount(account.id, {
maxComments: 100,
analyzeWithAI: true,
})
const created = result.created || 0
const updated = result.updated || 0
totalNew += created
totalUpdated += updated
successfulSyncs++
results.push({
accountId: account.id,
displayName: account.displayName || `Account ${account.id}`,
success: true,
created,
updated,
errors: result.errors || [],
})
console.log(` ✅ New: ${created}, Updated: ${updated}`)
if (result.errors?.length > 0) {
errors.push(...result.errors.map((e: string) => `${account.displayName}: ${e}`))
}
} catch (accountError: unknown) {
const msg = accountError instanceof Error ? accountError.message : 'Unknown error'
console.error(` ❌ Failed: ${msg}`)
errors.push(`${account.displayName}: ${msg}`)
results.push({
accountId: account.id,
displayName: account.displayName || `Account ${account.id}`,
success: false,
created: 0,
updated: 0,
errors: [msg],
})
}
}
// Zusammenfassung
console.log('\n' + '='.repeat(50))
console.log('📊 SYNC SUMMARY')
console.log('='.repeat(50))
console.log(` Accounts processed: ${accounts.docs.length}`)
console.log(` Successful syncs: ${successfulSyncs}`)
console.log(` New comments: ${totalNew}`)
console.log(` Updated: ${totalUpdated}`)
console.log(` Errors: ${errors.length}`)
if (errors.length > 0) {
console.log('\n⚠ Errors:')
errors.forEach((e) => console.log(` - ${e}`))
}
return {
totalAccounts: accounts.docs.length,
successfulSyncs,
totalNew,
totalUpdated,
errors,
}
}
/**
* Standalone-Ausführung für Cron-Job
*/
async function main() {
try {
const payload = await getPayload({ config })
await runSyncAllCommentsJob(payload)
process.exit(0)
} catch (error) {
console.error('❌ Sync job failed:', error)
process.exit(1)
}
}
// Wenn direkt ausgeführt (nicht importiert)
if (require.main === module) {
main()
}

View file

@ -57,7 +57,6 @@ export const hasCommunityAccess: Access = ({ req }) => {
const user = req.user as UserWithRoles | null const user = req.user as UserWithRoles | null
if (!user) return false if (!user) return false
if (checkIsSuperAdmin(user)) return true if (checkIsSuperAdmin(user)) return true
// YouTube-Zugriff impliziert Community-Lesezugriff
const ytRole = getYouTubeRole(user) const ytRole = getYouTubeRole(user)
if (ytRole && ytRole !== 'none') return true if (ytRole && ytRole !== 'none') return true
const commRole = getCommunityRole(user) const commRole = getCommunityRole(user)

View file

@ -0,0 +1,93 @@
/**
* YouTube OAuth Service
*
* Verwaltet die OAuth 2.0 Authentifizierung für YouTube API.
*/
import { google } from 'googleapis'
const SCOPES = [
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/youtube.force-ssl',
'https://www.googleapis.com/auth/youtube',
]
/**
* Erstellt einen konfigurierten OAuth2 Client
*/
export function getOAuth2Client() {
const clientId = process.env.YOUTUBE_CLIENT_ID
const clientSecret = process.env.YOUTUBE_CLIENT_SECRET
const redirectUri =
process.env.YOUTUBE_REDIRECT_URI ||
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/youtube/callback`
if (!clientId || !clientSecret) {
throw new Error('YouTube OAuth credentials not configured. Set YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET.')
}
return new google.auth.OAuth2(clientId, clientSecret, redirectUri)
}
/**
* Generiert die OAuth Autorisierungs-URL
*/
export function getAuthUrl(state?: string): string {
const oauth2Client = getOAuth2Client()
return oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // Immer Consent anfordern für Refresh Token
state: state,
})
}
/**
* Tauscht den Authorization Code gegen Tokens
*/
export async function getTokensFromCode(code: string) {
const oauth2Client = getOAuth2Client()
const { tokens } = await oauth2Client.getToken(code)
return tokens
}
/**
* Erneuert den Access Token mit dem Refresh Token
*/
export async function refreshAccessToken(refreshToken: string) {
const oauth2Client = getOAuth2Client()
oauth2Client.setCredentials({ refresh_token: refreshToken })
const { credentials } = await oauth2Client.refreshAccessToken()
return credentials
}
/**
* Validiert ob ein Token noch gültig ist
*/
export function isTokenExpired(expiresAt: string | Date | null | undefined): boolean {
if (!expiresAt) return true
const expiry = typeof expiresAt === 'string' ? new Date(expiresAt) : expiresAt
// Token als abgelaufen markieren 5 Minuten bevor er tatsächlich abläuft
const bufferMs = 5 * 60 * 1000
return expiry.getTime() - bufferMs < Date.now()
}
/**
* Holt die Kanal-Informationen des authentifizierten Users
*/
export async function getAuthenticatedChannel(accessToken: string) {
const oauth2Client = getOAuth2Client()
oauth2Client.setCredentials({ access_token: accessToken })
const youtube = google.youtube({ version: 'v3', auth: oauth2Client })
const response = await youtube.channels.list({
part: ['snippet', 'statistics', 'contentDetails'],
mine: true,
})
return response.data.items?.[0]
}

View file

@ -0,0 +1,325 @@
/**
* 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
}

View file

@ -0,0 +1,55 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
/**
* Migration: Add BlogWoman collections to payload_locked_documents_rels
*
* The multi-tenant plugin handles the tenant_id column automatically.
* This migration only adds the required columns to payload_locked_documents_rels
* to prevent RSC errors when accessing these collections in the admin panel.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
// Add favorites_id to payload_locked_documents_rels
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "favorites_id" integer
REFERENCES favorites(id) ON DELETE CASCADE;
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_favorites_idx"
ON "payload_locked_documents_rels" ("favorites_id");
`)
// Add series_id to payload_locked_documents_rels
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
ADD COLUMN IF NOT EXISTS "series_id" integer
REFERENCES series(id) ON DELETE CASCADE;
`)
await db.execute(sql`
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_series_idx"
ON "payload_locked_documents_rels" ("series_id");
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
// Remove columns in reverse order
await db.execute(sql`
DROP INDEX IF EXISTS "payload_locked_documents_rels_series_idx";
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
DROP COLUMN IF EXISTS "series_id";
`)
await db.execute(sql`
DROP INDEX IF EXISTS "payload_locked_documents_rels_favorites_idx";
`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels"
DROP COLUMN IF EXISTS "favorites_id";
`)
}

File diff suppressed because it is too large Load diff