mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 16:14:12 +00:00
feat(dashboard): Phase 3 - Scheduled Reports & Real-time Updates
Phase 3.0a - Scheduled Reports: - ReportSchedules Collection: Zeitplan-Verwaltung für automatische Reports - Frequenz: täglich, wöchentlich, monatlich - Formate: PDF, Excel (CSV), HTML E-Mail - Report-Typen: Übersicht, Sentiment, Response-Metriken, Content-Performance - Multiple Empfänger per E-Mail - Zeitzone-Support - ReportGeneratorService: Report-Generierung - Datensammlung aus community-interactions - HTML-Template für PDF und E-Mail - CSV-Export für Excel-kompatible Daten - Cron-Endpoint: /api/cron/send-reports (stündlich) - Prüft fällige Reports - Automatischer Versand per E-Mail - Status-Tracking und Fehlerbehandlung Phase 3.0b - Real-time Updates: - SSE Stream Endpoint: /api/community/stream - Server-Sent Events für Live-Updates - 5-Sekunden Polling-Intervall - Heartbeat für Verbindungserhalt - Automatische Reconnection - useRealtimeUpdates Hook: - React Hook für SSE-Konsum - Verbindungsstatus-Management - Update-Counter für Badges - Channel-Filterung Vercel Cron aktualisiert: - send-reports: stündlich (0 * * * *) Migrationen: - 20260116_120000_add_report_schedules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7774616f00
commit
cb035d209d
10 changed files with 2168 additions and 0 deletions
|
|
@ -1122,6 +1122,7 @@ Automatische Hintergrund-Jobs via Vercel Cron (`vercel.json`):
|
|||
|----------|----------|--------------|
|
||||
| `/api/cron/community-sync` | `*/15 * * * *` (alle 15 Min) | Synchronisiert Kommentare von YouTube, Facebook, Instagram |
|
||||
| `/api/cron/token-refresh` | `0 6,18 * * *` (6:00 + 18:00 UTC) | Erneuert ablaufende OAuth-Tokens automatisch |
|
||||
| `/api/cron/send-reports` | `0 * * * *` (stündlich) | Versendet fällige Community-Reports per E-Mail |
|
||||
|
||||
**Authentifizierung:**
|
||||
Alle Cron-Endpoints erfordern `Authorization: Bearer $CRON_SECRET` Header.
|
||||
|
|
|
|||
243
src/app/(payload)/api/community/stream/route.ts
Normal file
243
src/app/(payload)/api/community/stream/route.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// src/app/(payload)/api/community/stream/route.ts
|
||||
// Server-Sent Events Endpoint für Real-time Community Updates
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
|
||||
// Polling-Intervall in Millisekunden
|
||||
const POLL_INTERVAL = 5000 // 5 Sekunden
|
||||
|
||||
// Maximale Verbindungsdauer (Vercel Limit)
|
||||
const MAX_DURATION = 25000 // 25 Sekunden (Vercel max ist 30s)
|
||||
|
||||
/**
|
||||
* GET /api/community/stream
|
||||
* Server-Sent Events Endpoint für Live-Updates
|
||||
*
|
||||
* Query Parameters:
|
||||
* - since: ISO Timestamp - nur Updates seit diesem Zeitpunkt
|
||||
* - channels: Komma-separierte Channel-IDs für Filterung
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Authentifizierung prüfen
|
||||
const { user } = await payload.auth({ headers: request.headers })
|
||||
|
||||
if (!user) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
// Query-Parameter
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const sinceParam = searchParams.get('since')
|
||||
const channelsParam = searchParams.get('channels')
|
||||
|
||||
let lastCheckTime = sinceParam ? new Date(sinceParam) : new Date()
|
||||
const channelIds = channelsParam
|
||||
? channelsParam.split(',').map((id) => parseInt(id, 10)).filter((id) => !isNaN(id))
|
||||
: []
|
||||
|
||||
// Response-Stream erstellen
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const startTime = Date.now()
|
||||
|
||||
// Initiale Nachricht senden
|
||||
const initMessage = JSON.stringify({
|
||||
type: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Connected to community stream',
|
||||
})
|
||||
controller.enqueue(encoder.encode(`data: ${initMessage}\n\n`))
|
||||
|
||||
// Polling-Loop
|
||||
while (Date.now() - startTime < MAX_DURATION) {
|
||||
try {
|
||||
const updates = await fetchUpdates(payload, lastCheckTime, channelIds)
|
||||
|
||||
if (updates.length > 0) {
|
||||
const data = JSON.stringify({
|
||||
type: 'updates',
|
||||
updates,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
|
||||
// Letzten Zeitstempel aktualisieren
|
||||
lastCheckTime = new Date()
|
||||
}
|
||||
|
||||
// Heartbeat senden (verhindert Timeout)
|
||||
if (updates.length === 0) {
|
||||
const heartbeat = JSON.stringify({
|
||||
type: 'heartbeat',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.enqueue(encoder.encode(`data: ${heartbeat}\n\n`))
|
||||
}
|
||||
|
||||
// Warten bis zum nächsten Poll
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL))
|
||||
} catch (error) {
|
||||
console.error('[SSE] Error fetching updates:', error)
|
||||
|
||||
const errorData = JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Error fetching updates',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
controller.enqueue(encoder.encode(`data: ${errorData}\n\n`))
|
||||
}
|
||||
}
|
||||
|
||||
// Verbindung beenden (Client sollte reconnecten)
|
||||
const closeMessage = JSON.stringify({
|
||||
type: 'reconnect',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Connection timeout, please reconnect',
|
||||
})
|
||||
controller.enqueue(encoder.encode(`data: ${closeMessage}\n\n`))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Für Nginx
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt neue Updates seit dem letzten Check
|
||||
*/
|
||||
async function fetchUpdates(
|
||||
payload: any,
|
||||
since: Date,
|
||||
channelIds: number[]
|
||||
): Promise<CommunityUpdate[]> {
|
||||
const updates: CommunityUpdate[] = []
|
||||
|
||||
// Neue Interaktionen
|
||||
const whereInteractions: any = {
|
||||
createdAt: { greater_than: since.toISOString() },
|
||||
}
|
||||
|
||||
if (channelIds.length > 0) {
|
||||
whereInteractions.socialAccount = { in: channelIds }
|
||||
}
|
||||
|
||||
const newInteractions = await payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: whereInteractions,
|
||||
limit: 50,
|
||||
sort: '-createdAt',
|
||||
})
|
||||
|
||||
for (const interaction of newInteractions.docs) {
|
||||
updates.push({
|
||||
type: 'new_interaction',
|
||||
data: {
|
||||
id: interaction.id,
|
||||
platform: interaction.platform,
|
||||
authorName: interaction.authorName,
|
||||
content: (interaction.content as string)?.substring(0, 100),
|
||||
sentiment: (interaction.aiAnalysis as any)?.sentiment,
|
||||
sourceTitle: interaction.sourceTitle,
|
||||
createdAt: interaction.createdAt,
|
||||
},
|
||||
timestamp: interaction.createdAt as string,
|
||||
})
|
||||
}
|
||||
|
||||
// Status-Änderungen (aktualisierte Interaktionen)
|
||||
const updatedInteractions = await payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: {
|
||||
and: [
|
||||
{ updatedAt: { greater_than: since.toISOString() } },
|
||||
{ createdAt: { less_than: since.toISOString() } }, // Nicht neu erstellt
|
||||
...(channelIds.length > 0 ? [{ socialAccount: { in: channelIds } }] : []),
|
||||
],
|
||||
},
|
||||
limit: 50,
|
||||
sort: '-updatedAt',
|
||||
})
|
||||
|
||||
for (const interaction of updatedInteractions.docs) {
|
||||
updates.push({
|
||||
type: 'status_change',
|
||||
data: {
|
||||
id: interaction.id,
|
||||
status: interaction.status,
|
||||
platform: interaction.platform,
|
||||
authorName: interaction.authorName,
|
||||
updatedAt: interaction.updatedAt,
|
||||
},
|
||||
timestamp: interaction.updatedAt as string,
|
||||
})
|
||||
}
|
||||
|
||||
// Neue Antworten (kürzlich beantwortet)
|
||||
const newReplies = await payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: {
|
||||
and: [
|
||||
{ repliedAt: { greater_than: since.toISOString() } },
|
||||
{ status: { equals: 'replied' } },
|
||||
...(channelIds.length > 0 ? [{ socialAccount: { in: channelIds } }] : []),
|
||||
],
|
||||
},
|
||||
limit: 50,
|
||||
sort: '-repliedAt',
|
||||
})
|
||||
|
||||
for (const interaction of newReplies.docs) {
|
||||
// Nicht doppelt hinzufügen wenn schon als status_change
|
||||
if (!updates.some((u) => u.type === 'status_change' && u.data.id === interaction.id)) {
|
||||
updates.push({
|
||||
type: 'new_reply',
|
||||
data: {
|
||||
id: interaction.id,
|
||||
platform: interaction.platform,
|
||||
authorName: interaction.authorName,
|
||||
ourReply: (interaction.ourReply as string)?.substring(0, 100),
|
||||
repliedAt: interaction.repliedAt,
|
||||
},
|
||||
timestamp: interaction.repliedAt as string,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Nach Timestamp sortieren
|
||||
updates.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
|
||||
return updates.slice(0, 50) // Max 50 Updates pro Request
|
||||
}
|
||||
|
||||
interface CommunityUpdate {
|
||||
type: 'new_interaction' | 'status_change' | 'new_reply'
|
||||
data: {
|
||||
id: number
|
||||
platform?: string
|
||||
authorName?: string
|
||||
content?: string
|
||||
sentiment?: string
|
||||
sourceTitle?: string
|
||||
status?: string
|
||||
ourReply?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
repliedAt?: string
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 30 // Maximum für Vercel
|
||||
325
src/app/(payload)/api/cron/send-reports/route.ts
Normal file
325
src/app/(payload)/api/cron/send-reports/route.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// src/app/(payload)/api/cron/send-reports/route.ts
|
||||
// Scheduled Report Sending Cron Endpoint
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { ReportGeneratorService, ReportSchedule } from '@/lib/services/ReportGeneratorService'
|
||||
|
||||
// Geheimer Token für Cron-Authentifizierung
|
||||
const CRON_SECRET = process.env.CRON_SECRET
|
||||
|
||||
// Status für Monitoring
|
||||
let isRunning = false
|
||||
let lastRunAt: Date | null = null
|
||||
let lastResult: { success: boolean; sent: number; failed: number } | null = null
|
||||
|
||||
/**
|
||||
* GET /api/cron/send-reports
|
||||
* Prüft und sendet fällige Reports
|
||||
*
|
||||
* Läuft stündlich und prüft welche Reports gesendet werden müssen
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// Auth prüfen wenn CRON_SECRET gesetzt
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized request to send-reports')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Report sending already in progress' },
|
||||
{ status: 423 }
|
||||
)
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
const startedAt = new Date()
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const reportService = new ReportGeneratorService(payload)
|
||||
|
||||
// Fällige Reports finden
|
||||
const now = new Date()
|
||||
const dueSchedules = await findDueSchedules(payload, now)
|
||||
|
||||
console.log(`[Cron] Found ${dueSchedules.length} due report schedules`)
|
||||
|
||||
for (const schedule of dueSchedules) {
|
||||
try {
|
||||
console.log(`[Cron] Generating report: ${schedule.name}`)
|
||||
|
||||
// Report generieren
|
||||
const report = await reportService.generateReport(schedule)
|
||||
|
||||
if (!report.success) {
|
||||
console.error(`[Cron] Report generation failed: ${report.error}`)
|
||||
failed++
|
||||
await updateScheduleError(payload, schedule.id, report.error || 'Generation failed')
|
||||
continue
|
||||
}
|
||||
|
||||
// Report senden
|
||||
const sendSuccess = await reportService.sendReport(schedule, report)
|
||||
|
||||
if (sendSuccess) {
|
||||
sent++
|
||||
console.log(`[Cron] Report sent successfully: ${schedule.name}`)
|
||||
|
||||
// Nächsten Versandzeitpunkt berechnen
|
||||
await updateNextScheduledTime(payload, schedule)
|
||||
} else {
|
||||
failed++
|
||||
console.error(`[Cron] Report send failed: ${schedule.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[Cron] Error processing schedule ${schedule.name}:`, error)
|
||||
await updateScheduleError(payload, schedule.id, message)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
lastRunAt = startedAt
|
||||
lastResult = { success: failed === 0, sent, failed }
|
||||
|
||||
return NextResponse.json({
|
||||
success: failed === 0,
|
||||
message: `Reports processed: ${sent} sent, ${failed} failed`,
|
||||
processed: dueSchedules.length,
|
||||
sent,
|
||||
failed,
|
||||
duration: Date.now() - startedAt.getTime(),
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('[Cron] send-reports error:', error)
|
||||
|
||||
lastResult = { success: false, sent, failed: failed + 1 }
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: message, sent, failed },
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cron/send-reports
|
||||
* Manuelles Senden eines bestimmten Reports
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Auth prüfen
|
||||
if (CRON_SECRET) {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
|
||||
if (authHeader !== `Bearer ${CRON_SECRET}`) {
|
||||
console.warn('[Cron] Unauthorized POST to send-reports')
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { scheduleId } = body
|
||||
|
||||
if (!scheduleId) {
|
||||
return NextResponse.json({ error: 'scheduleId required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// Schedule laden
|
||||
const scheduleDoc = await payload.findByID({
|
||||
collection: 'report-schedules',
|
||||
id: scheduleId,
|
||||
depth: 2,
|
||||
})
|
||||
|
||||
if (!scheduleDoc) {
|
||||
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const schedule = transformSchedule(scheduleDoc)
|
||||
const reportService = new ReportGeneratorService(payload)
|
||||
|
||||
// Report generieren und senden
|
||||
const report = await reportService.generateReport(schedule)
|
||||
|
||||
if (!report.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: report.error },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const sendSuccess = await reportService.sendReport(schedule, report)
|
||||
|
||||
return NextResponse.json({
|
||||
success: sendSuccess,
|
||||
scheduleName: schedule.name,
|
||||
format: schedule.format,
|
||||
recipients: schedule.recipients.length,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('[Cron] POST send-reports error:', error)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HEAD /api/cron/send-reports
|
||||
* Status-Check für Monitoring
|
||||
*/
|
||||
export async function HEAD() {
|
||||
return new NextResponse(null, {
|
||||
status: isRunning ? 423 : 200,
|
||||
headers: {
|
||||
'X-Running': isRunning.toString(),
|
||||
'X-Last-Run': lastRunAt?.toISOString() || 'never',
|
||||
'X-Last-Sent': lastResult?.sent.toString() || '0',
|
||||
'X-Last-Failed': lastResult?.failed.toString() || '0',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet fällige Report-Schedules
|
||||
*/
|
||||
async function findDueSchedules(payload: any, now: Date): Promise<ReportSchedule[]> {
|
||||
// Schedules laden die:
|
||||
// 1. Aktiv sind
|
||||
// 2. nextScheduledAt <= jetzt
|
||||
const schedules = await payload.find({
|
||||
collection: 'report-schedules',
|
||||
where: {
|
||||
and: [
|
||||
{ enabled: { equals: true } },
|
||||
{
|
||||
or: [
|
||||
{ nextScheduledAt: { less_than_equal: now.toISOString() } },
|
||||
{ nextScheduledAt: { equals: null } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
depth: 2,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
return schedules.docs.map(transformSchedule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformiert DB-Dokument zu ReportSchedule
|
||||
*/
|
||||
function transformSchedule(doc: any): ReportSchedule {
|
||||
return {
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
enabled: doc.enabled,
|
||||
frequency: doc.frequency,
|
||||
dayOfWeek: doc.dayOfWeek,
|
||||
dayOfMonth: doc.dayOfMonth,
|
||||
time: doc.time,
|
||||
timezone: doc.timezone,
|
||||
reportType: doc.reportType,
|
||||
channels: doc.channels?.map((c: any) => ({
|
||||
id: typeof c === 'number' ? c : c.id,
|
||||
displayName: c.displayName,
|
||||
})),
|
||||
periodDays: doc.periodDays || 7,
|
||||
format: doc.format,
|
||||
recipients: doc.recipients || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den nächsten Versandzeitpunkt
|
||||
*/
|
||||
async function updateNextScheduledTime(payload: any, schedule: ReportSchedule): Promise<void> {
|
||||
const next = calculateNextScheduledTime(schedule)
|
||||
|
||||
await payload.update({
|
||||
collection: 'report-schedules',
|
||||
id: schedule.id,
|
||||
data: {
|
||||
nextScheduledAt: next?.toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den nächsten Versandzeitpunkt
|
||||
*/
|
||||
function calculateNextScheduledTime(schedule: ReportSchedule): Date | null {
|
||||
const [hours, minutes] = schedule.time.split(':').map(Number)
|
||||
const now = new Date()
|
||||
const next = new Date()
|
||||
next.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// Mindestens 1 Stunde in der Zukunft
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1)
|
||||
}
|
||||
|
||||
switch (schedule.frequency) {
|
||||
case 'daily':
|
||||
// Bereits korrekt
|
||||
break
|
||||
|
||||
case 'weekly':
|
||||
const dayMap: Record<string, number> = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
}
|
||||
const targetDay = dayMap[schedule.dayOfWeek || 'monday']
|
||||
const currentDay = next.getDay()
|
||||
let daysUntil = targetDay - currentDay
|
||||
if (daysUntil <= 0) daysUntil += 7
|
||||
next.setDate(next.getDate() + daysUntil)
|
||||
break
|
||||
|
||||
case 'monthly':
|
||||
const targetDate = schedule.dayOfMonth || 1
|
||||
next.setDate(targetDate)
|
||||
if (next <= now) {
|
||||
next.setMonth(next.getMonth() + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Fehler-Status eines Schedules
|
||||
*/
|
||||
async function updateScheduleError(payload: any, id: number, error: string): Promise<void> {
|
||||
await payload.update({
|
||||
collection: 'report-schedules',
|
||||
id,
|
||||
data: {
|
||||
lastError: error.substring(0, 255),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 120 // 2 Minuten max
|
||||
377
src/collections/ReportSchedules.ts
Normal file
377
src/collections/ReportSchedules.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
// src/collections/ReportSchedules.ts
|
||||
// Scheduled Community Reports Collection
|
||||
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { hasYouTubeAccess, isYouTubeManager } from '../lib/youtubeAccess'
|
||||
|
||||
/**
|
||||
* ReportSchedules Collection
|
||||
*
|
||||
* Ermöglicht die Planung automatischer Community-Reports.
|
||||
* Reports werden per E-Mail an definierte Empfänger gesendet.
|
||||
* Teil des Community Management Dashboards.
|
||||
*/
|
||||
export const ReportSchedules: CollectionConfig = {
|
||||
slug: 'report-schedules',
|
||||
labels: {
|
||||
singular: 'Report-Zeitplan',
|
||||
plural: 'Report-Zeitpläne',
|
||||
},
|
||||
admin: {
|
||||
group: 'Community',
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'reportType', 'frequency', 'enabled', 'lastSentAt'],
|
||||
description: 'Automatische Community-Reports per E-Mail',
|
||||
},
|
||||
access: {
|
||||
read: hasYouTubeAccess,
|
||||
create: isYouTubeManager,
|
||||
update: isYouTubeManager,
|
||||
delete: isYouTubeManager,
|
||||
},
|
||||
fields: [
|
||||
// === Grundeinstellungen ===
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
label: 'Report-Name',
|
||||
admin: {
|
||||
description: 'Interner Name für diesen Report-Zeitplan',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
type: 'checkbox',
|
||||
defaultValue: true,
|
||||
label: 'Aktiv',
|
||||
admin: {
|
||||
description: 'Deaktivierte Reports werden nicht automatisch versendet',
|
||||
},
|
||||
},
|
||||
|
||||
// === Zeitplan ===
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'frequency',
|
||||
type: 'select',
|
||||
required: true,
|
||||
label: 'Häufigkeit',
|
||||
defaultValue: 'weekly',
|
||||
options: [
|
||||
{ label: 'Täglich', value: 'daily' },
|
||||
{ label: 'Wöchentlich', value: 'weekly' },
|
||||
{ label: 'Monatlich', value: 'monthly' },
|
||||
],
|
||||
admin: {
|
||||
width: '33%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dayOfWeek',
|
||||
type: 'select',
|
||||
label: 'Wochentag',
|
||||
options: [
|
||||
{ label: 'Montag', value: 'monday' },
|
||||
{ label: 'Dienstag', value: 'tuesday' },
|
||||
{ label: 'Mittwoch', value: 'wednesday' },
|
||||
{ label: 'Donnerstag', value: 'thursday' },
|
||||
{ label: 'Freitag', value: 'friday' },
|
||||
{ label: 'Samstag', value: 'saturday' },
|
||||
{ label: 'Sonntag', value: 'sunday' },
|
||||
],
|
||||
admin: {
|
||||
width: '33%',
|
||||
condition: (data) => data?.frequency === 'weekly',
|
||||
description: 'Für wöchentliche Reports',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dayOfMonth',
|
||||
type: 'number',
|
||||
label: 'Tag des Monats',
|
||||
min: 1,
|
||||
max: 28,
|
||||
admin: {
|
||||
width: '33%',
|
||||
condition: (data) => data?.frequency === 'monthly',
|
||||
description: 'Für monatliche Reports (1-28)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'time',
|
||||
type: 'text',
|
||||
required: true,
|
||||
defaultValue: '08:00',
|
||||
label: 'Uhrzeit',
|
||||
admin: {
|
||||
width: '50%',
|
||||
description: 'Format: HH:MM (24-Stunden)',
|
||||
},
|
||||
validate: (value) => {
|
||||
if (!value) return true
|
||||
const regex = /^([01]\d|2[0-3]):([0-5]\d)$/
|
||||
if (!regex.test(value)) {
|
||||
return 'Bitte gültiges Format verwenden: HH:MM (z.B. 08:00)'
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timezone',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'Europe/Berlin',
|
||||
label: 'Zeitzone',
|
||||
options: [
|
||||
{ label: 'Europe/Berlin (MEZ/MESZ)', value: 'Europe/Berlin' },
|
||||
{ label: 'Europe/London (GMT/BST)', value: 'Europe/London' },
|
||||
{ label: 'America/New_York (EST/EDT)', value: 'America/New_York' },
|
||||
{ label: 'America/Los_Angeles (PST/PDT)', value: 'America/Los_Angeles' },
|
||||
{ label: 'Asia/Tokyo (JST)', value: 'Asia/Tokyo' },
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
],
|
||||
admin: {
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// === Report-Inhalt ===
|
||||
{
|
||||
name: 'reportType',
|
||||
type: 'select',
|
||||
required: true,
|
||||
label: 'Report-Typ',
|
||||
defaultValue: 'overview',
|
||||
options: [
|
||||
{ label: 'Übersicht', value: 'overview' },
|
||||
{ label: 'Sentiment-Analyse', value: 'sentiment_analysis' },
|
||||
{ label: 'Antwort-Metriken', value: 'response_metrics' },
|
||||
{ label: 'Content-Performance', value: 'content_performance' },
|
||||
{ label: 'Vollständiger Report', value: 'full_report' },
|
||||
],
|
||||
admin: {
|
||||
description: 'Art der Daten im Report',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'channels',
|
||||
type: 'relationship',
|
||||
relationTo: 'social-accounts',
|
||||
hasMany: true,
|
||||
label: 'Kanäle',
|
||||
admin: {
|
||||
description: 'Leer = alle aktiven Kanäle. Oder spezifische Kanäle auswählen.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'periodDays',
|
||||
type: 'number',
|
||||
defaultValue: 7,
|
||||
label: 'Zeitraum (Tage)',
|
||||
min: 1,
|
||||
max: 90,
|
||||
admin: {
|
||||
description: 'Wie viele Tage zurück sollen analysiert werden?',
|
||||
},
|
||||
},
|
||||
|
||||
// === Format & Versand ===
|
||||
{
|
||||
name: 'format',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'pdf',
|
||||
label: 'Format',
|
||||
options: [
|
||||
{ label: 'PDF', value: 'pdf' },
|
||||
{ label: 'Excel', value: 'excel' },
|
||||
{ label: 'HTML E-Mail', value: 'html_email' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'recipients',
|
||||
type: 'array',
|
||||
required: true,
|
||||
minRows: 1,
|
||||
label: 'Empfänger',
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
required: true,
|
||||
label: 'E-Mail',
|
||||
admin: {
|
||||
width: '60%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
label: 'Name',
|
||||
admin: {
|
||||
width: '40%',
|
||||
description: 'Optional',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// === Status (Read-only) ===
|
||||
{
|
||||
name: 'statusSection',
|
||||
type: 'collapsible',
|
||||
label: 'Status & Statistiken',
|
||||
admin: {
|
||||
initCollapsed: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'lastSentAt',
|
||||
type: 'date',
|
||||
label: 'Zuletzt gesendet',
|
||||
admin: {
|
||||
width: '50%',
|
||||
readOnly: true,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
displayFormat: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'nextScheduledAt',
|
||||
type: 'date',
|
||||
label: 'Nächster Versand',
|
||||
admin: {
|
||||
width: '50%',
|
||||
readOnly: true,
|
||||
date: {
|
||||
pickerAppearance: 'dayAndTime',
|
||||
displayFormat: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'sendCount',
|
||||
type: 'number',
|
||||
defaultValue: 0,
|
||||
label: 'Anzahl Versendungen',
|
||||
admin: {
|
||||
width: '50%',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'lastError',
|
||||
type: 'text',
|
||||
label: 'Letzter Fehler',
|
||||
admin: {
|
||||
width: '50%',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
// Berechne nächsten geplanten Versand
|
||||
async ({ data, operation }) => {
|
||||
if (!data) return data
|
||||
|
||||
if (operation === 'create' || data.enabled) {
|
||||
data.nextScheduledAt = calculateNextScheduledTime(data)
|
||||
}
|
||||
|
||||
return data
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet den nächsten geplanten Versandzeitpunkt
|
||||
*/
|
||||
function calculateNextScheduledTime(data: {
|
||||
frequency?: string
|
||||
dayOfWeek?: string
|
||||
dayOfMonth?: number
|
||||
time?: string
|
||||
timezone?: string
|
||||
}): Date | null {
|
||||
if (!data.frequency || !data.time) return null
|
||||
|
||||
const [hours, minutes] = data.time.split(':').map(Number)
|
||||
const now = new Date()
|
||||
|
||||
// Einfache Berechnung (ohne Timezone-Bibliothek)
|
||||
// In Production sollte luxon oder date-fns-tz verwendet werden
|
||||
const next = new Date()
|
||||
next.setHours(hours, minutes, 0, 0)
|
||||
|
||||
// Wenn die Zeit heute schon vorbei ist, auf morgen setzen
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1)
|
||||
}
|
||||
|
||||
switch (data.frequency) {
|
||||
case 'daily':
|
||||
// Bereits korrekt
|
||||
break
|
||||
|
||||
case 'weekly':
|
||||
const dayMap: Record<string, number> = {
|
||||
sunday: 0,
|
||||
monday: 1,
|
||||
tuesday: 2,
|
||||
wednesday: 3,
|
||||
thursday: 4,
|
||||
friday: 5,
|
||||
saturday: 6,
|
||||
}
|
||||
const targetDay = dayMap[data.dayOfWeek || 'monday']
|
||||
const currentDay = next.getDay()
|
||||
let daysUntil = targetDay - currentDay
|
||||
if (daysUntil <= 0) daysUntil += 7
|
||||
next.setDate(next.getDate() + daysUntil - 1) // -1 weil wir schon +1 gemacht haben
|
||||
break
|
||||
|
||||
case 'monthly':
|
||||
const targetDate = data.dayOfMonth || 1
|
||||
next.setDate(targetDate)
|
||||
if (next <= now) {
|
||||
next.setMonth(next.getMonth() + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export default ReportSchedules
|
||||
293
src/hooks/useRealtimeUpdates.ts
Normal file
293
src/hooks/useRealtimeUpdates.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
// src/hooks/useRealtimeUpdates.ts
|
||||
// React Hook für Real-time Community Updates via SSE
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type UpdateType = 'new_interaction' | 'status_change' | 'new_reply'
|
||||
|
||||
export interface CommunityUpdate {
|
||||
type: UpdateType
|
||||
data: {
|
||||
id: number
|
||||
platform?: string
|
||||
authorName?: string
|
||||
content?: string
|
||||
sentiment?: string
|
||||
sourceTitle?: string
|
||||
status?: string
|
||||
ourReply?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
repliedAt?: string
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface SSEMessage {
|
||||
type: 'connected' | 'updates' | 'heartbeat' | 'error' | 'reconnect'
|
||||
updates?: CommunityUpdate[]
|
||||
message?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface UseRealtimeUpdatesOptions {
|
||||
/** Channel-IDs für Filterung */
|
||||
channelIds?: number[]
|
||||
/** Automatisch verbinden beim Mount */
|
||||
autoConnect?: boolean
|
||||
/** Max. Anzahl Updates im State */
|
||||
maxUpdates?: number
|
||||
/** Callback für neue Updates */
|
||||
onUpdate?: (updates: CommunityUpdate[]) => void
|
||||
/** Callback für Verbindungsstatus */
|
||||
onConnectionChange?: (connected: boolean) => void
|
||||
}
|
||||
|
||||
export interface UseRealtimeUpdatesReturn {
|
||||
/** Verbindungsstatus */
|
||||
connected: boolean
|
||||
/** Verbindung wird aufgebaut */
|
||||
connecting: boolean
|
||||
/** Alle empfangenen Updates */
|
||||
updates: CommunityUpdate[]
|
||||
/** Anzahl neuer Interaktionen seit letztem Clear */
|
||||
newCount: number
|
||||
/** Letzter Fehler */
|
||||
error: string | null
|
||||
/** Manuell verbinden */
|
||||
connect: () => void
|
||||
/** Verbindung trennen */
|
||||
disconnect: () => void
|
||||
/** Updates zurücksetzen */
|
||||
clearUpdates: () => void
|
||||
/** NewCount zurücksetzen */
|
||||
clearNewCount: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook Implementation
|
||||
// =============================================================================
|
||||
|
||||
export function useRealtimeUpdates(
|
||||
options: UseRealtimeUpdatesOptions = {}
|
||||
): UseRealtimeUpdatesReturn {
|
||||
const {
|
||||
channelIds = [],
|
||||
autoConnect = true,
|
||||
maxUpdates = 100,
|
||||
onUpdate,
|
||||
onConnectionChange,
|
||||
} = options
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [updates, setUpdates] = useState<CommunityUpdate[]>([])
|
||||
const [newCount, setNewCount] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const lastTimestampRef = useRef<string | null>(null)
|
||||
|
||||
// Callbacks als Refs speichern um Dependency-Probleme zu vermeiden
|
||||
const onUpdateRef = useRef(onUpdate)
|
||||
const onConnectionChangeRef = useRef(onConnectionChange)
|
||||
onUpdateRef.current = onUpdate
|
||||
onConnectionChangeRef.current = onConnectionChange
|
||||
|
||||
/**
|
||||
* Verbindung herstellen
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
// Bereits verbunden oder verbindend
|
||||
if (eventSourceRef.current || connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
setConnecting(true)
|
||||
setError(null)
|
||||
|
||||
// URL mit Query-Parametern aufbauen
|
||||
const params = new URLSearchParams()
|
||||
if (lastTimestampRef.current) {
|
||||
params.set('since', lastTimestampRef.current)
|
||||
}
|
||||
if (channelIds.length > 0) {
|
||||
params.set('channels', channelIds.join(','))
|
||||
}
|
||||
|
||||
const url = `/api/community/stream${params.toString() ? `?${params.toString()}` : ''}`
|
||||
|
||||
try {
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnecting(false)
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
onConnectionChangeRef.current?.(true)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const message: SSEMessage = JSON.parse(event.data)
|
||||
|
||||
switch (message.type) {
|
||||
case 'connected':
|
||||
console.log('[SSE] Connected:', message.message)
|
||||
break
|
||||
|
||||
case 'updates':
|
||||
if (message.updates && message.updates.length > 0) {
|
||||
setUpdates((prev) => {
|
||||
const newUpdates = [...message.updates!, ...prev].slice(0, maxUpdates)
|
||||
return newUpdates
|
||||
})
|
||||
|
||||
// Neue Interaktionen zählen
|
||||
const newInteractions = message.updates.filter(
|
||||
(u) => u.type === 'new_interaction'
|
||||
).length
|
||||
if (newInteractions > 0) {
|
||||
setNewCount((prev) => prev + newInteractions)
|
||||
}
|
||||
|
||||
// Callback aufrufen
|
||||
onUpdateRef.current?.(message.updates)
|
||||
|
||||
// Timestamp aktualisieren
|
||||
lastTimestampRef.current = message.timestamp
|
||||
}
|
||||
break
|
||||
|
||||
case 'heartbeat':
|
||||
// Heartbeat - nichts tun
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('[SSE] Server error:', message.message)
|
||||
setError(message.message || 'Server error')
|
||||
break
|
||||
|
||||
case 'reconnect':
|
||||
// Server fordert Reconnect an
|
||||
console.log('[SSE] Reconnect requested')
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
scheduleReconnect()
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SSE] Error parsing message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (e) => {
|
||||
console.error('[SSE] Connection error:', e)
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
onConnectionChangeRef.current?.(false)
|
||||
|
||||
// EventSource schließen und Reconnect planen
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SSE] Failed to create EventSource:', e)
|
||||
setConnecting(false)
|
||||
setError('Failed to connect')
|
||||
}
|
||||
}, [channelIds, connecting, maxUpdates])
|
||||
|
||||
/**
|
||||
* Verbindung trennen
|
||||
*/
|
||||
const disconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
onConnectionChangeRef.current?.(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Reconnect nach Verzögerung planen
|
||||
*/
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
return // Bereits geplant
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = null
|
||||
connect()
|
||||
}, 3000) // 3 Sekunden warten
|
||||
}, [connect])
|
||||
|
||||
/**
|
||||
* Updates zurücksetzen
|
||||
*/
|
||||
const clearUpdates = useCallback(() => {
|
||||
setUpdates([])
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* NewCount zurücksetzen
|
||||
*/
|
||||
const clearNewCount = useCallback(() => {
|
||||
setNewCount(0)
|
||||
}, [])
|
||||
|
||||
// Auto-Connect beim Mount
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnect()
|
||||
}
|
||||
}, [autoConnect, connect, disconnect])
|
||||
|
||||
// Channel-IDs Änderung: Reconnect
|
||||
useEffect(() => {
|
||||
if (connected || connecting) {
|
||||
disconnect()
|
||||
// Kurz warten, dann neu verbinden
|
||||
const timeout = setTimeout(() => {
|
||||
connect()
|
||||
}, 100)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [channelIds.join(',')]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
updates,
|
||||
newCount,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
clearUpdates,
|
||||
clearNewCount,
|
||||
}
|
||||
}
|
||||
|
||||
export default useRealtimeUpdates
|
||||
767
src/lib/services/ReportGeneratorService.ts
Normal file
767
src/lib/services/ReportGeneratorService.ts
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
// src/lib/services/ReportGeneratorService.ts
|
||||
// Automatische Community Report Generierung
|
||||
|
||||
import { Payload } from 'payload'
|
||||
import { generatePdfFromHtml } from '../pdf/pdf-service'
|
||||
import { sendEmail } from '../email/tenant-email-service'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type ReportType =
|
||||
| 'overview'
|
||||
| 'sentiment_analysis'
|
||||
| 'response_metrics'
|
||||
| 'content_performance'
|
||||
| 'full_report'
|
||||
|
||||
export type ReportFormat = 'pdf' | 'excel' | 'html_email'
|
||||
|
||||
export interface ReportSchedule {
|
||||
id: number
|
||||
name: string
|
||||
enabled: boolean
|
||||
frequency: 'daily' | 'weekly' | 'monthly'
|
||||
dayOfWeek?: string
|
||||
dayOfMonth?: number
|
||||
time: string
|
||||
timezone: string
|
||||
reportType: ReportType
|
||||
channels?: { id: number; displayName?: string }[]
|
||||
periodDays: number
|
||||
format: ReportFormat
|
||||
recipients: { email: string; name?: string }[]
|
||||
}
|
||||
|
||||
export interface ReportData {
|
||||
schedule: ReportSchedule
|
||||
generatedAt: Date
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
overview?: OverviewData
|
||||
sentimentAnalysis?: SentimentData
|
||||
responseMetrics?: ResponseData
|
||||
contentPerformance?: ContentData
|
||||
}
|
||||
|
||||
export interface OverviewData {
|
||||
totalInteractions: number
|
||||
newInteractions: number
|
||||
repliedCount: number
|
||||
avgResponseTime: number
|
||||
platforms: { platform: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface SentimentData {
|
||||
positive: number
|
||||
neutral: number
|
||||
negative: number
|
||||
trend: { date: string; positive: number; neutral: number; negative: number }[]
|
||||
}
|
||||
|
||||
export interface ResponseData {
|
||||
avgResponseTimeHours: number
|
||||
responseRate: number
|
||||
repliedCount: number
|
||||
pendingCount: number
|
||||
byPlatform: { platform: string; avgTime: number; rate: number }[]
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
topPosts: {
|
||||
title: string
|
||||
platform: string
|
||||
comments: number
|
||||
engagement: number
|
||||
}[]
|
||||
topTopics: { topic: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface ReportResult {
|
||||
success: boolean
|
||||
format: ReportFormat
|
||||
buffer?: Buffer
|
||||
html?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Report Generator Service
|
||||
// =============================================================================
|
||||
|
||||
export class ReportGeneratorService {
|
||||
private payload: Payload
|
||||
|
||||
constructor(payload: Payload) {
|
||||
this.payload = payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einen Report basierend auf dem Schedule
|
||||
*/
|
||||
async generateReport(schedule: ReportSchedule): Promise<ReportResult> {
|
||||
try {
|
||||
// Daten sammeln
|
||||
const data = await this.collectReportData(schedule)
|
||||
|
||||
// Report im gewünschten Format generieren
|
||||
switch (schedule.format) {
|
||||
case 'pdf':
|
||||
return this.generatePdfReport(data)
|
||||
case 'excel':
|
||||
return this.generateExcelReport(data)
|
||||
case 'html_email':
|
||||
return this.generateHtmlEmail(data)
|
||||
default:
|
||||
throw new Error(`Unsupported format: ${schedule.format}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('[ReportGenerator] Error generating report:', error)
|
||||
return {
|
||||
success: false,
|
||||
format: schedule.format,
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet einen generierten Report per E-Mail
|
||||
*/
|
||||
async sendReport(schedule: ReportSchedule, report: ReportResult): Promise<boolean> {
|
||||
if (!report.success) {
|
||||
console.error('[ReportGenerator] Cannot send failed report')
|
||||
return false
|
||||
}
|
||||
|
||||
const subject = `Community Report: ${schedule.name} - ${new Date().toLocaleDateString('de-DE')}`
|
||||
const recipients = schedule.recipients.map((r) => r.email)
|
||||
|
||||
try {
|
||||
for (const email of recipients) {
|
||||
if (schedule.format === 'html_email' && report.html) {
|
||||
// HTML direkt als E-Mail-Body
|
||||
await sendEmail(this.payload, null, {
|
||||
to: email,
|
||||
subject,
|
||||
html: report.html,
|
||||
source: 'system',
|
||||
})
|
||||
} else if (report.buffer && report.filename && report.mimeType) {
|
||||
// PDF oder Excel als Attachment
|
||||
await sendEmail(this.payload, null, {
|
||||
to: email,
|
||||
subject,
|
||||
html: this.generateEmailBody(schedule),
|
||||
attachments: [
|
||||
{
|
||||
filename: report.filename,
|
||||
content: report.buffer,
|
||||
contentType: report.mimeType,
|
||||
},
|
||||
],
|
||||
source: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update schedule stats
|
||||
await this.payload.update({
|
||||
collection: 'report-schedules',
|
||||
id: schedule.id,
|
||||
data: {
|
||||
lastSentAt: new Date().toISOString(),
|
||||
sendCount: (await this.getSchedule(schedule.id))?.sendCount + 1 || 1,
|
||||
lastError: null,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error('[ReportGenerator] Error sending report:', error)
|
||||
|
||||
await this.payload.update({
|
||||
collection: 'report-schedules',
|
||||
id: schedule.id,
|
||||
data: {
|
||||
lastError: message,
|
||||
},
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Daten für den Report
|
||||
*/
|
||||
private async collectReportData(schedule: ReportSchedule): Promise<ReportData> {
|
||||
const periodEnd = new Date()
|
||||
const periodStart = new Date()
|
||||
periodStart.setDate(periodStart.getDate() - schedule.periodDays)
|
||||
|
||||
const channelIds = schedule.channels?.map((c) => c.id)
|
||||
|
||||
const data: ReportData = {
|
||||
schedule,
|
||||
generatedAt: new Date(),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
}
|
||||
|
||||
// Daten je nach Report-Typ sammeln
|
||||
const types =
|
||||
schedule.reportType === 'full_report'
|
||||
? ['overview', 'sentiment_analysis', 'response_metrics', 'content_performance']
|
||||
: [schedule.reportType]
|
||||
|
||||
for (const type of types) {
|
||||
switch (type) {
|
||||
case 'overview':
|
||||
data.overview = await this.collectOverviewData(periodStart, channelIds)
|
||||
break
|
||||
case 'sentiment_analysis':
|
||||
data.sentimentAnalysis = await this.collectSentimentData(periodStart, channelIds)
|
||||
break
|
||||
case 'response_metrics':
|
||||
data.responseMetrics = await this.collectResponseData(periodStart, channelIds)
|
||||
break
|
||||
case 'content_performance':
|
||||
data.contentPerformance = await this.collectContentData(periodStart, channelIds)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Übersichtsdaten
|
||||
*/
|
||||
private async collectOverviewData(
|
||||
since: Date,
|
||||
channelIds?: number[]
|
||||
): Promise<OverviewData> {
|
||||
const where: any = {
|
||||
createdAt: { greater_than: since.toISOString() },
|
||||
}
|
||||
|
||||
if (channelIds && channelIds.length > 0) {
|
||||
where.socialAccount = { in: channelIds }
|
||||
}
|
||||
|
||||
const interactions = await this.payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const replied = interactions.docs.filter((i) => i.status === 'replied')
|
||||
const platforms = interactions.docs.reduce(
|
||||
(acc, i) => {
|
||||
const platform = (i.platform as string) || 'unknown'
|
||||
acc[platform] = (acc[platform] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
// Durchschnittliche Antwortzeit berechnen
|
||||
let totalResponseTime = 0
|
||||
let responseCount = 0
|
||||
for (const interaction of replied) {
|
||||
if (interaction.repliedAt && interaction.createdAt) {
|
||||
const responseTime =
|
||||
new Date(interaction.repliedAt as string).getTime() -
|
||||
new Date(interaction.createdAt as string).getTime()
|
||||
totalResponseTime += responseTime
|
||||
responseCount++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalInteractions: interactions.totalDocs,
|
||||
newInteractions: interactions.docs.filter((i) => i.status === 'new').length,
|
||||
repliedCount: replied.length,
|
||||
avgResponseTime: responseCount > 0 ? totalResponseTime / responseCount / (1000 * 60 * 60) : 0,
|
||||
platforms: Object.entries(platforms).map(([platform, count]) => ({ platform, count })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Sentiment-Daten
|
||||
*/
|
||||
private async collectSentimentData(
|
||||
since: Date,
|
||||
channelIds?: number[]
|
||||
): Promise<SentimentData> {
|
||||
const where: any = {
|
||||
createdAt: { greater_than: since.toISOString() },
|
||||
}
|
||||
|
||||
if (channelIds && channelIds.length > 0) {
|
||||
where.socialAccount = { in: channelIds }
|
||||
}
|
||||
|
||||
const interactions = await this.payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
let positive = 0
|
||||
let neutral = 0
|
||||
let negative = 0
|
||||
|
||||
const dailyData: Record<string, { positive: number; neutral: number; negative: number }> = {}
|
||||
|
||||
for (const interaction of interactions.docs) {
|
||||
const sentiment = (interaction.aiAnalysis as any)?.sentiment || 'neutral'
|
||||
const date = new Date(interaction.createdAt as string).toISOString().split('T')[0]
|
||||
|
||||
if (!dailyData[date]) {
|
||||
dailyData[date] = { positive: 0, neutral: 0, negative: 0 }
|
||||
}
|
||||
|
||||
if (sentiment === 'positive') {
|
||||
positive++
|
||||
dailyData[date].positive++
|
||||
} else if (sentiment === 'negative') {
|
||||
negative++
|
||||
dailyData[date].negative++
|
||||
} else {
|
||||
neutral++
|
||||
dailyData[date].neutral++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
positive,
|
||||
neutral,
|
||||
negative,
|
||||
trend: Object.entries(dailyData)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, data]) => ({ date, ...data })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Response-Metriken
|
||||
*/
|
||||
private async collectResponseData(
|
||||
since: Date,
|
||||
channelIds?: number[]
|
||||
): Promise<ResponseData> {
|
||||
const where: any = {
|
||||
createdAt: { greater_than: since.toISOString() },
|
||||
}
|
||||
|
||||
if (channelIds && channelIds.length > 0) {
|
||||
where.socialAccount = { in: channelIds }
|
||||
}
|
||||
|
||||
const interactions = await this.payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const replied = interactions.docs.filter((i) => i.status === 'replied')
|
||||
const pending = interactions.docs.filter((i) => i.status === 'new' || i.status === 'in_review')
|
||||
|
||||
// Berechne durchschnittliche Antwortzeit
|
||||
let totalTime = 0
|
||||
let count = 0
|
||||
const platformData: Record<string, { totalTime: number; count: number; replied: number }> = {}
|
||||
|
||||
for (const interaction of interactions.docs) {
|
||||
const platform = (interaction.platform as string) || 'unknown'
|
||||
if (!platformData[platform]) {
|
||||
platformData[platform] = { totalTime: 0, count: 0, replied: 0 }
|
||||
}
|
||||
platformData[platform].count++
|
||||
|
||||
if (interaction.status === 'replied' && interaction.repliedAt && interaction.createdAt) {
|
||||
const responseTime =
|
||||
new Date(interaction.repliedAt as string).getTime() -
|
||||
new Date(interaction.createdAt as string).getTime()
|
||||
totalTime += responseTime
|
||||
count++
|
||||
platformData[platform].totalTime += responseTime
|
||||
platformData[platform].replied++
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
avgResponseTimeHours: count > 0 ? totalTime / count / (1000 * 60 * 60) : 0,
|
||||
responseRate: interactions.totalDocs > 0 ? (replied.length / interactions.totalDocs) * 100 : 0,
|
||||
repliedCount: replied.length,
|
||||
pendingCount: pending.length,
|
||||
byPlatform: Object.entries(platformData).map(([platform, data]) => ({
|
||||
platform,
|
||||
avgTime: data.replied > 0 ? data.totalTime / data.replied / (1000 * 60 * 60) : 0,
|
||||
rate: data.count > 0 ? (data.replied / data.count) * 100 : 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Content-Performance-Daten
|
||||
*/
|
||||
private async collectContentData(
|
||||
since: Date,
|
||||
channelIds?: number[]
|
||||
): Promise<ContentData> {
|
||||
const where: any = {
|
||||
createdAt: { greater_than: since.toISOString() },
|
||||
}
|
||||
|
||||
if (channelIds && channelIds.length > 0) {
|
||||
where.socialAccount = { in: channelIds }
|
||||
}
|
||||
|
||||
const interactions = await this.payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
// Top Posts nach Kommentaren gruppieren
|
||||
const postData: Record<string, { title: string; platform: string; comments: number }> = {}
|
||||
const topicCount: Record<string, number> = {}
|
||||
|
||||
for (const interaction of interactions.docs) {
|
||||
const postId = (interaction.sourceId as string) || 'unknown'
|
||||
const platform = (interaction.platform as string) || 'unknown'
|
||||
const title = (interaction.sourceTitle as string) || postId
|
||||
|
||||
if (!postData[postId]) {
|
||||
postData[postId] = { title, platform, comments: 0 }
|
||||
}
|
||||
postData[postId].comments++
|
||||
|
||||
// Topics aus AI-Analyse sammeln
|
||||
const topics = (interaction.aiAnalysis as any)?.topics || []
|
||||
for (const topic of topics) {
|
||||
topicCount[topic] = (topicCount[topic] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topPosts: Object.values(postData)
|
||||
.sort((a, b) => b.comments - a.comments)
|
||||
.slice(0, 10)
|
||||
.map((p) => ({ ...p, engagement: p.comments })),
|
||||
topTopics: Object.entries(topicCount)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 15)
|
||||
.map(([topic, count]) => ({ topic, count })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert PDF-Report
|
||||
*/
|
||||
private async generatePdfReport(data: ReportData): Promise<ReportResult> {
|
||||
const html = this.generateReportHtml(data, true)
|
||||
const filename = `report-${data.schedule.name.toLowerCase().replace(/\s+/g, '-')}-${
|
||||
new Date().toISOString().split('T')[0]
|
||||
}.pdf`
|
||||
|
||||
try {
|
||||
const result = await generatePdfFromHtml(html, { filename })
|
||||
|
||||
if (!result.success || !result.buffer) {
|
||||
throw new Error(result.error || 'PDF generation failed')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
format: 'pdf',
|
||||
buffer: result.buffer,
|
||||
filename,
|
||||
mimeType: 'application/pdf',
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
success: false,
|
||||
format: 'pdf',
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Excel-Report
|
||||
*/
|
||||
private async generateExcelReport(data: ReportData): Promise<ReportResult> {
|
||||
// Einfache CSV-Generierung als Fallback (xlsx würde extra Dependency benötigen)
|
||||
const filename = `report-${data.schedule.name.toLowerCase().replace(/\s+/g, '-')}-${
|
||||
new Date().toISOString().split('T')[0]
|
||||
}.csv`
|
||||
|
||||
const rows: string[] = []
|
||||
rows.push(`Community Report: ${data.schedule.name}`)
|
||||
rows.push(`Zeitraum: ${data.periodStart.toLocaleDateString('de-DE')} - ${data.periodEnd.toLocaleDateString('de-DE')}`)
|
||||
rows.push('')
|
||||
|
||||
if (data.overview) {
|
||||
rows.push('=== ÜBERSICHT ===')
|
||||
rows.push(`Gesamt Interaktionen,${data.overview.totalInteractions}`)
|
||||
rows.push(`Neue Interaktionen,${data.overview.newInteractions}`)
|
||||
rows.push(`Beantwortet,${data.overview.repliedCount}`)
|
||||
rows.push(`Durchschn. Antwortzeit (Std),${data.overview.avgResponseTime.toFixed(1)}`)
|
||||
rows.push('')
|
||||
rows.push('Plattform,Anzahl')
|
||||
for (const p of data.overview.platforms) {
|
||||
rows.push(`${p.platform},${p.count}`)
|
||||
}
|
||||
rows.push('')
|
||||
}
|
||||
|
||||
if (data.sentimentAnalysis) {
|
||||
rows.push('=== SENTIMENT-ANALYSE ===')
|
||||
rows.push(`Positiv,${data.sentimentAnalysis.positive}`)
|
||||
rows.push(`Neutral,${data.sentimentAnalysis.neutral}`)
|
||||
rows.push(`Negativ,${data.sentimentAnalysis.negative}`)
|
||||
rows.push('')
|
||||
}
|
||||
|
||||
if (data.responseMetrics) {
|
||||
rows.push('=== ANTWORT-METRIKEN ===')
|
||||
rows.push(`Durchschn. Antwortzeit (Std),${data.responseMetrics.avgResponseTimeHours.toFixed(1)}`)
|
||||
rows.push(`Antwortrate (%),${data.responseMetrics.responseRate.toFixed(1)}`)
|
||||
rows.push(`Beantwortet,${data.responseMetrics.repliedCount}`)
|
||||
rows.push(`Ausstehend,${data.responseMetrics.pendingCount}`)
|
||||
rows.push('')
|
||||
}
|
||||
|
||||
if (data.contentPerformance) {
|
||||
rows.push('=== TOP CONTENT ===')
|
||||
rows.push('Titel,Plattform,Kommentare')
|
||||
for (const post of data.contentPerformance.topPosts) {
|
||||
rows.push(`"${post.title}",${post.platform},${post.comments}`)
|
||||
}
|
||||
rows.push('')
|
||||
rows.push('=== TOP THEMEN ===')
|
||||
rows.push('Thema,Anzahl')
|
||||
for (const topic of data.contentPerformance.topTopics) {
|
||||
rows.push(`${topic.topic},${topic.count}`)
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(rows.join('\n'), 'utf-8')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
format: 'excel',
|
||||
buffer,
|
||||
filename,
|
||||
mimeType: 'text/csv',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert HTML-Email
|
||||
*/
|
||||
private async generateHtmlEmail(data: ReportData): Promise<ReportResult> {
|
||||
const html = this.generateReportHtml(data, false)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
format: 'html_email',
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert HTML für Report
|
||||
*/
|
||||
private generateReportHtml(data: ReportData, forPdf: boolean): string {
|
||||
const styles = forPdf
|
||||
? `
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; padding: 40px; color: #333; }
|
||||
h1 { color: #1a1a2e; border-bottom: 3px solid #4361ee; padding-bottom: 10px; }
|
||||
h2 { color: #4361ee; margin-top: 30px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin: 20px 0; }
|
||||
.metric { background: #f8f9fa; padding: 20px; border-radius: 8px; text-align: center; }
|
||||
.metric-value { font-size: 32px; font-weight: bold; color: #4361ee; }
|
||||
.metric-label { font-size: 14px; color: #666; margin-top: 5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
|
||||
th { background: #f8f9fa; font-weight: 600; }
|
||||
.positive { color: #10b981; }
|
||||
.negative { color: #ef4444; }
|
||||
.neutral { color: #6b7280; }
|
||||
`
|
||||
: `
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
h1 { color: #1a1a2e; font-size: 24px; }
|
||||
h2 { color: #4361ee; font-size: 18px; margin-top: 25px; }
|
||||
.metric-grid { display: flex; flex-wrap: wrap; gap: 15px; margin: 15px 0; }
|
||||
.metric { background: #f3f4f6; padding: 15px; border-radius: 8px; flex: 1; min-width: 120px; text-align: center; }
|
||||
.metric-value { font-size: 28px; font-weight: bold; color: #4361ee; }
|
||||
.metric-label { font-size: 12px; color: #666; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 15px 0; font-size: 14px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #e5e7eb; }
|
||||
th { background: #f9fafb; }
|
||||
.positive { color: #059669; }
|
||||
.negative { color: #dc2626; }
|
||||
.neutral { color: #6b7280; }
|
||||
`
|
||||
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>${styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Community Report: ${data.schedule.name}</h1>
|
||||
<p style="color: #666;">
|
||||
Zeitraum: ${data.periodStart.toLocaleDateString('de-DE')} - ${data.periodEnd.toLocaleDateString('de-DE')}
|
||||
<br>Erstellt: ${data.generatedAt.toLocaleString('de-DE')}
|
||||
</p>
|
||||
`
|
||||
|
||||
if (data.overview) {
|
||||
html += `
|
||||
<h2>Übersicht</h2>
|
||||
<div class="metric-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.overview.totalInteractions}</div>
|
||||
<div class="metric-label">Gesamt Interaktionen</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.overview.newInteractions}</div>
|
||||
<div class="metric-label">Neu</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.overview.repliedCount}</div>
|
||||
<div class="metric-label">Beantwortet</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.overview.avgResponseTime.toFixed(1)}h</div>
|
||||
<div class="metric-label">Ø Antwortzeit</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Plattform</th><th>Interaktionen</th></tr>
|
||||
${data.overview.platforms.map((p) => `<tr><td>${p.platform}</td><td>${p.count}</td></tr>`).join('')}
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
if (data.sentimentAnalysis) {
|
||||
const total = data.sentimentAnalysis.positive + data.sentimentAnalysis.neutral + data.sentimentAnalysis.negative
|
||||
html += `
|
||||
<h2>Sentiment-Analyse</h2>
|
||||
<div class="metric-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value positive">${total > 0 ? ((data.sentimentAnalysis.positive / total) * 100).toFixed(0) : 0}%</div>
|
||||
<div class="metric-label">Positiv (${data.sentimentAnalysis.positive})</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value neutral">${total > 0 ? ((data.sentimentAnalysis.neutral / total) * 100).toFixed(0) : 0}%</div>
|
||||
<div class="metric-label">Neutral (${data.sentimentAnalysis.neutral})</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value negative">${total > 0 ? ((data.sentimentAnalysis.negative / total) * 100).toFixed(0) : 0}%</div>
|
||||
<div class="metric-label">Negativ (${data.sentimentAnalysis.negative})</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (data.responseMetrics) {
|
||||
html += `
|
||||
<h2>Antwort-Metriken</h2>
|
||||
<div class="metric-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.responseMetrics.avgResponseTimeHours.toFixed(1)}h</div>
|
||||
<div class="metric-label">Ø Antwortzeit</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.responseMetrics.responseRate.toFixed(0)}%</div>
|
||||
<div class="metric-label">Antwortrate</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-value">${data.responseMetrics.pendingCount}</div>
|
||||
<div class="metric-label">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (data.contentPerformance && data.contentPerformance.topPosts.length > 0) {
|
||||
html += `
|
||||
<h2>Top Content</h2>
|
||||
<table>
|
||||
<tr><th>Titel</th><th>Plattform</th><th>Kommentare</th></tr>
|
||||
${data.contentPerformance.topPosts
|
||||
.slice(0, 5)
|
||||
.map((p) => `<tr><td>${p.title.substring(0, 50)}${p.title.length > 50 ? '...' : ''}</td><td>${p.platform}</td><td>${p.comments}</td></tr>`)
|
||||
.join('')}
|
||||
</table>
|
||||
`
|
||||
}
|
||||
|
||||
html += `
|
||||
<hr style="margin-top: 30px; border: none; border-top: 1px solid #eee;">
|
||||
<p style="font-size: 12px; color: #999;">
|
||||
Dieser Report wurde automatisch generiert vom Community Management System.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert einfachen E-Mail-Body für Attachments
|
||||
*/
|
||||
private generateEmailBody(schedule: ReportSchedule): string {
|
||||
return `
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2>Community Report: ${schedule.name}</h2>
|
||||
<p>Im Anhang finden Sie den automatisch generierten Community Report.</p>
|
||||
<p>
|
||||
<strong>Report-Typ:</strong> ${schedule.reportType}<br>
|
||||
<strong>Zeitraum:</strong> Letzte ${schedule.periodDays} Tage<br>
|
||||
<strong>Erstellt:</strong> ${new Date().toLocaleString('de-DE')}
|
||||
</p>
|
||||
<hr>
|
||||
<p style="font-size: 12px; color: #666;">
|
||||
Dieser Report wurde automatisch vom Community Management System versendet.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt einen Schedule aus der Datenbank
|
||||
*/
|
||||
private async getSchedule(id: number): Promise<{ sendCount: number } | null> {
|
||||
try {
|
||||
const schedule = await this.payload.findByID({
|
||||
collection: 'report-schedules',
|
||||
id,
|
||||
})
|
||||
return schedule as { sendCount: number }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ReportGeneratorService
|
||||
150
src/migrations/20260116_120000_add_report_schedules.ts
Normal file
150
src/migrations/20260116_120000_add_report_schedules.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
/**
|
||||
* Migration: Add Report Schedules Collection
|
||||
*
|
||||
* Creates tables for the scheduled community reports feature.
|
||||
*/
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
// Create enums
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "enum_report_schedules_frequency" AS ENUM ('daily', 'weekly', 'monthly');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "enum_report_schedules_day_of_week" AS ENUM ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "enum_report_schedules_report_type" AS ENUM ('overview', 'sentiment_analysis', 'response_metrics', 'content_performance', 'full_report');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "enum_report_schedules_timezone" AS ENUM ('Europe/Berlin', 'Europe/London', 'America/New_York', 'America/Los_Angeles', 'Asia/Tokyo', 'UTC');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "enum_report_schedules_format" AS ENUM ('pdf', 'excel', 'html_email');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
`)
|
||||
|
||||
// Create main table
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "report_schedules" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"enabled" boolean DEFAULT true,
|
||||
"frequency" "enum_report_schedules_frequency" NOT NULL,
|
||||
"day_of_week" "enum_report_schedules_day_of_week",
|
||||
"day_of_month" numeric,
|
||||
"time" varchar DEFAULT '08:00' NOT NULL,
|
||||
"timezone" "enum_report_schedules_timezone" DEFAULT 'Europe/Berlin' NOT NULL,
|
||||
"report_type" "enum_report_schedules_report_type" DEFAULT 'overview' NOT NULL,
|
||||
"period_days" numeric DEFAULT 7,
|
||||
"format" "enum_report_schedules_format" DEFAULT 'pdf' NOT NULL,
|
||||
"last_sent_at" timestamp(3) with time zone,
|
||||
"next_scheduled_at" timestamp(3) with time zone,
|
||||
"send_count" numeric DEFAULT 0,
|
||||
"last_error" varchar,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
`)
|
||||
|
||||
// Create recipients array table
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "report_schedules_recipients" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL REFERENCES report_schedules(id) ON DELETE CASCADE,
|
||||
"email" varchar NOT NULL,
|
||||
"name" varchar
|
||||
);
|
||||
`)
|
||||
|
||||
// Create channels relationship table
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS "report_schedules_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL REFERENCES report_schedules(id) ON DELETE CASCADE,
|
||||
"path" varchar NOT NULL,
|
||||
"social_accounts_id" integer REFERENCES social_accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
// Create indexes
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "report_schedules_enabled_idx" ON "report_schedules" ("enabled");
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "report_schedules_next_scheduled_idx" ON "report_schedules" ("next_scheduled_at");
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "report_schedules_recipients_parent_idx" ON "report_schedules_recipients" ("_parent_id");
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "report_schedules_rels_parent_idx" ON "report_schedules_rels" ("parent_id");
|
||||
`)
|
||||
|
||||
// Add to payload_locked_documents_rels
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
ADD COLUMN IF NOT EXISTS "report_schedules_id" integer
|
||||
REFERENCES report_schedules(id) ON DELETE CASCADE;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE INDEX IF NOT EXISTS "payload_locked_documents_rels_report_schedules_idx"
|
||||
ON "payload_locked_documents_rels" ("report_schedules_id");
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// Drop indexes
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "payload_locked_documents_rels_report_schedules_idx";`)
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "report_schedules_rels_parent_idx";`)
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "report_schedules_recipients_parent_idx";`)
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "report_schedules_next_scheduled_idx";`)
|
||||
await db.execute(sql`DROP INDEX IF EXISTS "report_schedules_enabled_idx";`)
|
||||
|
||||
// Remove from payload_locked_documents_rels
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "payload_locked_documents_rels"
|
||||
DROP COLUMN IF EXISTS "report_schedules_id";
|
||||
`)
|
||||
|
||||
// Drop tables
|
||||
await db.execute(sql`DROP TABLE IF EXISTS "report_schedules_rels";`)
|
||||
await db.execute(sql`DROP TABLE IF EXISTS "report_schedules_recipients";`)
|
||||
await db.execute(sql`DROP TABLE IF EXISTS "report_schedules";`)
|
||||
|
||||
// Drop enums
|
||||
await db.execute(sql`DROP TYPE IF EXISTS "enum_report_schedules_format";`)
|
||||
await db.execute(sql`DROP TYPE IF EXISTS "enum_report_schedules_timezone";`)
|
||||
await db.execute(sql`DROP TYPE IF EXISTS "enum_report_schedules_report_type";`)
|
||||
await db.execute(sql`DROP TYPE IF EXISTS "enum_report_schedules_day_of_week";`)
|
||||
await db.execute(sql`DROP TYPE IF EXISTS "enum_report_schedules_frequency";`)
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ import * as migration_20260113_140000_create_yt_series from './20260113_140000_c
|
|||
import * as migration_20260113_180000_add_community_phase1 from './20260113_180000_add_community_phase1';
|
||||
import * as migration_20260114_200000_fix_community_role_enum from './20260114_200000_fix_community_role_enum';
|
||||
import * as migration_20260116_100000_add_token_notification_fields from './20260116_100000_add_token_notification_fields';
|
||||
import * as migration_20260116_120000_add_report_schedules from './20260116_120000_add_report_schedules';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -216,4 +217,9 @@ export const migrations = [
|
|||
down: migration_20260116_100000_add_token_notification_fields.down,
|
||||
name: '20260116_100000_add_token_notification_fields'
|
||||
},
|
||||
{
|
||||
up: migration_20260116_120000_add_report_schedules.up,
|
||||
down: migration_20260116_120000_add_report_schedules.down,
|
||||
name: '20260116_120000_add_report_schedules'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import { SocialAccounts } from './collections/SocialAccounts'
|
|||
import { CommunityInteractions } from './collections/CommunityInteractions'
|
||||
import { CommunityTemplates } from './collections/CommunityTemplates'
|
||||
import { CommunityRules } from './collections/CommunityRules'
|
||||
import { ReportSchedules } from './collections/ReportSchedules'
|
||||
|
||||
// Debug: Minimal test collection - DISABLED (nur für Tests)
|
||||
// import { TestMinimal } from './collections/TestMinimal'
|
||||
|
|
@ -236,6 +237,7 @@ export default buildConfig({
|
|||
CommunityInteractions,
|
||||
CommunityTemplates,
|
||||
CommunityRules,
|
||||
ReportSchedules,
|
||||
// Debug: Minimal test collection - DISABLED
|
||||
// TestMinimal,
|
||||
// Consent Management
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
{
|
||||
"path": "/api/cron/token-refresh",
|
||||
"schedule": "0 6,18 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/cron/send-reports",
|
||||
"schedule": "0 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue