cms.c2sgmbh/src/app/(payload)/api/cron/send-reports/route.ts
Martin Porwoll e3987e50dc feat: security hardening, monitoring improvements, and API guards
- Hardened cron endpoints with coordination and auth improvements
- Added API guards and input validation layer
- Security observability and secrets health checks
- Monitoring types and service improvements
- PDF URL validation and newsletter unsubscribe security
- Unit tests for security-critical paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:42:56 +00:00

349 lines
9.4 KiB
TypeScript

// 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<SendReportsBody> {
const objectResult = asObject(input)
if (!objectResult.valid) {
return objectResult as ApiValidationResult<SendReportsBody>
}
const scheduleIdResult = optionalNumber(objectResult.data, 'scheduleId')
if (!scheduleIdResult.valid) {
return scheduleIdResult as ApiValidationResult<SendReportsBody>
}
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<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