mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 19:44: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/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"
|
||||||
|
|
|
||||||
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}`)
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
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
|
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)
|
||||||
|
|
|
||||||
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