// 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' import { requireCronAuth, withCronExecution } from '@/lib/security' import { asObject, optionalNumber, validateJsonBody, validationIssue, validationErrorResponse, type ApiValidationResult, } from '@/lib/validation' // Status für Monitoring let isRunning = false let lastRunAt: Date | null = null let lastResult: { success: boolean; sent: number; failed: number } | null = null interface SendReportsBody { scheduleId: number } function validateSendReportsBody(input: unknown): ApiValidationResult { const objectResult = asObject(input) if (!objectResult.valid) { return objectResult as ApiValidationResult } const scheduleIdResult = optionalNumber(objectResult.data, 'scheduleId') if (!scheduleIdResult.valid) { return scheduleIdResult as ApiValidationResult } if (scheduleIdResult.data === undefined) { return { valid: false, issues: [validationIssue('scheduleId', 'required', 'scheduleId is required')], } } return { valid: true, data: { scheduleId: scheduleIdResult.data, }, } } /** * 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) { return withCronExecution(request, { endpoint: 'send-reports' }, async () => { 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) { return withCronExecution(request, { endpoint: 'send-reports' }, async () => { try { const bodyResult = await validateJsonBody(request, validateSendReportsBody) if (!bodyResult.valid) { return validationErrorResponse(bodyResult.issues) } const { scheduleId } = bodyResult.data 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(request: NextRequest) { const authError = requireCronAuth(request, 'send-reports') if (authError) { return new NextResponse(null, { status: authError.status }) } return new NextResponse(null, { status: isRunning ? 423 : 200, headers: { 'X-Running': isRunning.toString(), 'X-Last-Run': lastRunAt?.toISOString() || 'never', 'X-Last-Sent': lastResult?.sent.toString() || '0', 'X-Last-Failed': lastResult?.failed.toString() || '0', }, }) } /** * Findet fällige Report-Schedules */ async function findDueSchedules(payload: any, now: Date): Promise { // Schedules laden die: // 1. Aktiv sind // 2. nextScheduledAt <= jetzt const schedules = await payload.find({ collection: 'report-schedules', where: { and: [ { enabled: { equals: true } }, { or: [ { nextScheduledAt: { less_than_equal: now.toISOString() } }, { nextScheduledAt: { equals: null } }, ], }, ], }, depth: 2, limit: 50, }) return schedules.docs.map(transformSchedule) } /** * Transformiert DB-Dokument zu ReportSchedule */ function transformSchedule(doc: any): ReportSchedule { return { id: doc.id, name: doc.name, enabled: doc.enabled, frequency: doc.frequency, dayOfWeek: doc.dayOfWeek, dayOfMonth: doc.dayOfMonth, time: doc.time, timezone: doc.timezone, reportType: doc.reportType, channels: doc.channels?.map((c: any) => ({ id: typeof c === 'number' ? c : c.id, displayName: c.displayName, })), periodDays: doc.periodDays || 7, format: doc.format, recipients: doc.recipients || [], } } /** * Aktualisiert den nächsten Versandzeitpunkt */ async function updateNextScheduledTime(payload: any, schedule: ReportSchedule): Promise { const next = calculateNextScheduledTime(schedule) await payload.update({ collection: 'report-schedules', id: schedule.id, data: { nextScheduledAt: next?.toISOString(), }, }) } /** * Berechnet den nächsten Versandzeitpunkt */ function calculateNextScheduledTime(schedule: ReportSchedule): Date | null { const [hours, minutes] = schedule.time.split(':').map(Number) const now = new Date() const next = new Date() next.setHours(hours, minutes, 0, 0) // Mindestens 1 Stunde in der Zukunft if (next <= now) { next.setDate(next.getDate() + 1) } switch (schedule.frequency) { case 'daily': // Bereits korrekt break case 'weekly': const dayMap: Record = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, } const targetDay = dayMap[schedule.dayOfWeek || 'monday'] const currentDay = next.getDay() let daysUntil = targetDay - currentDay if (daysUntil <= 0) daysUntil += 7 next.setDate(next.getDate() + daysUntil) break case 'monthly': const targetDate = schedule.dayOfMonth || 1 next.setDate(targetDate) if (next <= now) { next.setMonth(next.getMonth() + 1) } break } return next } /** * Aktualisiert den Fehler-Status eines Schedules */ async function updateScheduleError(payload: any, id: number, error: string): Promise { await payload.update({ collection: 'report-schedules', id, data: { lastError: error.substring(0, 255), }, }) } export const dynamic = 'force-dynamic' export const maxDuration = 120 // 2 Minuten max