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