Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
All checks were successful
Build & Deploy / deploy (push) Successful in 10s
This commit is contained in:
69
src/api/controllers/charge-controller.js
Normal file
69
src/api/controllers/charge-controller.js
Normal 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 };
|
||||
225
src/api/controllers/directus-controller.js
Normal file
225
src/api/controllers/directus-controller.js
Normal 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 };
|
||||
601
src/api/controllers/order-controller.js
Normal file
601
src/api/controllers/order-controller.js
Normal 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,
|
||||
};
|
||||
363
src/api/controllers/preview-controller.js
Normal file
363
src/api/controllers/preview-controller.js
Normal 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,
|
||||
};
|
||||
72
src/api/middleware/auth.js
Normal file
72
src/api/middleware/auth.js
Normal 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,
|
||||
};
|
||||
13
src/api/middleware/error-handler.js
Normal file
13
src/api/middleware/error-handler.js
Normal 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 })
|
||||
}
|
||||
});
|
||||
};
|
||||
53
src/api/middleware/rate-limiter.js
Normal file
53
src/api/middleware/rate-limiter.js
Normal 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();
|
||||
};
|
||||
14
src/api/middleware/request-logger.js
Normal file
14
src/api/middleware/request-logger.js
Normal 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();
|
||||
};
|
||||
13
src/api/routes/charge-routes.js
Normal file
13
src/api/routes/charge-routes.js
Normal 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;
|
||||
34
src/api/routes/health-routes.js
Normal file
34
src/api/routes/health-routes.js
Normal 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;
|
||||
154
src/api/routes/order-routes.js
Normal file
154
src/api/routes/order-routes.js
Normal 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;
|
||||
47
src/api/routes/preview-routes.js
Normal file
47
src/api/routes/preview-routes.js
Normal 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;
|
||||
Reference in New Issue
Block a user