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