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:
Martin Porwoll 2026-01-16 22:10:30 +00:00
parent 7774616f00
commit cb035d209d
10 changed files with 2168 additions and 0 deletions

View file

@ -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/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/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:** **Authentifizierung:**
Alle Cron-Endpoints erfordern `Authorization: Bearer $CRON_SECRET` Header. Alle Cron-Endpoints erfordern `Authorization: Bearer $CRON_SECRET` Header.

View 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

View 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

View 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

View 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

View 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

View 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";`)
}

View file

@ -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_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_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_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 = [ export const migrations = [
{ {
@ -216,4 +217,9 @@ export const migrations = [
down: migration_20260116_100000_add_token_notification_fields.down, down: migration_20260116_100000_add_token_notification_fields.down,
name: '20260116_100000_add_token_notification_fields' 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'
},
]; ];

View file

@ -86,6 +86,7 @@ import { SocialAccounts } from './collections/SocialAccounts'
import { CommunityInteractions } from './collections/CommunityInteractions' import { CommunityInteractions } from './collections/CommunityInteractions'
import { CommunityTemplates } from './collections/CommunityTemplates' import { CommunityTemplates } from './collections/CommunityTemplates'
import { CommunityRules } from './collections/CommunityRules' import { CommunityRules } from './collections/CommunityRules'
import { ReportSchedules } from './collections/ReportSchedules'
// Debug: Minimal test collection - DISABLED (nur für Tests) // Debug: Minimal test collection - DISABLED (nur für Tests)
// import { TestMinimal } from './collections/TestMinimal' // import { TestMinimal } from './collections/TestMinimal'
@ -236,6 +237,7 @@ export default buildConfig({
CommunityInteractions, CommunityInteractions,
CommunityTemplates, CommunityTemplates,
CommunityRules, CommunityRules,
ReportSchedules,
// Debug: Minimal test collection - DISABLED // Debug: Minimal test collection - DISABLED
// TestMinimal, // TestMinimal,
// Consent Management // Consent Management

View file

@ -8,6 +8,10 @@
{ {
"path": "/api/cron/token-refresh", "path": "/api/cron/token-refresh",
"schedule": "0 6,18 * * *" "schedule": "0 6,18 * * *"
},
{
"path": "/api/cron/send-reports",
"schedule": "0 * * * *"
} }
] ]
} }