mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
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:
parent
40f66eda35
commit
74b251edea
19 changed files with 8961 additions and 320 deletions
|
|
@ -38,9 +38,11 @@
|
|||
"@payloadcms/richtext-lexical": "3.69.0",
|
||||
"@payloadcms/translations": "3.69.0",
|
||||
"@payloadcms/ui": "3.69.0",
|
||||
"@types/pdfkit": "^0.17.4",
|
||||
"bullmq": "^5.65.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"googleapis": "^170.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"next": "15.5.9",
|
||||
|
|
@ -48,6 +50,7 @@
|
|||
"nodemailer": "^7.0.11",
|
||||
"payload": "3.69.0",
|
||||
"payload-oapi": "^0.2.5",
|
||||
"pdfkit": "^0.17.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"sharp": "0.34.5"
|
||||
|
|
|
|||
646
pnpm-lock.yaml
646
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
158
scripts/seed-community-rules.ts
Normal file
158
scripts/seed-community-rules.ts
Normal 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)
|
||||
})
|
||||
1515
src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx
Normal file
1515
src/app/(payload)/admin/views/community/inbox/CommunityInbox.tsx
Normal file
File diff suppressed because it is too large
Load diff
1155
src/app/(payload)/admin/views/community/inbox/inbox.scss
Normal file
1155
src/app/(payload)/admin/views/community/inbox/inbox.scss
Normal file
File diff suppressed because it is too large
Load diff
21
src/app/(payload)/admin/views/community/inbox/page.tsx
Normal file
21
src/app/(payload)/admin/views/community/inbox/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
558
src/app/(payload)/api/community/export/route.ts
Normal file
558
src/app/(payload)/api/community/export/route.ts
Normal 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
|
||||
}
|
||||
146
src/app/(payload)/api/community/stats/route.ts
Normal file
146
src/app/(payload)/api/community/stats/route.ts
Normal 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'
|
||||
75
src/app/(payload)/api/youtube/auth/route.ts
Normal file
75
src/app/(payload)/api/youtube/auth/route.ts
Normal 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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
127
src/app/(payload)/api/youtube/callback/route.ts
Normal file
127
src/app/(payload)/api/youtube/callback/route.ts
Normal 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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
115
src/app/(payload)/api/youtube/refresh-token/route.ts
Normal file
115
src/app/(payload)/api/youtube/refresh-token/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -546,6 +546,21 @@ export const CommunityInteractions: CollectionConfig = {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import cron from 'node-cron'
|
||||
import type { Payload } from 'payload'
|
||||
import { runConsentRetentionJob } from './consentRetentionJob'
|
||||
import { runSyncAllCommentsJob } from './syncAllComments'
|
||||
|
||||
/**
|
||||
* Initialisiert alle Scheduled Jobs
|
||||
|
|
@ -22,5 +23,24 @@ export const initScheduledJobs = (payload: Payload): void => {
|
|||
},
|
||||
)
|
||||
|
||||
// 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
207
src/jobs/syncAllComments.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -57,7 +57,6 @@ export const hasCommunityAccess: Access = ({ req }) => {
|
|||
const user = req.user as UserWithRoles | null
|
||||
if (!user) return false
|
||||
if (checkIsSuperAdmin(user)) return true
|
||||
// YouTube-Zugriff impliziert Community-Lesezugriff
|
||||
const ytRole = getYouTubeRole(user)
|
||||
if (ytRole && ytRole !== 'none') return true
|
||||
const commRole = getCommunityRole(user)
|
||||
|
|
|
|||
93
src/lib/integrations/youtube/oauth.ts
Normal file
93
src/lib/integrations/youtube/oauth.ts
Normal 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]
|
||||
}
|
||||
325
src/lib/services/RulesEngine.ts
Normal file
325
src/lib/services/RulesEngine.ts
Normal 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
|
||||
}
|
||||
55
src/migrations/20260113_090000_add_tenant_to_blogwoman.ts
Normal file
55
src/migrations/20260113_090000_add_tenant_to_blogwoman.ts
Normal 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
Loading…
Reference in a new issue