mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 15:04:14 +00:00
- 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>
349 lines
9.4 KiB
TypeScript
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
|