/** * PDF Generation API * * Endpunkt für PDF-Generierung über die Queue oder direkt. * Unterstützt HTML-zu-PDF und URL-zu-PDF. */ import { getPayload } from 'payload' import config from '@payload-config' import { NextRequest, NextResponse } from 'next/server' import { enqueuePdf, getPdfJobStatus, isQueueAvailable } from '@/lib/queue' import { generatePdfFromHtml, generatePdfFromUrl } from '@/lib/pdf/pdf-service' import { logAccessDenied } from '@/lib/audit/audit-service' import { publicApiLimiter, rateLimitHeaders, createSafeLogger, runApiGuards, createApiErrorResponse, } from '@/lib/security' import { asObject, validateJsonBody, validationIssue, validationErrorResponse, type ApiValidationResult, } from '@/lib/validation' const RATE_LIMIT_MAX = 10 const logger = createSafeLogger('API:GeneratePdf') interface UserWithTenants { id: number email?: string isSuperAdmin?: boolean tenants?: Array<{ tenant: { id: number } | number }> } interface GeneratePdfBody { tenantId: number source: 'html' | 'url' html?: string url?: string options: Record queued: boolean documentType?: 'invoice' | 'report' | 'export' | 'certificate' | 'other' filename?: string priority?: 'high' | 'normal' | 'low' } function validateGeneratePdfBody(input: unknown): ApiValidationResult { const objectResult = asObject(input) if (!objectResult.valid) { return objectResult as ApiValidationResult } const data = objectResult.data const issues = [] const tenantIdValue = Number(data.tenantId) if (!Number.isFinite(tenantIdValue)) { issues.push(validationIssue('tenantId', 'invalid_type', 'tenantId must be a number')) } const source = data.source if (source !== 'html' && source !== 'url') { issues.push(validationIssue('source', 'invalid_value', 'source must be "html" or "url"')) } const sourceValue = source === 'html' || source === 'url' ? source : undefined const html = typeof data.html === 'string' ? data.html : undefined const url = typeof data.url === 'string' ? data.url : undefined if (source === 'html' && !html) { issues.push(validationIssue('html', 'required', 'html is required when source="html"')) } if (source === 'url' && !url) { issues.push(validationIssue('url', 'required', 'url is required when source="url"')) } const options = data.options && typeof data.options === 'object' && !Array.isArray(data.options) ? (data.options as Record) : {} const queued = typeof data.queued === 'boolean' ? data.queued : true const documentType = ['invoice', 'report', 'export', 'certificate', 'other'].includes( String(data.documentType), ) ? (data.documentType as 'invoice' | 'report' | 'export' | 'certificate' | 'other') : undefined const filename = typeof data.filename === 'string' ? data.filename : undefined const priority = ['high', 'normal', 'low'].includes(String(data.priority)) ? (data.priority as 'high' | 'normal' | 'low') : undefined if (issues.length > 0) { return { valid: false, issues, } } return { valid: true, data: { tenantId: tenantIdValue, source: sourceValue as 'html' | 'url', html, url, options, queued, documentType, filename, priority, }, } } /** * Prüft ob User Zugriff auf den angegebenen Tenant hat */ function userHasAccessToTenant(user: UserWithTenants, tenantId: number): boolean { if (user.isSuperAdmin) { return true } if (!user.tenants || user.tenants.length === 0) { return false } return user.tenants.some((t) => { const userTenantId = typeof t.tenant === 'object' ? t.tenant.id : t.tenant return userTenantId === tenantId }) } /** * POST /api/generate-pdf * * Generiert ein PDF aus HTML oder URL. * * Body: * - tenantId: number (erforderlich) * - source: 'html' | 'url' (erforderlich) * - html?: string (wenn source='html') * - url?: string (wenn source='url') * - options?: { format, landscape, margin, printBackground, scale } * - queued?: boolean (true = async via Queue, false = sync) * - documentType?: string (invoice, report, export, etc.) * - filename?: string */ export async function POST(req: NextRequest) { try { const payload = await getPayload({ config }) const guardResult = await runApiGuards(req, { endpoint: '/api/generate-pdf', ipEndpoint: 'generatePdf', csrf: 'browser', authProvider: payload, requireUser: true, rateLimiter: publicApiLimiter, rateLimitMax: RATE_LIMIT_MAX, rateLimitIdentifier: ({ user, ip }) => typeof user === 'object' && user && 'id' in user ? String((user as { id: unknown }).id) : ip, }) if (!guardResult.ok) { return guardResult.response } const typedUser = guardResult.user as UserWithTenants const bodyResult = await validateJsonBody(req, validateGeneratePdfBody) if (!bodyResult.valid) { return validationErrorResponse(bodyResult.issues) } const { tenantId, source, html, url, options, queued, documentType, filename, priority, } = bodyResult.data // Zugriffskontrolle if (!userHasAccessToTenant(typedUser, tenantId)) { await logAccessDenied( payload, `/api/generate-pdf (tenantId: ${tenantId})`, typedUser.id, typedUser.email as string, ) return createApiErrorResponse( 403, 'FORBIDDEN', 'You do not have access to this tenant', ) } const rlHeaders = guardResult.rateLimit ? rateLimitHeaders(guardResult.rateLimit, RATE_LIMIT_MAX) : undefined // Queued PDF Generation (async) if (queued) { const queueAvailable = await isQueueAvailable() if (!queueAvailable) { return NextResponse.json( { error: 'Queue system unavailable. Use queued=false for direct generation.' }, { status: 503, headers: rlHeaders }, ) } const job = await enqueuePdf({ tenantId, source, html, url, options, documentType, filename, triggeredBy: String(typedUser.id), priority: priority || 'normal', correlationId: `pdf-${Date.now()}-${typedUser.id}`, }) return NextResponse.json( { success: true, queued: true, message: 'PDF generation job queued successfully', jobId: job.id, }, { headers: rlHeaders }, ) } // Direct PDF Generation (sync) let result if (source === 'html') { result = await generatePdfFromHtml(html || '', options) } else { result = await generatePdfFromUrl(url || '', options) } if (!result.success) { return NextResponse.json( { success: false, error: result.error }, { status: 500, headers: rlHeaders }, ) } // PDF als Base64 zurückgeben return NextResponse.json( { success: true, queued: false, pdf: result.buffer?.toString('base64'), filename: filename || `document-${Date.now()}.pdf`, pageCount: result.pageCount, fileSize: result.fileSize, duration: result.duration, }, { headers: rlHeaders }, ) } catch (error) { logger.error('generate-pdf error', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }, ) } } /** * GET /api/generate-pdf?jobId=... * * Gibt den Status/das Ergebnis eines PDF-Jobs zurück. */ export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url) const jobId = searchParams.get('jobId') // Wenn keine jobId, zeige API-Dokumentation if (!jobId) { return NextResponse.json({ endpoint: '/api/generate-pdf', methods: { POST: { description: 'Generate a PDF from HTML or URL', authentication: 'Required', body: { tenantId: 'number (required)', source: '"html" | "url" (required)', html: 'string (required if source="html")', url: 'string (required if source="url")', options: { format: '"A4" | "A3" | "Letter" | "Legal"', landscape: 'boolean', margin: '{ top, right, bottom, left }', printBackground: 'boolean', scale: 'number', }, queued: 'boolean (default: true)', documentType: 'string (invoice, report, export, etc.)', filename: 'string', priority: '"high" | "normal" | "low"', }, }, GET: { description: 'Get status/result of a PDF generation job', query: { jobId: 'string (required)', }, }, }, examples: { generateFromHtml: { tenantId: 1, source: 'html', html: '

Hello

World

', options: { format: 'A4' }, queued: true, }, generateFromUrl: { tenantId: 1, source: 'url', url: 'https://example.com/invoice/123', queued: false, }, }, }) } const payload = await getPayload({ config }) const guardResult = await runApiGuards(req, { endpoint: '/api/generate-pdf', csrf: 'none', authProvider: payload, requireUser: true, }) if (!guardResult.ok) { return guardResult.response } // Job-Status abrufen const status = await getPdfJobStatus(jobId) if (!status) { return NextResponse.json({ error: 'Job not found' }, { status: 404 }) } // Wenn abgeschlossen, Ergebnis mitliefern if (status.state === 'completed' && status.result) { return NextResponse.json({ jobId, state: status.state, result: status.result, }) } return NextResponse.json({ jobId, state: status.state, progress: status.progress, failedReason: status.failedReason, }) } catch (error) { logger.error('generate-pdf status error', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }, ) } }