From cb035d209dd9d1beaa7c2325fd61db555a0454a1 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Fri, 16 Jan 2026 22:10:30 +0000 Subject: [PATCH] feat(dashboard): Phase 3 - Scheduled Reports & Real-time Updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 1 + .../(payload)/api/community/stream/route.ts | 243 ++++++ .../(payload)/api/cron/send-reports/route.ts | 325 ++++++++ src/collections/ReportSchedules.ts | 377 +++++++++ src/hooks/useRealtimeUpdates.ts | 293 +++++++ src/lib/services/ReportGeneratorService.ts | 767 ++++++++++++++++++ .../20260116_120000_add_report_schedules.ts | 150 ++++ src/migrations/index.ts | 6 + src/payload.config.ts | 2 + vercel.json | 4 + 10 files changed, 2168 insertions(+) create mode 100644 src/app/(payload)/api/community/stream/route.ts create mode 100644 src/app/(payload)/api/cron/send-reports/route.ts create mode 100644 src/collections/ReportSchedules.ts create mode 100644 src/hooks/useRealtimeUpdates.ts create mode 100644 src/lib/services/ReportGeneratorService.ts create mode 100644 src/migrations/20260116_120000_add_report_schedules.ts diff --git a/CLAUDE.md b/CLAUDE.md index 21cd5d4..ce4ba7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/app/(payload)/api/community/stream/route.ts b/src/app/(payload)/api/community/stream/route.ts new file mode 100644 index 0000000..e2a3bd5 --- /dev/null +++ b/src/app/(payload)/api/community/stream/route.ts @@ -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 { + 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 diff --git a/src/app/(payload)/api/cron/send-reports/route.ts b/src/app/(payload)/api/cron/send-reports/route.ts new file mode 100644 index 0000000..cefdf55 --- /dev/null +++ b/src/app/(payload)/api/cron/send-reports/route.ts @@ -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 { + // 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 { + 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 = { + 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 { + 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 diff --git a/src/collections/ReportSchedules.ts b/src/collections/ReportSchedules.ts new file mode 100644 index 0000000..24f69c1 --- /dev/null +++ b/src/collections/ReportSchedules.ts @@ -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 = { + 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 diff --git a/src/hooks/useRealtimeUpdates.ts b/src/hooks/useRealtimeUpdates.ts new file mode 100644 index 0000000..c344194 --- /dev/null +++ b/src/hooks/useRealtimeUpdates.ts @@ -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([]) + const [newCount, setNewCount] = useState(0) + const [error, setError] = useState(null) + + const eventSourceRef = useRef(null) + const reconnectTimeoutRef = useRef(null) + const lastTimestampRef = useRef(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 diff --git a/src/lib/services/ReportGeneratorService.ts b/src/lib/services/ReportGeneratorService.ts new file mode 100644 index 0000000..a2aa836 --- /dev/null +++ b/src/lib/services/ReportGeneratorService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + ) + + // 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 { + 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 = {} + + 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 { + 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 = {} + + 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 { + 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 = {} + const topicCount: Record = {} + + 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 { + 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 { + // 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 { + 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 = ` + + + + + + + +

Community Report: ${data.schedule.name}

+

+ Zeitraum: ${data.periodStart.toLocaleDateString('de-DE')} - ${data.periodEnd.toLocaleDateString('de-DE')} +
Erstellt: ${data.generatedAt.toLocaleString('de-DE')} +

+ ` + + if (data.overview) { + html += ` +

Übersicht

+
+
+
${data.overview.totalInteractions}
+
Gesamt Interaktionen
+
+
+
${data.overview.newInteractions}
+
Neu
+
+
+
${data.overview.repliedCount}
+
Beantwortet
+
+
+
${data.overview.avgResponseTime.toFixed(1)}h
+
Ø Antwortzeit
+
+
+ + + ${data.overview.platforms.map((p) => ``).join('')} +
PlattformInteraktionen
${p.platform}${p.count}
+ ` + } + + if (data.sentimentAnalysis) { + const total = data.sentimentAnalysis.positive + data.sentimentAnalysis.neutral + data.sentimentAnalysis.negative + html += ` +

Sentiment-Analyse

+
+
+
${total > 0 ? ((data.sentimentAnalysis.positive / total) * 100).toFixed(0) : 0}%
+
Positiv (${data.sentimentAnalysis.positive})
+
+
+
${total > 0 ? ((data.sentimentAnalysis.neutral / total) * 100).toFixed(0) : 0}%
+
Neutral (${data.sentimentAnalysis.neutral})
+
+
+
${total > 0 ? ((data.sentimentAnalysis.negative / total) * 100).toFixed(0) : 0}%
+
Negativ (${data.sentimentAnalysis.negative})
+
+
+ ` + } + + if (data.responseMetrics) { + html += ` +

Antwort-Metriken

+
+
+
${data.responseMetrics.avgResponseTimeHours.toFixed(1)}h
+
Ø Antwortzeit
+
+
+
${data.responseMetrics.responseRate.toFixed(0)}%
+
Antwortrate
+
+
+
${data.responseMetrics.pendingCount}
+
Ausstehend
+
+
+ ` + } + + if (data.contentPerformance && data.contentPerformance.topPosts.length > 0) { + html += ` +

Top Content

+ + + ${data.contentPerformance.topPosts + .slice(0, 5) + .map((p) => ``) + .join('')} +
TitelPlattformKommentare
${p.title.substring(0, 50)}${p.title.length > 50 ? '...' : ''}${p.platform}${p.comments}
+ ` + } + + html += ` +
+

+ Dieser Report wurde automatisch generiert vom Community Management System. +

+ + + ` + + return html + } + + /** + * Generiert einfachen E-Mail-Body für Attachments + */ + private generateEmailBody(schedule: ReportSchedule): string { + return ` + + +

Community Report: ${schedule.name}

+

Im Anhang finden Sie den automatisch generierten Community Report.

+

+ Report-Typ: ${schedule.reportType}
+ Zeitraum: Letzte ${schedule.periodDays} Tage
+ Erstellt: ${new Date().toLocaleString('de-DE')} +

+
+

+ Dieser Report wurde automatisch vom Community Management System versendet. +

+ + + ` + } + + /** + * 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 diff --git a/src/migrations/20260116_120000_add_report_schedules.ts b/src/migrations/20260116_120000_add_report_schedules.ts new file mode 100644 index 0000000..08467c4 --- /dev/null +++ b/src/migrations/20260116_120000_add_report_schedules.ts @@ -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 { + // 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 { + // 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";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index c84d04d..d081b7b 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -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' + }, ]; diff --git a/src/payload.config.ts b/src/payload.config.ts index 546bc85..2bd8355 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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 diff --git a/vercel.json b/vercel.json index 058eeb5..2fb533a 100644 --- a/vercel.json +++ b/vercel.json @@ -8,6 +8,10 @@ { "path": "/api/cron/token-refresh", "schedule": "0 6,18 * * *" + }, + { + "path": "/api/cron/send-reports", + "schedule": "0 * * * *" } ] }