Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/ (skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion, AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
541
Docker/skrift-backend/src/api/controllers/order-controller.js
Normal file
541
Docker/skrift-backend/src/api/controllers/order-controller.js
Normal file
@@ -0,0 +1,541 @@
|
||||
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 } = 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 generateOrder(req, res, next) {
|
||||
try {
|
||||
const { orderNumber, letters } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!orderNumber) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid request',
|
||||
message: 'orderNumber is required'
|
||||
});
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
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+\]\]|[&|])/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);
|
||||
return res.status(500).json({
|
||||
error: 'Scriptalization failed',
|
||||
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Generate SVG files - separate letters and envelopes
|
||||
const generatedLetters = [];
|
||||
const generatedEnvelopes = [];
|
||||
|
||||
for (let i = 0; i < processedLetters.length; i++) {
|
||||
const letter = processedLetters[i];
|
||||
// Verwende loopIndex für scriptalizedMap Zuordnung
|
||||
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
||||
|
||||
let svgResult;
|
||||
|
||||
try {
|
||||
if (letter.type === 'envelope') {
|
||||
svgResult = generateEnvelopeSVG(
|
||||
scriptalizedText,
|
||||
letter.format,
|
||||
letter.envelopeType,
|
||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
||||
);
|
||||
} else {
|
||||
svgResult = generateLetterSVG(
|
||||
scriptalizedText,
|
||||
letter.format,
|
||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Order] 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}`
|
||||
});
|
||||
}
|
||||
|
||||
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
|
||||
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||
|
||||
if (letter.type === 'envelope') {
|
||||
const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
||||
const filepath = path.join(envelopesDir, filename);
|
||||
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
||||
generatedEnvelopes.push(filename);
|
||||
} else {
|
||||
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
||||
const filepath = path.join(outputDir, filename);
|
||||
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
||||
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 = req.body.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');
|
||||
|
||||
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
||||
|
||||
// Response
|
||||
res.status(200).json({
|
||||
orderNumber,
|
||||
outputPath: outputDir,
|
||||
letters: generatedLetters,
|
||||
envelopes: generatedEnvelopes,
|
||||
csvFiles,
|
||||
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
|
||||
timestamp: orderMetadata.generatedAt
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
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,
|
||||
uploadMotif,
|
||||
uploadOrderFile,
|
||||
registerOrder,
|
||||
};
|
||||
362
Docker/skrift-backend/src/api/controllers/preview-controller.js
Normal file
362
Docker/skrift-backend/src/api/controllers/preview-controller.js
Normal file
@@ -0,0 +1,362 @@
|
||||
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,
|
||||
} = 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,
|
||||
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+\]\]|[&|])/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;
|
||||
|
||||
let svgResult;
|
||||
|
||||
try {
|
||||
if (letter.type === "envelope") {
|
||||
svgResult = generateEnvelopeSVG(
|
||||
scriptalizedText,
|
||||
letter.format,
|
||||
letter.envelopeType,
|
||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
||||
);
|
||||
} else {
|
||||
// Briefe: Zeileninfo mit zurückgeben
|
||||
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
|
||||
font: letter.font,
|
||||
realisticHandwriting: letter.realisticHandwriting ?? false,
|
||||
signatures: letter.signatures || {},
|
||||
});
|
||||
}
|
||||
} 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}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Track overflow
|
||||
if (svgResult.overflow) {
|
||||
hasOverflow = true;
|
||||
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
|
||||
}
|
||||
|
||||
// Save SVG file - verwende den absoluten Index aus letter
|
||||
// Unterscheide zwischen Briefen und Umschlägen im Dateinamen
|
||||
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||
const prefix = letter.type === 'envelope' ? 'envelope' : 'letter';
|
||||
const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`;
|
||||
const filepath = path.join(sessionDir, filename);
|
||||
|
||||
await fs.writeFile(filepath, svgResult.svg, "utf8");
|
||||
|
||||
// Empfänger-Info für bessere Fehlermeldung
|
||||
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
|
||||
|
||||
files.push({
|
||||
index: absoluteIndex,
|
||||
filename,
|
||||
url: `/api/preview/${finalSessionId}/${filename}`,
|
||||
lineCount: svgResult.lineCount,
|
||||
lineLimit: svgResult.lineLimit,
|
||||
overflow: svgResult.overflow,
|
||||
recipientName: letter.type !== 'envelope' ? recipientName : null,
|
||||
});
|
||||
}
|
||||
|
||||
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
Docker/skrift-backend/src/api/middleware/auth.js
Normal file
72
Docker/skrift-backend/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
Docker/skrift-backend/src/api/middleware/error-handler.js
Normal file
13
Docker/skrift-backend/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
Docker/skrift-backend/src/api/middleware/rate-limiter.js
Normal file
53
Docker/skrift-backend/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
Docker/skrift-backend/src/api/middleware/request-logger.js
Normal file
14
Docker/skrift-backend/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();
|
||||
};
|
||||
34
Docker/skrift-backend/src/api/routes/health-routes.js
Normal file
34
Docker/skrift-backend/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;
|
||||
135
Docker/skrift-backend/src/api/routes/order-routes.js
Normal file
135
Docker/skrift-backend/src/api/routes/order-routes.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const orderController = require('../controllers/order-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);
|
||||
|
||||
module.exports = router;
|
||||
47
Docker/skrift-backend/src/api/routes/preview-routes.js
Normal file
47
Docker/skrift-backend/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