Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 10s

This commit is contained in:
Lucas Orth
2026-08-24 12:13:50 +02:00
commit 0ddaab27d0
34 changed files with 12768 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
/**
* Charge-Controller – Sammel-PDF für API-Chargen.
*
* Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B.
* Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem
* Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit
* sie zur Kuvert-Reihenfolge im Plotter passt.
*/
const { PDFDocument } = require('pdf-lib');
const directus = require('../../services/directus-service');
/**
* POST /api/charge/merge-pdf
* Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt)
*/
async function mergePdf(req, res, next) {
try {
const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!ids.length) {
return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' });
}
const orders = await directus.fetchOrdersMinimal(ids);
if (!orders || !orders.length) {
return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' });
}
// Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert).
const fileIds = [];
for (const o of orders) {
const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : [];
if (sf.length) fileIds.push(...sf);
else if (o.source_file) fileIds.push(o.source_file);
}
if (!fileIds.length) {
return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' });
}
const merged = await PDFDocument.create();
let ok = 0;
const skipped = [];
for (const fid of fileIds) {
try {
const buf = await directus.fetchAssetBuffer(fid);
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
ok += 1;
} catch (e) {
skipped.push(fid);
console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`);
}
}
if (!ok) {
return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' });
}
const bytes = await merged.save();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
res.setHeader('X-Skrift-Merged', String(ok));
if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length));
return res.status(200).send(Buffer.from(bytes));
} catch (err) {
return next(err);
}
}
module.exports = { mergePdf };

View File

@@ -0,0 +1,225 @@
/**
* Generierung auf Basis eines Directus-Auftrags.
*
* Ablauf: Directus meldet "Auftrag X ist bezahlt" → dieser Endpunkt holt den
* Auftrag samt Empfängerzeilen aus Directus, baut daraus die Dokumentliste für
* die bestehende Pipeline (runGeneration) und schreibt Ergebnis + Status zurück.
*
* Antwortet sofort mit 202 – die Generierung läuft im Hintergrund weiter, weil
* sie je nach Menge deutlich länger dauern kann als ein HTTP-Timeout erlaubt.
*/
const { runGeneration } = require('./order-controller');
const directus = require('../../services/directus-service');
const { countLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders } = require('../../services/placeholder-service');
// Directus-Formatschlüssel → Backend-Formate
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
const ENVELOPE_FORMAT = { c6: 'c6', dinlang: 'din_lang' };
/** Baut den Adressblock für einen Umschlag aus einer Empfängerzeile. */
function addressText(entry) {
if (entry.free_text) return String(entry.free_text).trim();
const name = [entry.first_name, entry.last_name].filter(Boolean).join(' ');
const street = [entry.street, entry.house_no].filter(Boolean).join(' ');
const city = [entry.zip, entry.city].filter(Boolean).join(' ');
const isForeign = entry.country && !/^(de|deutschland|germany)$/i.test(String(entry.country).trim());
return [name, street, city, isForeign ? entry.country : null]
.filter(Boolean).join('\n');
}
/** Platzhalterwerte einer Zeile (strukturierte Felder + freie Platzhalter). */
function placeholdersOf(entry) {
return {
Anrede: entry.salutation || '',
Vorname: entry.first_name || '',
Nachname: entry.last_name || '',
Strasse: [entry.street, entry.house_no].filter(Boolean).join(' '),
PLZ: entry.zip || '',
Ort: entry.city || '',
Land: entry.country || '',
Briefnummer: entry.letter_number ?? '',
...(entry.placeholders && typeof entry.placeholders === 'object' ? entry.placeholders : {}),
};
}
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
function buildDocuments(order, entries) {
const font = order.font || 'tilda';
// Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
// Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom
// Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon.
const istDatei = order.product?.input_mode === 'datei';
// Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten)
// Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag.
const istKuvertOnly = order.product?.type === 'envelope';
// Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert.
const realistisch = order.realistic !== false;
// Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard.
const fontScaleLetter = Number(order.font_scale_letter) || 1;
const fontScaleEnv = Number(order.font_scale_envelope) || 1;
// Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung.
const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null;
const sigMap = sigManual?.signatures || {};
const sigOff = sigManual?.offsets || {};
// Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt.
const istMehrseitig = !!order.multi_page;
const documents = [];
entries.forEach((entry, i) => {
const placeholders = placeholdersOf(entry);
// Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
const nr = Number(entry.letter_number) || (i + 1);
if (!istDatei && !istKuvertOnly) {
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext.
const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || '');
documents.push({
index: nr,
type: 'letter',
text: briefText,
format: letterFormat,
font,
placeholders,
fontScale: fontScaleLetter,
signatures: sigMap,
signatureOffsets: sigOff,
multiPage: istMehrseitig,
realisticHandwriting: realistisch,
});
}
if (wantsEnvelope) {
documents.push({
index: nr,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
format: envFormat,
font,
placeholders,
fontScale: fontScaleEnv,
realisticHandwriting: realistisch,
});
}
});
return { documents, letterFormat, envFormat, wantsEnvelope };
}
/**
* POST /api/order/from-directus
* Body: { orderId }
*/
async function generateFromDirectus(req, res, next) {
const { orderId } = req.body || {};
if (!orderId) {
return res.status(400).json({ error: 'Invalid request', message: 'orderId is required' });
}
let order;
try {
const loaded = await directus.fetchOrder(orderId);
order = loaded.order;
if (!loaded.entries.length) {
return res.status(400).json({
error: 'Invalid order',
message: `Auftrag ${order.order_number} hat keine Empfängerzeilen.`
});
}
// Sofort quittieren – der Rest läuft im Hintergrund.
res.status(202).json({
accepted: true,
orderId,
orderNumber: order.order_number,
documents: loaded.entries.length * (order.needs_envelope ? 2 : 1),
});
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
// Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen.
if (!documents.length) {
console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`);
return;
}
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
const result = await runGeneration({
orderNumber: order.order_number,
letters: documents,
metadata: {
letterFormat,
envelopeFormat: wantsEnvelope ? envFormat : null,
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
directusOrderId: orderId,
customerEmail: order.customer_email,
// Vorschau-Session → gezeigte Dokumente 1:1 übernehmen.
previewSession: order.preview_session || null,
// Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen.
rawText: order.text_template || null,
},
});
await directus.updateOrder(orderId, {
status: 'in_queue',
artifact_path: result.outputPath,
});
await directus.addStatusHistory(orderId, 'in_queue',
`${result.letters.length} Schriftstücke, ${result.envelopes.length} Umschläge generiert.`);
console.log(`[Directus] ${order.order_number} fertig → in_queue`);
} catch (err) {
console.error('[Directus] Generierung fehlgeschlagen:', err.message);
// Fehler in Directus sichtbar machen, statt still zu scheitern.
if (order?.id || orderId) {
try {
await directus.addStatusHistory(orderId, 'fehler', `Generierung fehlgeschlagen: ${err.message}`);
} catch (e) {
console.error('[Directus] Konnte Fehler nicht zurückschreiben:', e.message);
}
}
// Antwort wurde ggf. schon gesendet (202) – dann nur noch loggen.
if (!res.headersSent) {
if (err.status) return res.status(err.status).json({ error: 'Generation failed', message: err.message });
return next(err);
}
}
}
/**
* POST /api/order/pagecount
* Body: { text, format, fontScale, entries: [{ placeholders }] }
* Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG).
*/
function pageCounts(req, res) {
const { text, format, fontScale, entries } = req.body || {};
if (!text || !String(text).trim()) {
return res.status(400).json({ error: 'Invalid request', message: 'text is required' });
}
const fmt = format || 'a4';
const scale = Number(fontScale) || 1;
const list = Array.isArray(entries) && entries.length ? entries : [{}];
const counts = list.map((e, i) => {
const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) };
let pages = 1;
try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); }
catch { pages = 1; }
return { index: i + 1, pages };
});
return res.json({ counts });
}
module.exports = { generateFromDirectus, pageCounts };

View File

@@ -0,0 +1,601 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const config = require('../../config');
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
const { generateLetterSVG, generateEnvelopeSVG, generateLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
const { TAB_MARKER } = require('../../lib/svg-font-engine');
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*/
async function finalizeOrder(req, res, next) {
try {
const { sessionId, orderNumber } = req.body;
// Validation
if (!sessionId || !orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'sessionId and orderNumber are required'
});
}
console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`);
// Check if session cache exists
const sessionDir = path.join(config.paths.previews, sessionId);
try {
await fs.access(sessionDir);
} catch {
return res.status(404).json({
error: 'Session not found',
message: `Preview cache not found for session: ${sessionId}. Please generate previews first.`
});
}
// Check cache expiration
const metadataPath = path.join(sessionDir, '.metadata.json');
try {
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
const expiresAt = new Date(metadata.expiresAt);
if (new Date() > expiresAt) {
return res.status(410).json({
error: 'Cache expired',
message: 'Preview cache has expired. Please regenerate previews.',
expiredAt: metadata.expiresAt
});
}
} catch (err) {
console.warn(`[Order] Metadata not found for session: ${sessionId}`);
}
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Copy files from cache to output, separating letters and envelopes
const files = await fs.readdir(sessionDir);
const copiedLetters = [];
const copiedEnvelopes = [];
const copiedOther = [];
for (const file of files) {
// Skip metadata file
if (file === '.metadata.json') {
continue;
}
const sourcePath = path.join(sessionDir, file);
// Determine destination based on file type
if (file.startsWith('envelope_')) {
// Umschläge in Unterordner 'umschlaege'
const destPath = path.join(envelopesDir, file);
await fs.copyFile(sourcePath, destPath);
copiedEnvelopes.push(file);
} else if (file.startsWith('letter_')) {
// Briefe im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedLetters.push(file);
} else {
// Andere Dateien (CSV, etc.) im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedOther.push(file);
}
}
// Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
sessionId,
finalizedAt: new Date().toISOString(),
letterCount: copiedLetters.length,
envelopeCount: copiedEnvelopes.length,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length;
console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
totalFiles,
envelopesGenerated: 0,
timestamp: orderMetadata.finalizedAt
});
} catch (err) {
console.error('[Order] Error finalizing order:', err);
next(err);
}
}
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function runGeneration({ orderNumber, letters, metadata }) {
{
// Validation
if (!orderNumber) {
const e = new Error('orderNumber is required');
e.status = 400;
throw e;
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
const e = new Error('Letters array is required and must not be empty');
e.status = 400;
throw e;
}
console.log(`[Order] Generating order: ${orderNumber}`);
console.log(`[Order] Total documents: ${letters.length}`);
// Debug: Zeige welche Dokument-Typen ankommen
const letterCount = letters.filter(l => l.type !== 'envelope').length;
const envelopeCount = letters.filter(l => l.type === 'envelope').length;
console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`);
console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType })));
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || '';
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === 'object') {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex,
processedText: text,
// Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge).
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient',
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
// Step 2: Group letters by font for batch scriptalization
const lettersByFont = {};
processedLetters.forEach(letter => {
const font = letter.font;
if (!lettersByFont[font]) {
lettersByFont[font] = [];
}
lettersByFont[font].push(letter);
});
// Step 3: Scriptalize texts by font
const scriptalizedMap = new Map();
for (const [font, fontLetters] of Object.entries(lettersByFont)) {
// Tabs (\t) und Signatur-Marker ([[signaturN]]) vor Scriptalisierung extrahieren,
// danach mit TAB_MARKER bzw. [[signaturN]] restaurieren.
const scriptalizerInputs = [];
const splitMaps = [];
for (const letter of fontLetters) {
const raw = letter.processedText;
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ loopIndex: letter.loopIndex, startIdx, segments, markers, hasContent, fallback: raw });
}
console.log(`[Order] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`);
try {
const scriptalizedAll = await scriptalizeBatch(scriptalizerInputs, font, config.scriptalizer.errFrequency);
for (const { loopIndex, startIdx, segments, markers, hasContent, fallback } of splitMaps) {
const nonEmptyCount = hasContent.filter(Boolean).length;
if (!nonEmptyCount && !markers.length) { scriptalizedMap.set(loopIndex, fallback); continue; }
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let reconstructed = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
reconstructed += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
reconstructed += (segs[i + 1] || '');
}
scriptalizedMap.set(loopIndex, reconstructed);
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
e.status = 500;
e.label = 'Scriptalization failed';
throw e;
}
}
// Step 4: Generate SVG files - separate letters and envelopes
const generatedLetters = [];
const generatedEnvelopes = [];
// Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1
// aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte
// Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift).
const previewSession = (metadata || {}).previewSession;
const previewsRoot = config.paths.previews;
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const isEnv = letter.type === 'envelope';
const pad = String(absoluteIndex).padStart(3, '0');
// Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N).
if (!isEnv && letter.multiPage) {
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let pages;
try {
({ pages } = generateLetterPages(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
}));
} catch (err) {
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500; e.label = 'SVG generation failed'; throw e;
}
// Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern –
// nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite.
let alleInVorschau = false;
if (previewSession) {
alleInVorschau = true;
for (let p = 0; p < pages.length; p++) {
try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); }
catch { alleInVorschau = false; break; }
}
}
for (let p = 0; p < pages.length; p++) {
const fn = `letter_${pad}_p${p + 1}.svg`;
const fp = path.join(outputDir, fn);
if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp);
else await fs.writeFile(fp, pages[p], 'utf8');
generatedLetters.push(fn);
}
console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`);
continue;
}
const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.svg`;
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
// Vorschau-Datei existiert.
if (previewSession) {
const cached = path.join(previewsRoot, previewSession, filename);
try {
await fs.access(cached);
await fs.copyFile(cached, filepath);
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`);
continue;
} catch { /* keine Vorschau-Datei → normal rendern */ }
}
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let svgResult;
try {
if (isEnv) {
svgResult = generateEnvelopeSVG(
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText, letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale }
);
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500;
e.label = 'SVG generation failed';
throw e;
}
await fs.writeFile(filepath, svgResult.svg, 'utf8');
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
}
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
// Step 5: Generate all CSV files if placeholders are present
const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0);
const csvFiles = [];
if (hasPlaceholders) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = 'brief_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = 'empfaenger.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = 'umschlag_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated umschlag_platzhalter.csv`);
}
} catch (err) {
console.error('[Order] CSV generation failed:', err.message);
// Don't fail the request, CSV is optional
}
}
// Step 6: Create order metadata
const meta = metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
letterCount: generatedLetters.length,
envelopeCount: generatedEnvelopes.length,
letters: generatedLetters,
envelopes: generatedEnvelopes,
fonts: Object.keys(lettersByFont),
hasPlaceholders,
csvFiles,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
// Eingegebenen Text als reine Textdatei mitablegen (falls übergeben).
if (meta.rawText) {
try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); }
catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); }
}
console.log(`[Order] Order ${orderNumber} generated successfully`);
return {
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
envelopes: generatedEnvelopes,
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
};
}
}
/**
* POST /api/order/generate
* HTTP-Wrapper um runGeneration – Verhalten unverändert.
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters, metadata } = req.body;
const result = await runGeneration({ orderNumber, letters, metadata });
res.status(200).json(result);
} catch (err) {
if (err.status) {
return res.status(err.status).json({
error: err.label || 'Invalid request',
message: err.message
});
}
console.error('[Order] Error generating order:', err);
next(err);
}
}
/**
* POST /api/order/motif
* Upload a motif image for an order
*/
async function uploadMotif(req, res, next) {
try {
const { orderNumber } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
console.log(`[Order] Uploading motif for order: ${orderNumber}`);
console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`);
// Create output directory if it doesn't exist
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
// Determine file extension
const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png';
const filename = `motif${originalExt}`;
const filepath = path.join(outputDir, filename);
// Write file to disk
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] Motif saved to: ${filepath}`);
// Response
res.status(200).json({
success: true,
orderNumber,
filename,
path: filepath,
size: req.file.size,
mimetype: req.file.mimetype
});
} catch (err) {
console.error('[Order] Error uploading motif:', err);
next(err);
}
}
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adress-/Platzhalter-Excel, Briefdokument) im Auftragsordner ab,
* damit sie zusammen mit den generierten Umschlägen vorliegt.
*/
async function uploadOrderFile(req, res, next) {
try {
const { orderNumber, fileType } = req.body;
if (!orderNumber || !fileType) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber and fileType are required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
const outputDir = path.join(config.paths.output, orderNumber, 'dateien');
await fs.mkdir(outputDir, { recursive: true });
const ext = path.extname(req.file.originalname) || '';
const filename = `${fileType}${ext}`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] File saved for order ${orderNumber}: ${filename}`);
res.status(200).json({
success: true,
orderNumber,
fileType,
filename,
path: filepath,
size: req.file.size,
});
} catch (err) {
console.error('[Order] Error saving order file:', err);
next(err);
}
}
/**
* POST /api/order/register
* Speichert Metadaten für einen Auftrag ohne SVG-Generierung (z.B. gedruckte Briefe)
*/
async function registerOrder(req, res, next) {
try {
const { orderNumber, metadata } = req.body;
if (!orderNumber) {
return res.status(400).json({ error: 'Invalid request', message: 'orderNumber is required' });
}
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
const orderMetadata = {
orderNumber,
registeredAt: new Date().toISOString(),
...(metadata || {}),
};
await fs.writeFile(
path.join(outputDir, 'order-metadata.json'),
JSON.stringify(orderMetadata, null, 2),
'utf8'
);
console.log(`[Order] Registered order ${orderNumber}`);
res.status(200).json({ orderNumber, registeredAt: orderMetadata.registeredAt });
} catch (err) {
console.error('[Order] Error registering order:', err);
next(err);
}
}
module.exports = {
finalizeOrder,
generateOrder,
runGeneration,
uploadMotif,
uploadOrderFile,
registerOrder,
};

View File

@@ -0,0 +1,363 @@
const fs = require("fs").promises;
const fsSync = require("fs");
const path = require("path");
const { v4: uuidv4 } = require("uuid");
const config = require("../../config");
const { scriptalizeBatch } = require("../../services/scriptalizer-service");
const {
generateLetterSVG,
generateEnvelopeSVG,
generateLetterPages,
} = require("../../lib/svg-generator");
const {
replacePlaceholders,
generateAllCSVs,
} = require("../../services/placeholder-service");
const { TAB_MARKER } = require("../../lib/svg-font-engine");
/**
* POST /api/preview/batch
* Generate preview batch with rate limiting
*/
async function generateBatch(req, res, next) {
try {
const { sessionId, letters } = req.body;
// Validation
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: "Invalid request",
message: "Letters array is required and must not be empty",
});
}
// KEIN Limit mehr - Frontend sendet ALLE Dokumente auf einmal
// Backend teilt sie intern in 25er Batches für Scriptalizer auf
console.log(`[Preview] Received ${letters.length} letters (no limit)`);
// Optional: Warn if extremely large
if (letters.length > 1000) {
console.warn(`[Preview] Large batch detected: ${letters.length} letters`);
}
// Generate or validate sessionId
const finalSessionId = sessionId || uuidv4();
console.log(
`[Preview] Starting batch generation for session: ${finalSessionId}`
);
console.log(`[Preview] Batch size: ${letters.length} letters`);
// Create session cache directory
const sessionDir = path.join(config.paths.previews, finalSessionId);
await fs.mkdir(sessionDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
console.log(
"[Preview] Received indices from frontend:",
letters.map((l) => l.index)
);
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || "";
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === "object") {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
processedText: text,
// Signaturen aus Platzhaltern UND direkt mitgeschickte (manuelle Aufträge).
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
format: letter.format || "a4",
font: letter.font || "tilda",
type: letter.type || "letter",
envelopeType: letter.envelopeType || "recipient",
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
console.log(
"[Preview] Final indices being used for files:",
processedLetters.map((l) => l.index)
);
// Step 2: Batch scriptalize texts.
// Tabs (\t) und Signatur-Marker ([[signaturN]]) werden vor der Scriptalisierung
// extrahiert, anschliessend wieder restauriert (TAB_MARKER für Tabs, {{signaturN}} unverändert).
const font = processedLetters[0]?.font || "tilda";
const scriptalizerInputs = [];
const splitMaps = []; // pro Brief: { segments[], markers[] }
for (const letter of processedLetters) {
const raw = letter.processedText;
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden (API könnte sie ablehnen)
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ startIdx, segments, markers, hasContent });
}
console.log(
`[Preview] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`
);
let scriptalizedAll;
try {
scriptalizedAll = await scriptalizeBatch(
scriptalizerInputs,
font,
config.scriptalizer.errFrequency
);
} catch (err) {
console.error("[Preview] Scriptalization failed:", err.message);
return res.status(500).json({
error: "Scriptalization failed",
message: err.message,
});
}
// Segmente und Marker wieder zusammensetzen; \t → TAB_MARKER, [[signaturN]] unverändert
// Leere Segmente werden als '' wiederhergestellt (wurden nicht scriptalisiert)
const scriptalizedTexts = splitMaps.map(({ startIdx, segments, markers, hasContent }) => {
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let result = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
result += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
result += (segs[i + 1] || '');
}
return result;
});
if (scriptalizedTexts.length !== processedLetters.length) {
console.warn(
`[Preview] Scriptalization mismatch: expected ${processedLetters.length}, got ${scriptalizedTexts.length}`
);
}
// Step 3: Generate SVG files
const files = [];
let hasOverflow = false;
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const pad = String(absoluteIndex).padStart(3, "0");
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
try {
if (letter.type === "envelope") {
const svgResult = generateEnvelopeSVG(
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
);
if (svgResult.overflow) hasOverflow = true;
const filename = `envelope_${pad}.svg`;
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
files.push({
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
recipientName: null,
});
} else if (letter.multiPage) {
// Mehrseitiger Brief: Text fließt über N Seiten – kein Überlauf-Fehler.
const { pages } = generateLetterPages(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
});
for (let p = 0; p < pages.length; p++) {
const filename = `letter_${pad}_p${p + 1}.svg`;
await fs.writeFile(path.join(sessionDir, filename), pages[p], "utf8");
files.push({
index: absoluteIndex, page: p + 1, pageCount: pages.length, filename,
url: `/api/preview/${finalSessionId}/${filename}`, recipientName,
});
}
} else {
const svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false,
signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {},
fontScale: letter.fontScale,
});
if (svgResult.overflow) {
hasOverflow = true;
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
}
const filename = `letter_${pad}.svg`;
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
files.push({
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
recipientName,
});
}
} catch (err) {
console.error(`[Preview] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: "SVG generation failed",
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
});
}
}
console.log(`[Preview] Generated ${files.length} SVG files`);
// Step 4: Generate all CSV files (placeholders, recipients, envelope placeholders, free addresses)
const csvUrls = {};
const hasPlaceholders = processedLetters.some((l) => l.placeholders && Object.keys(l.placeholders).length > 0);
const hasFreeAddresses = processedLetters.some((l) => l.type === 'envelope' && l.envelopeType === 'free');
if (hasPlaceholders || hasFreeAddresses) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = "brief_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.placeholders, "utf8");
csvUrls.placeholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = "empfaenger.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.recipients, "utf8");
csvUrls.recipients = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = "umschlag_platzhalter.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.envelopePlaceholders, "utf8");
csvUrls.envelopePlaceholders = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated umschlag_platzhalter.csv`);
}
// Freie Adressen CSV (für free-Modus)
if (csvs.freeAddresses) {
const filename = "freie_adressen.csv";
await fs.writeFile(path.join(sessionDir, filename), csvs.freeAddresses, "utf8");
csvUrls.freeAddresses = `/api/preview/${finalSessionId}/${filename}`;
console.log(`[Preview] Generated freie_adressen.csv`);
}
} catch (err) {
console.error("[Preview] CSV generation failed:", err.message);
// Don't fail the request, CSV is optional
}
}
// Step 5: Save session metadata (no expiration)
const metadataPath = path.join(sessionDir, ".metadata.json");
const metadata = {
sessionId: finalSessionId,
createdAt: new Date().toISOString(),
letterCount: files.length,
font,
hasPlaceholders,
};
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
console.log(
`[Preview] Batch generation complete for session: ${finalSessionId}`
);
// Response
res.status(200).json({
sessionId: finalSessionId,
files,
csvUrls,
hasOverflow,
overflowFiles: files.filter(f => f.overflow),
});
} catch (err) {
console.error("[Preview] Unexpected error:", err);
next(err);
}
}
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview files
*/
async function servePreview(req, res, next) {
try {
const { sessionId, filename } = req.params;
// Validate inputs
if (!sessionId || !filename) {
return res.status(400).json({
error: "Invalid request",
message: "sessionId and filename are required",
});
}
// Prevent directory traversal
const sanitizedFilename = path.basename(filename);
const filePath = path.join(
config.paths.previews,
sessionId,
sanitizedFilename
);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return res.status(404).json({
error: "File not found",
message: `Preview file not found: ${sanitizedFilename}`,
});
}
// No cache expiration check - files are served until manually deleted
// Determine content type
const ext = path.extname(sanitizedFilename).toLowerCase();
let contentType = "application/octet-stream";
if (ext === ".svg") {
contentType = "image/svg+xml";
} else if (ext === ".csv") {
contentType = "text/csv";
}
// Serve file - NO CACHING
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
const fileContent = await fs.readFile(filePath);
res.send(fileContent);
} catch (err) {
console.error("[Preview] Error serving file:", err);
next(err);
}
}
// No automatic cache cleanup - files are kept until manually deleted
module.exports = {
generateBatch,
servePreview,
};

View File

@@ -0,0 +1,72 @@
/**
* Authentication Middleware
* Validiert API-Token in Request-Headers
*/
const config = require('../../config');
/**
* Middleware: API-Token validieren
*/
function authenticateApiToken(req, res, next) {
// API-Token aus Umgebungsvariablen
const validToken = config.auth.apiToken;
// Wenn kein Token konfiguriert ist, alle Requests erlauben (Development)
if (!validToken) {
console.warn('[Auth] WARNING: No API token configured - authentication disabled!');
return next();
}
// Token aus verschiedenen Quellen prüfen
const token =
req.headers['x-api-token'] || // Custom Header
req.headers['authorization']?.replace('Bearer ', '') || // Bearer Token
req.query.api_token; // Query Parameter (nur für Tests)
// Kein Token vorhanden
if (!token) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API token required. Please provide X-API-Token header or Authorization: Bearer <token>',
});
}
// Token validieren
if (token !== validToken) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid API token',
});
}
// Token ist gültig
next();
}
/**
* Optionale Authentifizierung - Warnung aber kein Fehler
*/
function optionalAuth(req, res, next) {
const validToken = config.auth.apiToken;
if (!validToken) {
return next();
}
const token =
req.headers['x-api-token'] ||
req.headers['authorization']?.replace('Bearer ', '') ||
req.query.api_token;
if (!token || token !== validToken) {
console.warn('[Auth] Unauthenticated request to', req.path);
}
next();
}
module.exports = {
authenticateApiToken,
optionalAuth,
};

View File

@@ -0,0 +1,13 @@
module.exports = (err, req, res, next) => {
console.error('Error occurred:', err);
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
error: {
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};

View File

@@ -0,0 +1,53 @@
const config = require('../../config');
// Simple in-memory rate limiter
const rateLimitStore = new Map();
// Cleanup old entries every minute
setInterval(() => {
const now = Date.now();
const oneMinute = 60 * 1000;
for (const [key, data] of rateLimitStore.entries()) {
if (now - data.windowStart > oneMinute) {
rateLimitStore.delete(key);
}
}
}, 60 * 1000);
module.exports = (req, res, next) => {
const sessionId = req.body.sessionId || req.params.sessionId || 'anonymous';
const now = Date.now();
const oneMinute = 60 * 1000;
let rateData = rateLimitStore.get(sessionId);
if (!rateData || now - rateData.windowStart > oneMinute) {
// New window
rateData = {
windowStart: now,
requests: []
};
rateLimitStore.set(sessionId, rateData);
}
// Remove requests older than 1 minute
rateData.requests = rateData.requests.filter(timestamp => now - timestamp < oneMinute);
// Check limit
if (rateData.requests.length >= config.preview.rateLimitPerMinute) {
const oldestRequest = Math.min(...rateData.requests);
const retryAfter = Math.ceil((oneMinute - (now - oldestRequest)) / 1000);
return res.status(429).json({
error: 'Zu viele Vorschau-Anfragen. Bitte warten Sie.',
retryAfter,
message: `Limit: ${config.preview.rateLimitPerMinute} Anfragen pro Minute`
});
}
// Add current request
rateData.requests.push(now);
next();
};

View File

@@ -0,0 +1,14 @@
module.exports = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const timestamp = new Date().toISOString();
console.log(
`[${timestamp}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)`
);
});
next();
};

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const chargeController = require('../controllers/charge-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/charge/merge-pdf
* Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck).
* Body: { orderIds: (number|string)[] }
*/
router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf);
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const config = require('../../config');
router.get('/', (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
scriptalizer: config.scriptalizer.licenseKey ? 'configured' : 'missing',
storage: {
cache: fs.existsSync(config.paths.cache) && isWritable(config.paths.cache),
output: fs.existsSync(config.paths.output) && isWritable(config.paths.output)
}
};
const allHealthy = health.scriptalizer === 'configured' &&
health.storage.cache &&
health.storage.output;
res.status(allHealthy ? 200 : 503).json(health);
});
function isWritable(path) {
try {
fs.accessSync(path, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
module.exports = router;

View File

@@ -0,0 +1,154 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const directusController = require('../controllers/directus-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
// 10 MB reichten für hochauflösende bzw. gescannte Brief-PDFs nicht aus – multer
// verwarf die Datei still, bevor der Controller lief.
const MAX_UPLOAD_BYTES = 120 * 1024 * 1024; // 120MB
// Multer konfigurieren für Datei-Uploads (temporär im Speicher)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
// Erlaubte Dateitypen
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'application/pdf'];
const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur PNG, JPG, WEBP, SVG und PDF Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*
* Request body:
* {
* sessionId: string,
* orderNumber: string
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/finalize', authenticateApiToken, orderController.finalizeOrder);
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*
* Request body:
* {
* orderNumber: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* orderNumber: string,
* outputPath: string,
* files: string[],
* timestamp: string
* }
*/
router.post('/generate', authenticateApiToken, orderController.generateOrder);
/**
* POST /api/order/motif
* Upload a motif image for an order
*
* Request: multipart/form-data
* - motif: file (PNG, JPG, WEBP, SVG, PDF)
* - orderNumber: string
*
* Response:
* {
* success: boolean,
* filename: string,
* path: string
* }
*/
router.post('/motif', authenticateApiToken, upload.single('motif'), orderController.uploadMotif);
// Multer für Auftragsdateien (Adress-/Platzhalter-Excel, Briefdokument)
const uploadOrderFile = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_UPLOAD_BYTES,
},
fileFilter: (req, file, cb) => {
const allowedExts = ['.xlsx', '.xls', '.csv', '.pdf', '.doc', '.docx'];
const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.'));
if (allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Nur XLSX, XLS, CSV, PDF, DOC oder DOCX Dateien sind erlaubt'), false);
}
}
});
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adresse, Platzhalter, Brief, Brief-Excel) im Auftragsordner
*
* Request: multipart/form-data
* - file: file (XLSX, XLS, CSV, PDF, DOC, DOCX)
* - orderNumber: string
* - fileType: 'adresse' | 'platzhalter' | 'brief' | 'brief_excel'
*/
router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orderController.uploadOrderFile);
/**
* POST /api/order/register
* Metadaten-Auftrag ohne SVG-Generierung (gedruckte Briefe)
*/
router.post('/register', authenticateApiToken, orderController.registerOrder);
/**
* POST /api/order/from-directus
* Generiert einen Auftrag, dessen Daten in Directus liegen.
*
* Request body: { orderId: string|number } (ID des Directus-Auftrags)
*
* Antwortet mit 202 (accepted); die Generierung läuft im Hintergrund und
* schreibt Status + artifact_path selbstständig nach Directus zurück.
*/
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
/**
* POST /api/order/pagecount
* Seitenzahl je Dokument (mehrseitig) – ohne Scriptalizer/SVG, nur Umbruch-Zählung.
* Body: { text, format, fontScale, entries: [{ placeholders }] }
*/
router.post('/pagecount', authenticateApiToken, directusController.pageCounts);
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const previewController = require('../controllers/preview-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/preview/batch
* Generate preview batch (no rate limiting, no batch size limit)
* Backend automatically splits into 25-letter batches for Scriptalizer API
*
* Request body:
* {
* sessionId: string,
* letters: [
* {
* text: string,
* format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang',
* font: 'tilda' | 'alva' | 'ellie',
* type?: 'letter' | 'envelope',
* envelopeType?: 'recipient' | 'custom',
* placeholders?: { [key: string]: string }
* }
* ]
* }
*
* Response:
* {
* sessionId: string,
* files: [
* {
* index: number,
* filename: string,
* url: string
* }
* ],
* csvUrl?: string
* }
*/
router.post('/batch', authenticateApiToken, previewController.generateBatch);
/**
* GET /api/preview/:sessionId/:filename
* Serve cached preview SVG files
*/
router.get('/:sessionId/:filename', previewController.servePreview);
module.exports = router;