cms.c2sgmbh/src/app/(payload)/api/generate-pdf/route.ts

384 lines
10 KiB
TypeScript

/**
* 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<string, unknown>
queued: boolean
documentType?: 'invoice' | 'report' | 'export' | 'certificate' | 'other'
filename?: string
priority?: 'high' | 'normal' | 'low'
}
function validateGeneratePdfBody(input: unknown): ApiValidationResult<GeneratePdfBody> {
const objectResult = asObject(input)
if (!objectResult.valid) {
return objectResult as ApiValidationResult<GeneratePdfBody>
}
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<string, unknown>)
: {}
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: '<h1>Hello</h1><p>World</p>',
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 },
)
}
}