diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs
index 78c98eb..6f9f7cc 100644
--- a/bootstrap/bootstrap.mjs
+++ b/bootstrap/bootstrap.mjs
@@ -459,6 +459,7 @@ const collections = [
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
+ f('spaltenweise', 'boolean', { interface: 'boolean', default: false, note: 'Rollenschonende Zeichenreihenfolge (spaltenweise, für A6 quer). Der Agent lädt das SVG dann umsortiert.' }),
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js
index 8f2d6af..36f4759 100644
--- a/extensions/directus-extension-skrift-orders/dist/index.js
+++ b/extensions/directus-extension-skrift-orders/dist/index.js
@@ -1,2244 +1,2291 @@
-/**
- * Skrift – Bestell-Endpunkt
- * ---------------------------------------------------------------------------
- * POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt)
- * POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben)
- *
- * Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer
- * serverseitig aus products/price_items/pricing_settings neu berechnet.
- *
- * Preislogik:
- * - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet
- * von einem sicheren Mini-Interpreter (kein eval).
- * - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im
- * Code, Werte aus price_items.
- * - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung.
- */
-
-import crypto from 'node:crypto';
-import fs from 'node:fs';
-import path from 'node:path';
-import { Readable } from 'node:stream';
-
-/** Basisordner der generierten Auftragsdateien (read-only in Directus gemountet). */
-const OUTPUT_DIR = process.env.SKRIFT_OUTPUT_DIR || '/var/skrift-output';
-/** Directus-Upload-Ordner (lokaler Storage) – zum direkten Datei-Lesen. */
-const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
-
-// ── Sicherer Formel-Interpreter (kein eval) ─────────────────────────────────
-// Erlaubt: Zahlen, Variablen, + - * / ( ), unäres Minus, Vergleiche
-// (>= <= > < == !=), Ternär (cond ? a : b) und Funktionen sqrt/abs/min/max/
-// round/floor/ceil. Schreibweisen %var% und Math.fn werden vorab normalisiert.
-const FORMULA_FUNCS = {
- sqrt: Math.sqrt, abs: Math.abs, min: Math.min, max: Math.max,
- round: Math.round, floor: Math.floor, ceil: Math.ceil, pow: Math.pow,
-};
-function evalFormula(expr, vars) {
- const src = String(expr).replace(/%(\w+)%/g, '$1').replace(/Math\./g, '');
- const tokens = src.match(/\d+(?:\.\d+)?|[A-Za-z_]\w*|>=|<=|==|!=|[+\-*/()?:<>,]/g);
- if (!tokens) throw new Error('Formel ist leer oder ungültig.');
-
- let pos = 0;
- const peek = () => tokens[pos];
- const next = () => tokens[pos++];
- const expect = (t) => { if (next() !== t) throw new Error(`Erwartet "${t}" in der Formel.`); };
-
- function parseTernary() {
- const cond = parseCompare();
- if (peek() === '?') {
- next();
- const a = parseTernary();
- expect(':');
- const b = parseTernary();
- return cond ? a : b;
- }
- return cond;
- }
- function parseCompare() {
- const left = parseAdd();
- const op = peek();
- if (['>=', '<=', '>', '<', '==', '!='].includes(op)) {
- next();
- const right = parseAdd();
- if (op === '>=') return left >= right ? 1 : 0;
- if (op === '<=') return left <= right ? 1 : 0;
- if (op === '>') return left > right ? 1 : 0;
- if (op === '<') return left < right ? 1 : 0;
- if (op === '==') return left === right ? 1 : 0;
- return left !== right ? 1 : 0;
- }
- return left;
- }
- function parseAdd() {
- let left = parseMul();
- while (peek() === '+' || peek() === '-') {
- const op = next();
- const right = parseMul();
- left = op === '+' ? left + right : left - right;
- }
- return left;
- }
- function parseMul() {
- let left = parseUnary();
- while (peek() === '*' || peek() === '/') {
- const op = next();
- const right = parseUnary();
- if (op === '/' && right === 0) throw new Error('Division durch 0 in der Formel.');
- left = op === '*' ? left * right : left / right;
- }
- return left;
- }
- function parseUnary() {
- if (peek() === '-') { next(); return -parseUnary(); }
- return parsePrimary();
- }
- function parsePrimary() {
- const t = next();
- if (t === undefined) throw new Error('Formel unvollständig.');
- if (t === '(') { const v = parseTernary(); expect(')'); return v; }
- if (/^\d/.test(t)) return parseFloat(t);
- if (/^[A-Za-z_]/.test(t)) {
- if (peek() === '(') { // Funktionsaufruf
- next();
- const args = [];
- if (peek() !== ')') {
- args.push(parseTernary());
- while (peek() === ',') { next(); args.push(parseTernary()); }
- }
- expect(')');
- const fn = FORMULA_FUNCS[t.toLowerCase()];
- if (!fn) throw new Error(`Unbekannte Funktion: "${t}"`);
- return fn(...args);
- }
- if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
- throw new Error(`Unbekannte Variable in der Formel: "${t}"`);
- }
- throw new Error(`Unerwartetes Token: "${t}"`);
- }
-
- const result = parseTernary();
- if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.');
- if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Wert.');
- return result;
-}
-
-const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
-
-/** Adresse, an die Angebotsanfragen gemeldet werden. */
-const TEAM_EMAIL = process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de';
-
-/** Backend, das die SVGs erzeugt. */
-const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
-const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
-
-/** Vorschauen pro Kennung und Tag. */
-const PREVIEW_LIMIT = Number(process.env.SKRIFT_PREVIEW_LIMIT || 10);
-/** Wie viele Dokumente eine Vorschau maximal zurückgibt. */
-const PREVIEW_MAX_DOCS = 3;
-
-/** PayPal – Server-Zugangsdaten (Secret liegt NUR hier, nie im Frontend). */
-const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || '';
-const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || '';
-const PAYPAL_API = (process.env.PAYPAL_ENV || 'sandbox') === 'live'
- ? 'https://api-m.paypal.com'
- : 'https://api-m.sandbox.paypal.com';
-
-/** OAuth-Token für die PayPal-REST-API holen. */
-async function paypalToken() {
- const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_SECRET}`).toString('base64');
- const r = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
- method: 'POST',
- headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
- body: 'grant_type=client_credentials',
- });
- if (!r.ok) throw new Error(`PayPal-Token: ${r.status}`);
- return (await r.json()).access_token;
-}
-
-/**
- * Zielland-Erkennung je Empfänger für den Länder-Split.
- * Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext,
- * „D-" vor der PLZ, oder nur eine PLZ ohne Auslandshinweis. Sonst → weltweit (INT).
- */
-// Auslandsländer als GANZE Wörter (\b) – sonst matchte „schweiz" in „Schweizer
-// Straße", „usa" in „Neusass", „polen" in „Polenz" usw. und flaggte fälschlich Ausland.
-const AUSLAND_RE = /\b(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)\b/i;
-// HTML-Escaping + Feld-Tabelle für vollständige Anfrage-Team-Mails.
-const escH = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
-function felderHtml(pairs) {
- const rows = pairs
- .filter(([, v]) => v != null && String(v).trim())
- .map(([k, v]) => `
| ${escH(k)} | `
- + `${escH(v).replace(/\n/g, ' ')} |
`)
- .join('');
- return ``;
-}
-
-// Erstes nicht-leeres Feld aus mehreren möglichen Namen (App + WordPress-Varianten).
-function feldWert(body, ...keys) {
- for (const k of keys) {
- const v = body && body[k];
- if (v != null && String(v).trim()) return String(v).trim();
- }
- return '';
-}
-function zielInland(e) {
- const land = String(e?.country || '').trim();
- if (land) {
- if (/^(de|deu|ger|d|brd|deutschland|germany)$/i.test(land)) return true;
- if (/deutschland|germany/i.test(land)) return true;
- if (AUSLAND_RE.test(land)) return false;
- return true; // unbekannter/leerer Ländertext → NICHT automatisch Ausland
- }
- const txt = String(e?.free_text || '');
- if (/deutschland|germany/i.test(txt)) return true;
- if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true;
- if (AUSLAND_RE.test(txt)) return false;
- return true;
-}
-
-const handler = (router, { services, getSchema, logger }) => {
- const { ItemsService, MailService, UsersService, RolesService, AssetsService, FilesService } = services;
-
- // Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar).
- const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, '');
-
- /** Liest eine Directus-Datei (Upload) serverseitig als Buffer – direkt von der
- * Platte (lokaler Storage), wie der Mailer. Vermeidet AssetsService.getAsset,
- * dessen Signatur je nach Directus-Version „transforms"-Fehler wirft. */
- async function dateiBytes(schema, id) {
- const f = await new ItemsService('directus_files', { schema, accountability: null })
- .readOne(id, { fields: ['filename_disk'] });
- if (!f?.filename_disk) throw new Error(`Datei ${id} nicht gefunden.`);
- return fs.readFileSync(path.join(UPLOAD_ROOT, f.filename_disk));
- }
-
- /** Erzeugt aus einem hochgeladenen Motiv das A6-Drucklayout (152×109 mm, 2 mm
- * Beschnitt, Endformat 148×105) – Motiv mittig, randabfallend – und legt es als
- * Datei ab (orders.motif_layout). Rein serverseitig, für die Kundenfreigabe. */
- async function erzeugeMotivEntwurf(schema, orderId, fileId) {
- const meta = await new ItemsService('directus_files', { schema, accountability: null })
- .readOne(fileId, { fields: ['type', 'filename_disk'] });
- if (!meta?.filename_disk) throw new Error(`Motiv-Datei ${fileId} nicht gefunden.`);
- const base64 = fs.readFileSync(path.join(UPLOAD_ROOT, meta.filename_disk)).toString('base64');
- const mime = meta.type || 'image/png';
- // A6 quer: Motiv wird VOLLSTÄNDIG in die Trim-Fläche (148×105) eingepasst
- // (mittig, nichts beschnitten) auf weißem A6-Grund inkl. 2 mm Beschnitt.
- const svg = ``;
- const files = new FilesService({ schema, accountability: null });
- const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
- const layoutId = await files.uploadOne(
- Readable.from(Buffer.from(svg, 'utf8')),
- { storage, filename_download: `a6-entwurf-${orderId}.svg`, title: `A6-Entwurf ${orderId}`, type: 'image/svg+xml' },
- undefined, { emitEvents: false },
- );
- await new ItemsService('orders', { schema, accountability: null }).updateOne(orderId, { motif_layout: layoutId });
- return layoutId;
- }
-
- /** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
- async function signatureForm(schema, pdfId, sigList) {
- const pdfBytes = await dateiBytes(schema, pdfId);
- const form = new FormData();
- form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf');
- const jobs = [];
- for (let i = 0; i < sigList.length; i += 1) {
- const s = sigList[i] || {};
- if (!s.file_id) continue;
- const svgBytes = await dateiBytes(schema, s.file_id);
- const svgName = `sig${i + 1}.svg`;
- form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName);
- const marker = String(s.marker_key || `SIG${i + 1}`).replace(/[§[\]]/g, '').trim();
- jobs.push({
- token: `[[${marker}]]`,
- svg_id: svgName,
- position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau',
- breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45,
- versatz_x_mm: Number(s.offset_x_mm) || 0,
- versatz_y_mm: Number(s.offset_y_mm) || 0,
- });
- }
- form.append('job', JSON.stringify(jobs));
- return { form, jobCount: jobs.length };
- }
-
- /** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */
- async function signatureAufruf(req, res, pfad, contentType, key) {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- // Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem
- // Dokument (document_id) – eins von beiden muss vorhanden sein.
- if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- let ord = null;
- if (body.order) {
- ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] });
- if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
- }
- const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
- if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' });
- const sigList = Array.isArray(body.signatures) ? body.signatures : [];
- if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' });
-
- const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
- if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
-
- let r;
- try {
- r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
- } catch (e) {
- // Netzwerkfehler (Service down / Containername nicht auflösbar / falsche URL).
- logger.warn(`[skrift-orders] Signatur-Service nicht erreichbar (${SIGN_URL}): ${e.message}`);
- return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}. SKRIFT_SIGN_URL / Container prüfen.` });
- }
- if (!r.ok) {
- const t = await r.text();
- return res.status(502).json({ error: 'Signatur-Service-Fehler (evtl. Marker nicht im PDF gefunden).', detail: `${r.status}: ${String(t).slice(0, 400)}` });
- }
- const buf = Buffer.from(await r.arrayBuffer());
- return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' });
- } catch (err) {
- logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.', detail: String(err.message || err).slice(0, 400) });
- }
- }
-
- // Vorschau-PDF (Dokument mit eingefügten Unterschriften).
- router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64'));
- // A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion).
- router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64'));
-
- // Unterschriftenservice als ECHTEN Auftrag anlegen: erzeugt je PDF-Seite eine
- // A4-SVG (leer, wo kein Marker), schreibt sie als Schriftstück in den Auftrags-
- // ordner und legt den Auftrag an. Danach läuft alles über die normale Produktion
- // (Drucken → Plotter). KEIN neuer Job-Typ. Setzt den schreibbaren Output-Mount voraus.
- router.post('/signature-order', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
- const sigList = Array.isArray(body.signatures) ? body.signatures : [];
- if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Unterschrift wählen.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
-
- let ord = null;
- if (body.order) {
- ord = await svc('orders').readOne(body.order, { fields: ['id', 'source_file', 'source_files'] });
- if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
- }
- const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
- if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (document_id fehlt).' });
-
- const prodKey = body.product || 'unterschriftenservice';
- let product = (await svc('products').readByQuery({ filter: { key: { _eq: prodKey } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
- if (!product) product = (await svc('products').readByQuery({ filter: { input_mode: { _eq: 'datei' } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
- if (!product) return res.status(400).json({ error: 'Kein Unterschriftenservice-/Datei-Produkt gefunden.' });
-
- let customerId = null, customerEmail = null;
- if (body.customer_email && isEmail(norm(body.customer_email))) {
- const u = (await new UsersService({ schema, accountability: null }).readByQuery({
- filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
- }))?.[0];
- if (u) { customerId = u.id; customerEmail = u.email; } else customerEmail = norm(body.customer_email);
- }
-
- // A4-Signaturen erzeugen (eine je Seite) – JSON-Variante.
- const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
- if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
- let r;
- try { r = await fetch(`${SIGN_URL}/render-json`, { method: 'POST', body: form }); }
- catch (e) { return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}` }); }
- if (!r.ok) {
- let d = ''; try { d = (await r.json())?.detail || ''; } catch { /* ignore */ }
- return res.status(502).json({ error: 'Signatur-Service-Fehler (Marker im PDF?).', detail: String(Array.isArray(d) ? d.join('; ') : d).slice(0, 400) });
- }
- const pages = (await r.json())?.pages || [];
- if (!pages.length) return res.status(422).json({ error: 'Keine Seiten erzeugt.' });
-
- const jt = new Date();
- const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
- const naechsteNummer = async () => {
- const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
- return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
- };
- const orderData = {
- customer: customerId, customer_email: customerEmail, product: product.id,
- person_type: 'privat', source: 'operator', status: 'in_queue', production_status: 'eingegangen',
- payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel',
- needs_envelope: false, envelope_labeling: 'keine',
- source_file: pdfId, source_files: [pdfId],
- entries_count: pages.length, count_inland: pages.length, count_ausland: 0,
- };
- let orderId, orderNumber;
- for (let v = 0; v < 6; v += 1) {
- orderNumber = await naechsteNummer();
- try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
- catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
- }
-
- const entriesSvc = svc('order_entries');
- for (let i = 0; i < pages.length; i += 1) {
- await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` });
- }
- await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Unterschriftenservice – manuell angelegt.' }).catch(() => {});
-
- // Seiten-SVGs als Schriftstück in den Auftragsordner schreiben.
- const ordner = path.join(OUTPUT_DIR, orderNumber);
- fs.mkdirSync(ordner, { recursive: true });
- pages.forEach((p, i) => {
- fs.writeFileSync(path.join(ordner, `letter_${String(i + 1).padStart(3, '0')}.svg`), p.svg, 'utf8');
- });
-
- return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length });
- } catch (err) {
- logger.error(`[skrift-orders] signature-order: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Auftrag konnte nicht angelegt werden.', detail: String(err.message || err).slice(0, 400) });
- }
- });
-
- // Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
- const zufallsPasswort = () =>
- `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
-
- const norm = (e) => String(e || '').trim().toLowerCase();
- const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
-
- /** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */
- function makeSanitizer(whitelist) {
- let re;
- try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); }
- catch { re = null; }
- return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
- }
-
- async function loadPricingContext(schema) {
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const [settingsRaw, priceItems] = await Promise.all([
- svc('pricing_settings').readSingleton({}),
- svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1,
- fields: ['key', 'price', 'unit', 'category'] }),
- ]);
- const settings = settingsRaw || {};
- const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p]));
- return { settings, priceItems: byKey };
- }
-
- /** Berechnet den Preis vollständig serverseitig. */
- async function calculatePrice(schema, input) {
- const { settings, priceItems } = await loadPricingContext(schema);
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
-
- const product = (await svc('products').readByQuery({
- filter: { key: { _eq: input.product } }, limit: 1,
- fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'],
- }))?.[0];
- if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
- if (product.pricing_mode === 'auf_anfrage')
- throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`);
-
- const menge = Math.max(0, parseInt(input.quantity, 10) || 0);
- if (menge < 1) throw new Error('Menge muss mindestens 1 sein.');
-
- const lines = [];
- // perUnit=true kennzeichnet Positionen, die pro Stück anfallen (fließen in den
- // „Preis pro Stück"); einmalige Kosten (Sammelversand, Gutschein …) nicht.
- const add = (label, amount, perUnit = false) => { if (amount) lines.push({ label, amount: round2(amount), per_unit: perUnit }); };
- const priceOf = (key) => Number(priceItems[key]?.price) || 0;
- const unitOf = (key) => priceItems[key]?.unit || 'einmalig';
- const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key));
-
- // 1) Grundpreis über die editierbare Formel × Mengen-Multiplikator.
- // Der Multiplikator bildet den Kleinmengen-Aufschlag ab und ist je Segment
- // (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
- // Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
- const multFormula = settings.multiplier_formula || '1';
- // ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
- // überschatten, die weiter unten für norm(input.email) gebraucht wird.
- const normMenge = Number(product.norm_qty) || 0;
- // Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
- // Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
- const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
- const faktorA = zahlOder(product.price_factor_a, 1);
- const faktorE = zahlOder(product.price_factor_e, 3);
- const multVars = {
- qty: menge, menge, norm: normMenge,
- a: faktorA, e: faktorE,
- base_price: Number(product.base_price) || 0,
- };
- // Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
- let multiplier = 1;
- if (normMenge > 0) {
- try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
- }
- if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
-
- // Grundpreis-Formel. Variablen sind bewusst nur base_price und menge.
- // Ist die Formel fehlerhaft (z. B. versehentlich die Multiplikator-Formel
- // eingetragen), fällt der Preis auf die Standardformel zurück, statt die
- // gesamte Preisauskunft scheitern zu lassen.
- const formula = settings.formula || 'base_price * menge';
- const grundVars = { base_price: Number(product.base_price) || 0, menge };
- let basis;
- try {
- basis = evalFormula(formula, grundVars);
- } catch (e) {
- logger.warn(`[skrift-orders] Grundpreis-Formel ungültig ("${formula}"): ${e.message} – nutze base_price * menge.`);
- basis = evalFormula('base_price * menge', grundVars);
- }
- if (!Number.isFinite(basis)) basis = grundVars.base_price * menge;
- const grund = basis * multiplier;
- add(`${product.name} (${menge} Stk.)`, grund, true);
-
- // 1b) Mengenrabatt-Staffel: höchste passende Stufe (auf den Grundpreis).
- const tiers = Array.isArray(settings.quantity_discounts) ? settings.quantity_discounts : [];
- const stufe = tiers
- .filter((t) => menge >= (Number(t.min_qty) || 0) && Number(t.percent) > 0)
- .sort((a, b) => (Number(b.min_qty) || 0) - (Number(a.min_qty) || 0)
- || (Number(b.percent) || 0) - (Number(a.percent) || 0))[0];
- if (stufe) add(`Mengenrabatt (${stufe.percent} %)`, -(grund * (Number(stufe.percent) / 100)), true);
-
- // 2) Versand – Kuvert ist bei Einzelversand Pflicht
- if (input.shipping_type === 'einzeln') {
- const auslandCount = (input.entries || [])
- .filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length;
- const inlandCount = menge - auslandCount;
- add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
- add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
- } else {
- // Sammelversand-Staffel: bis 5 Schriftstücke günstiger (sammelversand_klein),
- // ab 6 der reguläre Satz. Fallback 1,80 €, falls der kleine Satz fehlt.
- const sammelKlein = priceItems['sammelversand_klein'] ? priceOf('sammelversand_klein') : 1.80;
- add('Sammelversand', menge <= 5 ? sammelKlein : priceOf('sammelversand'), false);
- }
-
- // 3) Kuvert + Beschriftung – NUR bei Sammelversand.
- // Bei Einzelversand an die Empfänger ist Kuvert + Beschriftung bereits im
- // Einzelversand-Wert (Porto) enthalten und wird NICHT separat berechnet.
- if (input.needs_envelope && input.shipping_type !== 'einzeln') {
- add('Kuvert', applyUnit('kuvert'), unitOf('kuvert') === 'pro_stueck');
- if (input.envelope_labeling && input.envelope_labeling !== 'keine')
- add('Kuvert-Beschriftung', applyUnit('beschriftung'), unitOf('beschriftung') === 'pro_stueck');
- }
-
- // 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
- if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'), unitOf('motiv_upload') === 'pro_stueck');
- for (const key of input.addons || []) {
- const item = priceItems[key];
- if (!item || item.category !== 'zusatzleistung') continue;
- add(item.key, applyUnit(key), unitOf(key) === 'pro_stueck');
- }
-
- // 4b) Kundenrabatt – nur bei bekanntem Konto (E-Mail). Rabatt gilt auf den
- // Schriftstück-Grundpreis. „erste_n_stueck" zählt KUMULATIV über alle
- // bisherigen (nicht stornierten) Aufträge des Kunden.
- if (input.email && isEmail(norm(input.email))) {
- try {
- const users = new UsersService({ schema, accountability: null });
- const kunde = (await users.readByQuery({
- filter: { email: { _eq: norm(input.email) } }, limit: 1, fields: ['id'],
- }))?.[0];
- if (kunde) {
- const jetzt = new Date().toISOString();
- const rabatt = (await svc('customer_discounts').readByQuery({
- filter: {
- customer: { _eq: kunde.id }, active: { _eq: true },
- _or: [{ valid_until: { _null: true } }, { valid_until: { _gte: jetzt } }],
- },
- limit: 1, sort: ['-value'],
- fields: ['label', 'type', 'value', 'scope', 'limit_stueck'],
- }))?.[0];
- if (rabatt && Number(rabatt.value) > 0) {
- const proStueck = menge > 0 ? grund / menge : 0;
- let stueck = menge; // scope 'alle'
- if (rabatt.scope === 'erste_n_stueck') {
- const frueher = await svc('orders').readByQuery({
- filter: { customer: { _eq: kunde.id }, status: { _neq: 'storniert' } },
- limit: -1, fields: ['entries_count'],
- });
- const genutzt = (frueher || []).reduce((s, o) => s + (Number(o.entries_count) || 0), 0);
- stueck = Math.min(menge, Math.max(0, (Number(rabatt.limit_stueck) || 0) - genutzt));
- }
- if (stueck > 0) {
- const betrag = rabatt.type === 'prozent'
- ? proStueck * stueck * (Number(rabatt.value) / 100)
- : Number(rabatt.value) * stueck;
- add(`Kundenrabatt${rabatt.label ? ` (${rabatt.label})` : ''}`, -Math.min(betrag, grund), true);
- }
- }
- }
- } catch (e) { logger.warn(`[skrift-orders] Kundenrabatt übersprungen: ${e.message}`); }
- }
-
- let net = lines.reduce((s, l) => s + l.amount, 0);
-
- // 5) Gutschein
- // Kundenangebot (customer_offers): frei benannte Aufschläge pro Stück, wenn der
- // eingeloggte Kunde ein Angebot gewählt hat. Serverseitig geprüft (Zugehörigkeit).
- let offer = null;
- if (input.offer) {
- const off = (await svc('customer_offers').readByQuery({
- filter: { id: { _eq: input.offer }, active: { _eq: true } }, limit: 1,
- fields: ['id', 'customer', 'product.key', 'name_suffix', 'surcharges', 'allow_multi_page'],
- }))?.[0];
- // Nur anwenden, wenn das Angebot dem anfragenden Kunden UND dem Produkt gehört.
- if (off && input.customerId && String(off.customer) === String(input.customerId)
- && (!off.product?.key || off.product.key === input.product)) {
- offer = off;
- for (const s of (Array.isArray(off.surcharges) ? off.surcharges : [])) {
- const betrag = Number(s?.amount) || 0;
- if (betrag) { const gesamt = betrag * menge; add(s.label || 'Aufschlag', gesamt, true); net += gesamt; }
- }
- }
- }
-
- let voucher = null;
- if (input.voucher_code) {
- const v = (await svc('vouchers').readByQuery({
- filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } },
- limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'],
- }))?.[0];
- const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now();
- const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses;
- if (v && !expired && !exhausted) {
- const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value);
- const capped = Math.min(discount, net);
- add(`Gutschein ${v.code}`, -capped);
- net -= capped;
- voucher = v;
- }
- }
-
- net = round2(Math.max(0, net));
- // Netto-Summe NUR der pro-Stück-Positionen (für „Preis pro Stück" ohne Einmalkosten).
- const unitNet = round2(Math.max(0, lines.filter((l) => l.per_unit).reduce((s, l) => s + l.amount, 0)));
- const mwst = Number(settings.mwst_percent ?? 19);
- const vat = round2(net * (mwst / 100));
- const gross = round2(net + vat);
-
- // 6) Zahlungsart: ab Limit nur noch Rechnung
- const limit = Number(settings.paypal_limit_net ?? 200);
- const paypalAllowed = net < limit;
-
- return { product, lines, net_total: net, unit_net_total: unitNet, vat_amount: vat, gross_total: gross,
- mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher, offer };
- }
-
- function validate(body) {
- const errors = [];
- if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.');
- if (!body?.product) errors.push('Produkt fehlt.');
- const entries = Array.isArray(body?.entries) ? body.entries : [];
- if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.');
- // Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller)
- if (body?.shipping_type === 'einzeln' && !body?.needs_envelope)
- errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.');
- return errors;
- }
-
- // ── Nur Preis berechnen (Live-Anzeige) ───────────────────────────────────
- router.post('/quote', async (req, res) => {
- try {
- const schema = await getSchema();
- const body = req.body || {};
- const quote = await calculatePrice(schema, {
- ...body, quantity: body.quantity ?? (body.entries || []).length,
- customerId: req.accountability?.user || null,
- });
- return res.json({
- lines: quote.lines, net_total: quote.net_total, unit_net_total: quote.unit_net_total,
- vat_amount: quote.vat_amount,
- gross_total: quote.gross_total, mwst_percent: quote.mwst_percent,
- paypal_allowed: quote.paypal_allowed,
- });
- } catch (err) {
- return res.status(400).json({ error: err.message });
- }
- });
-
- // ── Angebote des eingeloggten Kunden (Auswahl im Konfigurator) ────────────
- router.get('/my-offers', async (req, res) => {
- const uid = req.accountability?.user;
- if (!uid) return res.json({ offers: [] });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const filter = { customer: { _eq: uid }, active: { _eq: true } };
- if (req.query.product) filter.product = { key: { _eq: String(req.query.product) } };
- const offers = await svc('customer_offers').readByQuery({
- filter, limit: -1, sort: ['label'],
- fields: ['id', 'label', 'name_suffix', 'note', 'allow_multi_page', 'surcharges', 'product.key', 'product.name'],
- });
- return res.json({ offers: offers || [] });
- } catch (err) {
- logger.error(`[skrift-orders] my-offers: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Angebote konnten nicht geladen werden.' });
- }
- });
-
- // ── Vorschau ─────────────────────────────────────────────────────────────
- /**
- * Erzeugt bis zu PREVIEW_MAX_DOCS Vorschau-SVGs über das Backend.
- * Das Kontingent wird serverseitig geführt – jede Generierung kostet einen
- * Scriptalizer-Aufruf, deshalb darf der Client das nicht bestimmen.
- */
- router.post('/preview', async (req, res) => {
- const body = req.body || {};
- // Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
- // und liefert keine SVGs zurück – nur has_overflow/overflow.
- const nurPruefen = body.validate === true;
- // Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
- const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
- const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
- if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
- // Ohne Brieftext nur erlaubt, wenn Kuverts gewünscht sind (dann nur Kuvert-Vorschau).
- const willKuvertVorschau = !!(body.envelope_labeling && body.envelope_labeling !== 'keine');
- if ((!body.text || !String(body.text).trim()) && !willKuvertVorschau)
- return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const heute = new Date().toISOString().slice(0, 10);
-
- const vorhanden = (nurPruefen || istIntern) ? null : (await svc('preview_usage').readByQuery({
- filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
- }))?.[0];
- const verbraucht = vorhanden?.count ?? 0;
- if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
- return res.status(429).json({
- error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
- remaining: 0, limit: PREVIEW_LIMIT,
- });
- }
-
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const entries = (Array.isArray(body.entries) ? body.entries : []).slice(0, PREVIEW_MAX_DOCS);
- const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
- const realistisch = body.realistic !== false;
- // App-Formatschlüssel → Backend-Formate (wie im directus-controller).
- // Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
- // Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
- // Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
- const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
- const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
- // A4 → DIN Lang, A6 → C6
- const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
- // Postkarte (Text links, Adresse rechts, ein SVG) – Erkennung über render_formats.kind.
- const istPk = (await svc('render_formats').readByQuery({
- filter: { key: { _eq: briefFormat } }, limit: 1, fields: ['kind'],
- }))?.[0]?.kind === 'postkarte';
-
- const platzhalterVon = (e, i) => {
- // 5 Adresszeilen (5-Zeilen-Modus) auch als [[Zeile1]]..[[Zeile5]].
- const zeilen = String(e.free_text || '').split('\n');
- return {
- Anrede: clean(e.salutation || ''),
- Vorname: clean(e.first_name || ''),
- Nachname: clean(e.last_name || ''),
- Strasse: clean([e.street, e.house_no].filter(Boolean).join(' ')),
- PLZ: clean(e.zip || ''),
- Ort: clean(e.city || ''),
- Land: clean(e.country || ''),
- Briefnummer: String(i + 1),
- Zeile1: clean(zeilen[0] || ''), Zeile2: clean(zeilen[1] || ''), Zeile3: clean(zeilen[2] || ''),
- Zeile4: clean(zeilen[3] || ''), Zeile5: clean(zeilen[4] || ''),
- ...(e.placeholders && typeof e.placeholders === 'object'
- ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
- : {}),
- };
- };
-
- const adressBlock = (e) => {
- if (e.free_text) return clean(String(e.free_text));
- const name = [e.first_name, e.last_name].filter(Boolean).join(' ');
- const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
- const ort = [e.zip, e.city].filter(Boolean).join(' ');
- return clean([name, strasse, ort].filter(Boolean).join('\n'));
- };
-
- // Relative Schriftgröße (manuelle Aufträge), getrennt für Brief/Kuvert. 1 = Standard.
- const skala = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0.8, Math.min(1.5, n)) : 1; };
- const fsLetter = skala(body.font_scale_letter);
- const fsEnv = skala(body.font_scale_envelope);
- // Signaturen (SVG je [[signaturN]]) + Feinjustierung – nur Brief, nicht Kuvert.
- const sigMap = (body.signatures && typeof body.signatures === 'object') ? body.signatures : {};
- const sigOff = (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {};
- // Mehrseitiges Schriftstück: Text fließt über mehrere Seiten (kein Überlauf-Fehler).
- const mehrseitig = body.multi_page === true;
- const basis = entries.length ? entries : [{}];
- const letters = [];
- basis.forEach((e, i) => {
- // Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
- const nr = i + 1;
- // Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
- const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
-
- // Postkarte: EIN Dokument mit Text + Absender + Empfänger (Marker-getrennt).
- // Ohne Brieftext KEINE Karte erzeugen (dann greift ggf. nur der Kuvert-Zweig).
- if (istPk && String(briefText).trim()) {
- const sender = clean(String(body.envelope_sender || '')).trim();
- const recip = adressBlock(e);
- let combined = String(clean(briefText) || '');
- if (sender) combined += `[[pk_abs]]${sender}`;
- if (recip) combined += `[[pk_adr]]${recip}`;
- letters.push({
- index: nr, type: 'postcard', text: combined, format: briefFormat, font,
- realisticHandwriting: realistisch, placeholders: platzhalterVon(e, i),
- fontScale: fsLetter, senderScale: Number(body.envelope_sender_scale) || 1, recipientScale: fsEnv,
- });
- return;
- }
-
- // Kein Schriftstück, wenn kein Text da ist (reiner Kuvert-Druck).
- if (String(briefText).trim()) {
- letters.push({
- index: nr, type: 'letter', text: clean(briefText),
- format: briefFormat, font, realisticHandwriting: realistisch,
- placeholders: platzhalterVon(e, i), fontScale: fsLetter,
- signatures: sigMap, signatureOffsets: sigOff, multiPage: mehrseitig,
- });
- }
-
- // Umschlag nur, wenn er auch beschriftet wird.
- if (body.envelope_labeling && body.envelope_labeling !== 'keine') {
- const istFreitext = body.envelope_labeling === 'freitext';
- letters.push({
- index: nr, type: 'envelope',
- envelopeType: istFreitext ? 'custom' : 'recipient',
- text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
- format: umschlagFormat, font, realisticHandwriting: realistisch,
- placeholders: platzhalterVon(e, i), fontScale: fsEnv,
- // Absender aufs Kuvert (oben links); Empfänger rutscht dann unten rechts.
- sender: clean(String(body.envelope_sender || '')),
- senderScale: Number(body.envelope_sender_scale) || 1,
- });
- }
- });
-
- const sessionId = `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- const kopf = { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN };
-
- // Das Backend liest `letters` direkt aus dem Body (nicht aus `config`).
- // Umschläge stecken als Einträge mit type:'envelope' bereits in `letters`.
- const antwort = await fetch(`${BACKEND_URL}/api/preview/batch`, {
- method: 'POST',
- headers: kopf,
- body: JSON.stringify({ sessionId, letters }),
- });
- if (!antwort.ok) {
- const txt = await antwort.text();
- logger.error(`[skrift-orders] Vorschau-Backend: ${antwort.status} ${txt}`);
- // Grund für die Diagnose mitgeben (Status + gekürzte Backend-Meldung).
- return res.status(502).json({
- error: 'Vorschau konnte nicht erzeugt werden.',
- detail: `Backend ${antwort.status}: ${String(txt).slice(0, 200)}`,
- });
- }
- const daten = await antwort.json();
-
- // Zeilen-Überlauf (Schriftstück zu lang für das Format) durchreichen.
- const overflow = (daten.overflowFiles || [])
- .map((f) => ({ index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit }));
-
- // Reine Prüfung (beim „Weiter"): kein Download, kein Kontingent-Verbrauch.
- if (nurPruefen) {
- return res.json({ has_overflow: !!daten.hasOverflow, overflow });
- }
-
- // SVGs direkt einsammeln – so braucht der Browser keinen Backend-Zugang.
- // Schriftstücke und Umschläge kommen gemeinsam zurück.
- // Umschlag vs. Schriftstück steckt im Dateinamen-Präfix (envelope_ / letter_) –
- // die Datei-Objekte des Backends tragen kein eigenes type-Feld.
- const docs = [];
- for (const datei of (daten.files || []).slice(0, PREVIEW_MAX_DOCS * 2)) {
- const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } });
- if (r.ok) {
- const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || '');
- docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text(), page: datei.page, pageCount: datei.pageCount });
- }
- }
-
- // Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
- if (!istIntern && !nurPruefen && kennung) {
- if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
- else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
- }
-
- return res.json({
- docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
- has_overflow: !!daten.hasOverflow, overflow,
- // Session zurückgeben, damit die gezeigten Dokumente bei der Bestellung
- // 1:1 übernommen werden können.
- session: sessionId,
- });
- } catch (err) {
- logger.error(`[skrift-orders] preview: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Vorschau fehlgeschlagen.' });
- }
- });
-
- /** Seitenzahl je Schriftstück (mehrseitig) – schlank, ohne Scriptalizer/SVG. */
- router.post('/pagecount', async (req, res) => {
- const body = req.body || {};
- if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
- try {
- const schema = await getSchema();
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
- const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
- const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
- const scale = Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1));
- const entries = (Array.isArray(body.entries) ? body.entries : []).map((e) => ({
- placeholders: (e && typeof e.placeholders === 'object')
- ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
- : {},
- }));
- const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({ text: clean(String(body.text)), format: briefFormat, fontScale: scale, entries }),
- });
- if (!r.ok) return res.status(502).json({ error: 'Seitenzählung fehlgeschlagen.' });
- return res.json(await r.json());
- } catch (err) {
- logger.error(`[skrift-orders] pagecount: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Seitenzählung fehlgeschlagen.' });
- }
- });
-
- /**
- * Öffentlicher Datei-Upload (Motiv/Quelldatei). Gäste bestellen ohne Login und
- * haben keinen Directus-Token für /files – daher hier serverseitig via FilesService
- * (admin) ablegen. Datei kommt als Base64 im JSON. Nur Bilder + PDF, max. 20 MB.
- */
- router.post('/upload', async (req, res) => {
- const body = req.body || {};
- const roh = typeof body.data_base64 === 'string' ? body.data_base64.replace(/^data:[^;]+;base64,/, '') : '';
- if (!roh) return res.status(400).json({ error: 'Keine Datei übergeben.' });
- const type = String(body.content_type || 'application/octet-stream');
- if (!/^image\/(png|jpe?g|gif|webp|svg\+xml)$|^application\/pdf$/i.test(type)) {
- return res.status(415).json({ error: 'Dateityp nicht erlaubt (nur Bilder oder PDF).' });
- }
- let buffer;
- try { buffer = Buffer.from(roh, 'base64'); } catch { return res.status(400).json({ error: 'Ungültige Datei.' }); }
- if (!buffer.length) return res.status(400).json({ error: 'Leere Datei.' });
- if (buffer.length > 20 * 1024 * 1024) return res.status(413).json({ error: 'Datei zu groß (max. 20 MB).' });
- try {
- const schema = await getSchema();
- const files = new FilesService({ schema, accountability: null });
- const name = (String(body.filename || 'upload').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'upload');
- const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
- const id = await files.uploadOne(
- Readable.from(buffer),
- { storage, filename_download: name, title: name, type },
- undefined,
- { emitEvents: false },
- );
- return res.json({ id });
- } catch (err) {
- logger.error(`[skrift-orders] upload: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Upload fehlgeschlagen.' });
- }
- });
-
- // ── Motiv-Freigabe (öffentlich, per Einmal-Token) ──────────────────────────
- const escHtml = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
- const freigabeAuftrag = async (schema, token, fields) => {
- const t = String(token || '').trim();
- if (!t) return null;
- return (await new ItemsService('orders', { schema, accountability: null }).readByQuery({
- filter: { approval_token: { _eq: t } }, limit: 1, fields,
- }))?.[0] || null;
- };
-
- // Auftrags-/Statusinfo für die Freigabe-Seite (kein Login nötig – Token genügt).
- router.get('/freigabe/:token', async (req, res) => {
- try {
- const schema = await getSchema();
- const o = await freigabeAuftrag(schema, req.params.token,
- ['id', 'order_number', 'approval_status', 'approval_note', 'motif_layout', 'product.name']);
- if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
- return res.json({
- order_number: o.order_number,
- product: o.product?.name || 'Postkarte',
- status: o.approval_status || 'pending',
- note: o.approval_note || '',
- has_layout: !!o.motif_layout,
- });
- } catch (e) {
- logger.error(`[skrift-orders] freigabe GET: ${e.message}`);
- return res.status(500).json({ error: 'Fehler beim Laden.' });
- }
- });
-
- // Das A6-Entwurfs-SVG direkt ausliefern (Token-gated, kein Asset-Login nötig).
- router.get('/freigabe/:token/layout', async (req, res) => {
- try {
- const schema = await getSchema();
- const o = await freigabeAuftrag(schema, req.params.token, ['motif_layout']);
- if (!o?.motif_layout) return res.status(404).send('Kein Entwurf.');
- const bytes = await dateiBytes(schema, o.motif_layout);
- res.setHeader('Content-Type', 'image/svg+xml');
- res.setHeader('Cache-Control', 'no-store');
- return res.send(bytes);
- } catch (e) {
- logger.error(`[skrift-orders] freigabe layout: ${e.message}`);
- return res.status(500).send('Fehler.');
- }
- });
-
- // Freigabe oder Änderungswunsch speichern + Team benachrichtigen.
- router.post('/freigabe/:token', async (req, res) => {
- const action = String(req.body?.action || '');
- const note = String(req.body?.note || '').slice(0, 2000).trim();
- if (!['approve', 'changes'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion.' });
- if (action === 'changes' && !note) return res.status(400).json({ error: 'Bitte den Änderungswunsch beschreiben.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const o = await freigabeAuftrag(schema, req.params.token, ['id', 'order_number', 'approval_status']);
- if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
- const neu = action === 'approve' ? 'approved' : 'changes';
- await svc('orders').updateOne(o.id, { approval_status: neu, approval_note: action === 'changes' ? note : null });
- await svc('status_history').createOne({ order: o.id, status: 'eingegangen',
- note: action === 'approve' ? 'Motiv-Entwurf vom Kunden freigegeben.' : `Motiv-Änderungswunsch: ${note}` }).catch(() => {});
- await teamMail(schema,
- action === 'approve' ? `✅ Motiv freigegeben: ${o.order_number}` : `✏️ Motiv-Änderungswunsch: ${o.order_number}`,
- action === 'approve'
- ? `Der Kunde hat den Motiv-Entwurf zu ${escHtml(o.order_number)} freigegeben – kann in den Druck.
`
- : `Änderungswunsch zum Motiv-Entwurf ${escHtml(o.order_number)}:
${escHtml(note)}
`);
- return res.json({ ok: true, status: neu });
- } catch (e) {
- logger.error(`[skrift-orders] freigabe POST: ${e.message}`);
- return res.status(500).json({ error: 'Konnte nicht gespeichert werden.' });
- }
- });
-
- /** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
- router.get('/preview/quota', async (req, res) => {
- try {
- const kennung = norm(req.query.email) || String(req.query.client_id || '').slice(0, 64);
- if (!kennung) return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
- const schema = await getSchema();
- const heute = new Date().toISOString().slice(0, 10);
- const row = (await new ItemsService('preview_usage', { schema, accountability: null }).readByQuery({
- filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['count'],
- }))?.[0];
- return res.json({ remaining: Math.max(0, PREVIEW_LIMIT - (row?.count ?? 0)), limit: PREVIEW_LIMIT });
- } catch {
- return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
- }
- });
-
- // ── Auftragsdateien (Direktzugriff auf den Ausgabe-Ordner, keine Kopie) ────
- // Zugriff: App-Nutzer (Staff/Admin) ODER der Produktions-Agent (Rolle Service).
- let _serviceRoleId;
- async function serviceRoleId() {
- if (_serviceRoleId !== undefined) return _serviceRoleId;
- try {
- const rows = await new ItemsService('directus_roles', { schema: await getSchema(), accountability: null })
- .readByQuery({ filter: { name: { _eq: 'Service' } }, limit: 1, fields: ['id'] });
- _serviceRoleId = rows?.[0]?.id ?? null;
- } catch { _serviceRoleId = null; }
- return _serviceRoleId;
- }
- async function darfDateien(req) {
- const acc = req.accountability;
- if (!acc || !acc.user) return false;
- if (acc.admin || acc.app) return true; // Operator im Admin
- const svc = await serviceRoleId();
- return !!(svc && acc.role === svc); // Produktions-Agent
- }
- const safeOrder = (s) => (/^[A-Za-z0-9_-]+$/.test(String(s || '')) ? String(s) : null);
-
- /** Listet die Dateien eines Auftrags aus dem gemounteten Ausgabe-Ordner. */
- function listeDateien(orderNummer) {
- const basis = path.join(OUTPUT_DIR, orderNummer);
- let root;
- try { root = fs.readdirSync(basis); } catch { return null; } // Ordner (noch) nicht da
- const out = [];
- const add = (rel, kind) => {
- try {
- const st = fs.statSync(path.join(basis, rel));
- if (st.isFile()) out.push({ name: path.basename(rel), rel, kind, size: st.size });
- } catch { /* ignore */ }
- };
- for (const f of root) {
- if (/\.csv$/i.test(f)) add(f, 'platzhalter');
- else if (/\.svg$/i.test(f)) add(f, 'schriftstueck');
- }
- try {
- for (const f of fs.readdirSync(path.join(basis, 'umschlaege'))) {
- if (/\.svg$/i.test(f)) add(path.join('umschlaege', f), 'umschlag');
- }
- } catch { /* keine Umschläge */ }
- return out;
- }
-
- router.get('/files/:order', async (req, res) => {
- if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
- const nummer = safeOrder(req.params.order);
- if (!nummer) return res.status(400).json({ error: 'Ungültige Auftragsnummer.' });
- const files = listeDateien(nummer);
- if (files === null) return res.json({ files: [], exists: false });
- return res.json({ files, exists: true });
- });
-
- router.get('/files/:order/download', async (req, res) => {
- if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
- const nummer = safeOrder(req.params.order);
- const rel = String(req.query.rel || '');
- if (!nummer || rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) {
- return res.status(400).json({ error: 'Ungültiger Pfad.' });
- }
- const basis = path.resolve(OUTPUT_DIR, nummer);
- const ziel = path.resolve(basis, rel);
- // Pfad muss innerhalb des Auftragsordners liegen (kein Ausbruch).
- if (ziel !== basis && !ziel.startsWith(basis + path.sep)) {
- return res.status(400).json({ error: 'Ungültiger Pfad.' });
- }
- if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) {
- return res.status(404).json({ error: 'Datei nicht gefunden.' });
- }
- const typ = ziel.endsWith('.svg') ? 'image/svg+xml'
- : ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream';
- res.setHeader('Content-Type', typ);
- res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`);
- fs.createReadStream(ziel).pipe(res);
- });
-
- // ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
- // Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
- // EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
- // Body: { orderIds: (number|string)[] }
- router.post('/charge/pdf', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
- if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
- try {
- const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({ orderIds }),
- });
- if (!r.ok) {
- let msg = 'Sammel-PDF fehlgeschlagen.';
- try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
- return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
- }
- const buf = Buffer.from(await r.arrayBuffer());
- res.setHeader('Content-Type', 'application/pdf');
- res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
- return res.send(buf);
- } catch (e) {
- logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
- return res.status(502).json({ error: 'Backend nicht erreichbar.' });
- }
- });
-
- // ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
- // Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
- // für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
- const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
- const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
- // Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
- // Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
- const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
- const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
- const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
- const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
-
- router.post('/enqueue', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- if (!body.order || !body.machine) return res.status(400).json({ error: 'order und machine erforderlich.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const order = await svc('orders').readOne(body.order, {
- fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
- });
- if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
-
- const tplByKey = async (key) => (await svc('format_templates').readByQuery({
- filter: { key: { _eq: key } }, limit: 1, fields: ['id'],
- }))?.[0]?.id ?? null;
-
- // Läuft auf dieser Maschine gerade ein Druck? → nur Warteschlange möglich.
- const laufend = await svc('jobs').readByQuery({
- filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'],
- });
- const busy = !!(laufend && laufend.length);
- const modus = (body.mode === 'direct' && !busy) ? 'direct' : 'queue';
- const prio = modus === 'direct' ? 100 : 0;
-
- // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null).
- const clampParams = (p) => {
- if (!p || typeof p !== 'object') return null;
- const out = {};
- for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
- if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
- }
- return Object.keys(out).length ? out : null;
- };
-
- const jobs = [];
- // Neue, ausführliche Form: explizite Abschnitte (Brief/Umschlag) aus dem Popup.
- const s = body.sections;
- if (s && typeof s === 'object') {
- for (const typ of ['brief', 'umschlag']) {
- const sec = s[typ];
- if (!sec || sec.enabled === false) continue;
- if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
- const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
- jobs.push(await svc('jobs').createOne({
- order: order.id, machine: body.machine, type: typ, template: sec.template,
- format: fmt.key, format_label: fmt.label,
- params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
- status: 'queued', priority: prio,
- }));
- }
- if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
- } else {
- // Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
- const bf = briefFormatOf(order);
- const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
- jobs.push(await svc('jobs').createOne({
- order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
- format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
- }));
- if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
- const ef = umschlagFormatOf(order);
- const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
- jobs.push(await svc('jobs').createOne({
- order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
- format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
- }));
- }
- }
- return res.json({ ok: true, jobs, mode: modus, busy });
- } catch (err) {
- logger.error(`[skrift-orders] enqueue: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Einreihen fehlgeschlagen.' });
- }
- });
-
- // ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
- // Body: { orders:[id…], mode:'queue'|'direct',
- // byFormat:{ '': { template, machine, label } } }
- // Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
- // Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
- router.post('/enqueue-bulk', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- const orderIds = Array.isArray(body.orders) ? body.orders : [];
- const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
- if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const ordersRoh = await svc('orders').readMany(orderIds, {
- fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
- });
- // WICHTIG: readMany bewahrt die Reihenfolge der übergebenen IDs NICHT. Die
- // Jobs werden in der Iterationsreihenfolge erzeugt (→ date_created → Druck-
- // reihenfolge am Plotter). Damit Kuvert-/Brief-Reihenfolge zur ebenfalls nach
- // Auftragsnummer sortierten Sammel-PDF passt, hier explizit nach order_number
- // sortieren – sonst driften die Stapel auseinander (Handarbeit).
- const orders = (ordersRoh || []).slice().sort((a, b) =>
- String(a.order_number || '').localeCompare(String(b.order_number || ''), undefined, { numeric: true }));
-
- // Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
- const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
- const busySet = new Set((printing || []).map((j) => String(j.machine)));
- const mode = body.mode === 'direct' ? 'direct' : 'queue';
- const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
-
- // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null) –
- // wie in /enqueue, damit der Sammel-Druck Template-Werte übersteuern kann.
- const clampParams = (p) => {
- if (!p || typeof p !== 'object') return null;
- const out = {};
- for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
- if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
- }
- return Object.keys(out).length ? out : null;
- };
-
- const jobs = [];
- const uebersprungen = [];
- const mkJob = async (order, typ, fmt) => {
- const cfg = byFormat[fmt.key];
- if (!cfg || !cfg.template || !cfg.machine) {
- uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
- return;
- }
- jobs.push(await svc('jobs').createOne({
- order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
- format: fmt.key, format_label: cfg.label || fmt.label, params: clampParams(cfg.params),
- status: 'queued', priority: prioFor(cfg.machine),
- }));
- };
- for (const order of orders) {
- await mkJob(order, 'brief', briefFormatOf(order));
- if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
- await mkJob(order, 'umschlag', umschlagFormatOf(order));
- }
- }
- return res.json({ ok: true, jobs, mode, uebersprungen });
- } catch (err) {
- logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Sammel-Einreihen fehlgeschlagen.' });
- }
- });
-
- // ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
- const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
- router.post('/test-print', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const { id } = req.body || {};
- if (!id) return res.status(400).json({ error: 'id erforderlich.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const tp = await svc('test_prints').readOne(id, {
- fields: ['id', 'text', 'font', 'format', 'machine', 'template'],
- });
- if (!tp) return res.status(404).json({ error: 'Testdruck nicht gefunden.' });
- if (!tp.template || !tp.machine) return res.status(400).json({ error: 'Bitte Template und Maschine wählen.' });
-
- const ref = `TEST-${id}-${Date.now().toString(36)}`;
- const font = ['tilda', 'alva', 'ellie'].includes(tp.font) ? tp.font : 'tilda';
- const format = TEST_FORMAT[tp.format] || 'a4';
-
- // 1) Test-SVG über das bestehende Backend erzeugen (schreibt in den Ausgabeordner).
- const gen = await fetch(`${BACKEND_URL}/api/order/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({
- orderNumber: ref,
- letters: [{ index: 0, type: 'letter', text: tp.text || 'Skrift Testdruck', format, font, realisticHandwriting: false }],
- metadata: {},
- }),
- });
- if (!gen.ok) {
- const txt = await gen.text();
- await svc('test_prints').updateOne(id, { status: 'fehler' });
- return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` });
- }
-
- // 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
- const job = await svc('jobs').createOne({
- machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
- format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
- });
- await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
- return res.json({ ok: true, ref, job });
- } catch (err) {
- logger.error(`[skrift-orders] test-print: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Testdruck fehlgeschlagen.' });
- }
- });
-
- // ── Manueller Druck (Muster) ───────────────────────────────────────────────
- // Generiert ein einzelnes Schriftstück aus freiem Text (Adresse/Standardtext
- // wurden im Dialog bereits eingesetzt) und reiht es als Job ein. Optional wird
- // die zugehörige Musteranfrage auf „in_bearbeitung" gesetzt.
- router.post('/manual-print', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
- if (!body.template || !body.machine) return res.status(400).json({ error: 'Template und Maschine erforderlich.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const ref = `MUSTER-${(body.offer_request || 'x')}-${Date.now().toString(36)}`;
- const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
- const format = TEST_FORMAT[body.format] || 'a4';
-
- // Kuvert (Adresse aus der Anfrage) optional mitdrucken.
- const ENV_FMT = { dinlang: 'din_lang', c6: 'c6' };
- const envAddr = clean(String(body.envelope || '')).trim();
- const wantsEnv = !!(envAddr && body.envelope_template);
- const envFormat = ENV_FMT[body.envelope_format] || 'din_lang';
-
- const letters = [{ index: 1, type: 'letter', text: clean(String(body.text)), format, font, realisticHandwriting: body.realistic !== false }];
- if (wantsEnv) {
- letters.push({ index: 1, type: 'envelope', envelopeType: 'recipient', text: envAddr, format: envFormat, font, realisticHandwriting: body.realistic !== false });
- }
-
- const gen = await fetch(`${BACKEND_URL}/api/order/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({
- orderNumber: ref,
- letters,
- metadata: { rawText: String(body.text) },
- }),
- });
- if (!gen.ok) {
- const txt = await gen.text();
- return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` });
- }
-
- const busyList = await svc('jobs').readByQuery({
- filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'],
- });
- const busy = !!(busyList && busyList.length);
- const prio = (body.mode === 'direct' && !busy) ? 100 : 0;
-
- const job = await svc('jobs').createOne({
- machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
- format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
- });
- // Zweiter Job für das Kuvert (gleicher Ausgabeordner, Typ umschlag).
- if (wantsEnv) {
- await svc('jobs').createOne({
- machine: body.machine, template: body.envelope_template, type: 'umschlag', status: 'queued', output_ref: ref, priority: prio,
- format: body.envelope_format || 'dinlang', format_label: ENV_LABEL[body.envelope_format] || 'Kuvert',
- });
- }
- if (body.offer_request) {
- try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
- }
- return res.json({ ok: true, ref, job, mode: prio ? 'direct' : 'queue', busy, kuvert: wantsEnv });
- } catch (err) {
- logger.error(`[skrift-orders] manual-print: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Manueller Druck fehlgeschlagen.' });
- }
- });
-
- // ── Zahlung bestätigen (serverseitig verifiziert) ────────────────────────
- /**
- * Verifiziert die PayPal-Zahlung gegen die PayPal-API:
- * - Capture existiert und ist COMPLETED
- * - gezahlter Betrag == serverseitig gespeicherter Auftragsbetrag (gross_total)
- * Erst dann wird der Auftrag als bezahlt markiert. Dem Client wird nicht
- * vertraut – weder Betrag noch Status.
- */
- router.post('/payment', async (req, res) => {
- const { order_number, payment_ref } = req.body || {};
- if (!order_number || !payment_ref)
- return res.status(400).json({ error: 'order_number und payment_ref erforderlich.' });
- if (!PAYPAL_CLIENT_ID || !PAYPAL_SECRET) {
- logger.error('[skrift-orders] PayPal-Server-Zugangsdaten fehlen.');
- return res.status(500).json({ error: 'Zahlungsprüfung nicht konfiguriert.' });
- }
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
-
- const order = (await svc('orders').readByQuery({
- filter: { order_number: { _eq: String(order_number) } },
- limit: 1, fields: ['id', 'payment_status', 'gross_total'],
- }))?.[0];
- if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
- if (order.payment_status === 'bezahlt') return res.json({ ok: true, bereits: true });
-
- // Capture bei PayPal abfragen und prüfen.
- const token = await paypalToken();
- const r = await fetch(`${PAYPAL_API}/v2/payments/captures/${encodeURIComponent(String(payment_ref))}`, {
- headers: { Authorization: `Bearer ${token}` },
- });
- if (!r.ok) {
- logger.error(`[skrift-orders] PayPal-Capture ${payment_ref}: ${r.status}`);
- return res.status(400).json({ error: 'Zahlung konnte nicht verifiziert werden.' });
- }
- const capture = await r.json();
-
- const bezahlt = Number(capture?.amount?.value);
- const soll = Number(order.gross_total);
- const betragOk = Number.isFinite(bezahlt) && Math.abs(bezahlt - soll) < 0.01;
-
- if (capture?.status !== 'COMPLETED' || capture?.amount?.currency_code !== 'EUR' || !betragOk) {
- logger.warn(`[skrift-orders] Zahlung ungültig: status=${capture?.status} ` +
- `betrag=${capture?.amount?.value} soll=${soll}`);
- return res.status(400).json({ error: 'Zahlung stimmt nicht mit dem Auftrag überein.' });
- }
-
- await svc('orders').updateOne(order.id, {
- payment_status: 'bezahlt',
- payment_ref: String(payment_ref).slice(0, 128),
- status: 'bezahlt',
- });
- await svc('status_history').createOne({
- order: order.id, status: 'bezahlt',
- note: `Zahlung über PayPal verifiziert (${bezahlt.toFixed(2)} €).`,
- });
-
- return res.json({ ok: true });
- } catch (err) {
- logger.error(`[skrift-orders] payment: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Zahlung konnte nicht verifiziert werden.' });
- }
- });
-
- /**
- * Absender aus email_settings („Skrift "). Ohne das greift der
- * Directus-Standardabsender – dann steht „Directus" im Postfach.
- */
- async function absender(schema) {
- try {
- const s = await new ItemsService('email_settings', { schema, accountability: null })
- .readSingleton({ fields: ['sender_name', 'sender_email'] });
- if (s && s.sender_email) {
- return s.sender_name ? `${s.sender_name} <${s.sender_email}>` : s.sender_email;
- }
- } catch { /* Standard verwenden */ }
- return null;
- }
-
- /**
- * Schlanke interne Benachrichtigung (neue Bestellung/Muster/Anfrage). Empfänger
- * ist konfigurierbar (email_settings.team_email, Fallback SKRIFT_TEAM_EMAIL).
- * Leer → keine Team-Mail.
- */
- async function teamMail(schema, subject, html) {
- try {
- let to = TEAM_EMAIL;
- try {
- const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['team_email'] });
- if (es && es.team_email !== undefined && es.team_email !== null) to = String(es.team_email).trim();
- } catch { /* Feld evtl. noch nicht gebootstrapt → Fallback */ }
- if (!to) return; // bewusst leer = keine Team-Mails
- const mail = new MailService({ schema, accountability: null });
- const von = await absender(schema);
- await mail.send({ ...(von ? { from: von } : {}), to, subject, html });
- } catch (e) {
- logger.warn(`[skrift-orders] Team-Mail nicht versendet: ${e.message}`);
- }
- }
-
- /**
- * Sendet eine im Admin editierbare E-Mail-Vorlage (email_templates) an den
- * Kunden. Platzhalter {{feld}} werden aus `vars` gefüllt. Fehlt/deaktiviert die
- * Vorlage, passiert nichts (kein Fehler).
- */
- async function sendeVorlage(schema, key, to, vars) {
- try {
- const tpl = (await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
- filter: { key: { _eq: key }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
- }))?.[0];
- if (!tpl) return;
- const fuelle = (t) => String(t || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? ''));
- let signature = '';
- try {
- const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['signature'] });
- if (es?.signature) signature = es.signature;
- } catch { /* optional */ }
- const mail = new MailService({ schema, accountability: null });
- const von = await absender(schema);
- await mail.send({ ...(von ? { from: von } : {}), to, subject: fuelle(tpl.subject), html: fuelle(tpl.body) + signature });
- } catch (e) {
- logger.warn(`[skrift-orders] Vorlagen-Mail „${key}" nicht versendet: ${e.message}`);
- }
- }
-
- // ── Angebotsanfrage („Individuelles Angebot") ────────────────────────────
- router.post('/offer-request', async (req, res) => {
- const body = req.body || {};
- try { logger.info('[skrift-orders] offer-request Felder: ' + JSON.stringify(Object.keys(body))); } catch { /* egal */ }
- // Feld-Aliase (App + WordPress + Varianten). App-Feldnamen haben Vorrang.
- const vorname = feldWert(body, 'first_name', 'vorname', 'firstname', 'first', 'given_name');
- const nachname = feldWert(body, 'last_name', 'nachname', 'name', 'lastname', 'surname', 'familienname');
- const name = [vorname, nachname].filter(Boolean).join(' ');
- const phone = feldWert(body, 'phone', 'telefon', 'tel', 'telephone', 'telefonnummer');
- const product = feldWert(body, 'product', 'produkt_interesse', 'produkt', 'produkt_interese');
- const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
- const message = String(body.message || body.nachricht || body.vorhaben || '').trim();
-
- const errors = [];
- if (!name) errors.push('Name fehlt.');
- if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
- if (!message && !product) errors.push('Bitte beschreiben Sie Ihr Vorhaben.');
- if (message.length > 5000) errors.push('Beschreibung ist zu lang.');
- if (errors.length) return res.status(400).json({ errors });
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
-
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- // Falls es zu der E-Mail schon ein Konto gibt, hängen wir die Anfrage an.
- const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
- filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
- }))?.[0];
-
- const quantity = Number.parseInt(body.quantity, 10);
-
- await svc('offer_requests').createOne({
- type: 'angebot',
- name: clean(name),
- email,
- phone: clean(phone) || null,
- quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : null,
- product: product ? clean(product) : null,
- message: clean(message),
- address: (vorname || nachname) ? { first_name: clean(vorname), last_name: clean(nachname) } : null,
- status: 'neu',
- customer: customer?.id ?? null,
- });
-
- // Vollständige interne Benachrichtigung.
- await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
- `Neue Angebotsanfrage – im Board unter „Neu".
`
- + felderHtml([
- ['Vorname', clean(vorname)], ['Nachname', clean(nachname)],
- ['E-Mail', email], ['Telefon', clean(phone)],
- ['Produkt', product ? clean(product) : ''],
- ['Menge', Number.isFinite(quantity) && quantity > 0 ? quantity : ''],
- ['Vorhaben', clean(message)],
- ]));
-
- return res.json({ ok: true });
- } catch (err) {
- logger.error(`[skrift-orders] offer-request: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
- }
- });
-
- // ── Musterbestellung (kostenlos) ─────────────────────────────────────────
- router.post('/sample-request', async (req, res) => {
- const body = req.body || {};
- // Diagnose: welche Feldnamen kommen rein + welche davon GEFÜLLT (Werte selbst
- // NICHT loggen – Datenschutz). So sieht man, ob WP z. B. name/ort leer schickt.
- try {
- logger.info('[skrift-orders] sample-request Felder: ' + JSON.stringify(Object.keys(body))
- + ' | gefüllt: ' + JSON.stringify(Object.keys(body).filter((k) => String(body[k] ?? '').trim())));
- } catch { /* egal */ }
- const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
- // Feld-Aliase (App + WordPress + gängige Varianten). App-Feldnamen haben Vorrang.
- const first = feldWert(body, 'first_name', 'vorname', 'firstname', 'first', 'given_name');
- const last = feldWert(body, 'last_name', 'nachname', 'name', 'lastname', 'surname', 'familienname');
- const product = feldWert(body, 'product', 'produkt_interesse', 'produkt', 'produkt_interese');
- const phone = feldWert(body, 'phone', 'telefon', 'tel', 'telephone', 'telefonnummer');
- const street = feldWert(body, 'street', 'strasse', 'straße', 'adresse', 'address', 'strasse_hausnummer');
- const zip = feldWert(body, 'zip', 'zipcode', 'plz', 'postleitzahl', 'postal_code');
- const city = feldWert(body, 'city', 'ort', 'stadt', 'wohnort');
- const country = feldWert(body, 'country', 'land');
- const vollName = [first, last].filter(Boolean).join(' ');
- const errors = [];
- if (!product) errors.push('Produkt fehlt.');
- if (!vollName) errors.push('Name fehlt.');
- if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
- if (!street || !zip || !city) errors.push('Lieferadresse unvollständig.');
- if (errors.length) return res.status(400).json({ errors });
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
- filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
- }))?.[0];
-
- await svc('offer_requests').createOne({
- type: 'muster',
- name: clean(vollName),
- email,
- phone: clean(phone) || null,
- product: clean(product),
- message: clean(String(body.message || '').trim().slice(0, 1000)) || null,
- address: {
- first_name: clean(first),
- last_name: clean(last),
- street: clean(street),
- zip: clean(zip),
- city: clean(city),
- country: clean(country),
- },
- status: 'neu',
- customer: customer?.id ?? null,
- });
-
- const musterNachricht = clean(String(body.message || '').trim().slice(0, 2000));
- await teamMail(schema, `Neue Musteranfrage: ${clean(vollName)}`,
- `Neue Musteranfrage – im Board unter „Neu".
`
- + felderHtml([
- ['Vorname', clean(first)], ['Nachname', clean(last)],
- ['E-Mail', email], ['Telefon', clean(phone)], ['Produkt', clean(product)],
- ['Straße', clean(street)], ['PLZ', clean(zip)], ['Ort', clean(city)], ['Land', clean(country)],
- ['Nachricht', musterNachricht],
- ]));
-
- return res.json({ ok: true });
- } catch (err) {
- logger.error(`[skrift-orders] sample-request: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Musterbestellung konnte nicht gespeichert werden.' });
- }
- });
-
- // ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
- // POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
- // Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
- // per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
- // JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
- // Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
- // Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
- router.use('/inbound', (req, res, next) => {
- if (req.method !== 'POST') return next();
- if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
- let raw = '';
- req.setEncoding('utf8');
- req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
- req.on('end', () => {
- const s = raw.trim();
- if (s) {
- try { req.body = JSON.parse(s); }
- catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
- }
- next();
- });
- req.on('error', () => next());
- });
-
- router.post('/inbound/:kind', async (req, res) => {
- // Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
- let body = req.body || {};
- if (typeof body === 'string') {
- try { body = JSON.parse(body); }
- catch { body = Object.fromEntries(new URLSearchParams(body)); }
- }
- const kind = String(req.params.kind || '').toLowerCase();
- // Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
- // „v2" + gefüllt=[…] zeigt, dass der neue Mapping-Code läuft und WELCHE Felder
- // wirklich einen Wert haben (leere werden nicht gelistet).
- const gefuellt = Object.keys(body).filter((k) => String(body[k] ?? '').trim());
- logger.info(`[skrift-orders] inbound/${kind} v2: content-type=${req.get('content-type') || '—'}, `
- + `felder=[${Object.keys(body).join(',')}], gefüllt=[${gefuellt.join(',')}], `
- + `token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
-
- const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
- const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
- if (!secret) {
- logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
- return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
- }
- if (String(provided) !== secret) {
- return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
- }
- if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
-
- // WP schickt Vor-/Nachname GETRENNT: vorname + name(=Nachname). Anzeigename = beides.
- const vorname = feldWert(body, 'vorname', 'first_name', 'firstname');
- const nachname = feldWert(body, 'name', 'nachname', 'last_name', 'lastname');
- const name = [vorname, nachname].filter(Boolean).join(' ');
- const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
- if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
- if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
- filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
- }))?.[0];
-
- const phone = clean(feldWert(body, 'telefon', 'phone', 'tel', 'telefonnummer')) || null;
-
- let record; let subject; let mailHtml;
- if (kind === 'muster') {
- const produkt = feldWert(body, 'produkt_interesse', 'product', 'produkt');
- const strasse = feldWert(body, 'street', 'strasse', 'straße', 'adresse');
- const plz = feldWert(body, 'zipcode', 'zip', 'plz', 'postleitzahl');
- const ort = feldWert(body, 'ort', 'city', 'stadt', 'wohnort');
- const land = feldWert(body, 'country', 'land');
- record = {
- type: 'muster', name: clean(name), email, phone, product: produkt ? clean(produkt) : null, message: null,
- address: {
- first_name: clean(vorname),
- last_name: clean(nachname),
- street: clean(strasse),
- zip: clean(plz),
- city: clean(ort),
- country: clean(land),
- },
- status: 'neu', customer: customer?.id ?? null,
- };
- subject = `Neue Musteranfrage: ${name}`;
- mailHtml = felderHtml([
- ['Vorname', clean(vorname)], ['Nachname', clean(nachname)],
- ['E-Mail', email], ['Telefon', phone], ['Produkt', produkt ? clean(produkt) : ''],
- ['Straße', clean(strasse)], ['PLZ', clean(plz)], ['Ort', clean(ort)], ['Land', clean(land)],
- ]);
- } else {
- const unternehmen = clean(String(body.unternehmen || '').trim());
- const nachricht = clean(String(body.nachricht || body.message || '').trim());
- // offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
- const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
- record = {
- type: 'angebot', name: clean(name), email, phone, product: null, message,
- status: 'neu', customer: customer?.id ?? null,
- };
- subject = `Neue Angebotsanfrage: ${name}`;
- mailHtml = felderHtml([
- ['Vorname', clean(vorname)], ['Nachname', clean(nachname)], ['Unternehmen', unternehmen],
- ['E-Mail', email], ['Telefon', phone], ['Nachricht', nachricht],
- ]);
- }
-
- // „Gesamter Inhalt": alle übermittelten Felder (außer technische) mit anhängen,
- // damit auch nicht standardisierte Formularfelder in der Mail landen.
- const TECH = new Set(['token', 'dataprotection', '__form_id', '__refer', '__is_ajax', '__is_ajax_submit']);
- const rest = Object.entries(body).filter(([k, v]) => !TECH.has(k.toLowerCase()) && String(v ?? '').trim());
- if (rest.length) mailHtml += `Alle übermittelten Felder:
` + felderHtml(rest.map(([k, v]) => [k, clean(String(v))]));
-
- await svc('offer_requests').createOne(record);
- logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
-
- // Schlanke interne Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
- await teamMail(schema, subject, mailHtml);
-
- return res.json({ ok: true });
- } catch (err) {
- logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
- }
- });
-
- // ── Manueller Auftrag (Admin) ────────────────────────────────────────────
- // Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
- // die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
- // dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
- // Schriftstück(e) + Kuvert(e) eines/mehrerer Aufträge neu generieren (Operator).
- router.post('/regenerate', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- const ids = Array.isArray(body.orders) ? body.orders : (body.order != null ? [body.order] : []);
- if (!ids.length) return res.status(400).json({ error: 'Keine Auftrags-ID.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- let ok = 0;
- for (const id of ids) {
- // Vorschau-Session lösen → frisch rendern (nicht 1:1 aus der alten Vorschau kopieren).
- try { await svc('orders').updateOne(id, { preview_session: null }); } catch { /* egal */ }
- try {
- const r = await fetch(`${BACKEND_URL}/api/order/from-directus`, {
- method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({ orderId: id }),
- });
- if (r.ok) ok += 1;
- } catch (e) { logger.warn(`[skrift-orders] regenerate ${id}: ${e.message}`); }
- }
- return res.json({ ok: true, regeneriert: ok, gesamt: ids.length });
- } catch (err) {
- logger.error(`[skrift-orders] regenerate: ${err.stack || err.message}`);
- return res.status(500).json({ error: 'Neu-Generierung fehlgeschlagen.' });
- }
- });
-
- router.post('/manual-order', async (req, res) => {
- const acc = req.accountability;
- if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
- const body = req.body || {};
- const entries = Array.isArray(body.entries) ? body.entries : [];
- if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
- if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile erforderlich.' });
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const product = (await svc('products').readByQuery({
- filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
- }))?.[0];
- if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
-
- // Preis nur zur Aufzeichnung (kein Gate).
- let quote = null;
- try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
-
- // Kunde per E-Mail verknüpfen – existiert er noch nicht, automatisch anlegen.
- let customerId = null, customerEmail = null;
- if (body.customer_email && isEmail(norm(body.customer_email))) {
- const kundeMail = norm(body.customer_email);
- const users = new UsersService({ schema, accountability: null });
- const u = (await users.readByQuery({ filter: { email: { _eq: kundeMail } }, limit: 1, fields: ['id', 'email'] }))?.[0];
- if (u) { customerId = u.id; customerEmail = u.email; }
- else {
- try {
- const role = (await new RolesService({ schema, accountability: null }).readByQuery({
- filter: { name: { _eq: 'Kunde' } }, limit: 1, fields: ['id'],
- }))?.[0];
- customerId = await users.createOne({ email: kundeMail, password: zufallsPasswort(), role: role?.id ?? null, status: 'active' });
- customerEmail = kundeMail;
- } catch (e) { logger.warn(`[skrift-orders] manual-order Kunde anlegen: ${e.message}`); customerEmail = kundeMail; }
- }
- }
-
- let formatId = null;
- if (body.format) {
- formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
- }
- // Kuvertformat: explizite Angabe gewinnt; Datei-Produkte (Unterschriften)
- // immer DIN Lang; A6/Postkarten → C6; sonst Standard DIN Lang.
- const envelopeFormat = body.needs_envelope
- ? (body.envelope_format
- || (quote.product?.input_mode === 'datei' ? 'dinlang'
- : (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')))
- : null;
-
- const istInland = zielInland;
- const countAusland = entries.filter((e) => !istInland(e)).length;
-
- const jt = new Date();
- const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
- const naechsteNummer = async () => {
- const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
- return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
- };
-
- const orderData = {
- customer: customerId, customer_email: customerEmail,
- product: product.id, format: formatId,
- person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
- font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
- realistic: body.realistic !== false,
- // Relative Schriftgröße (getrennt Brief/Kuvert), 1 = Standard.
- font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
- font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
- // Signatur(en) auf dem Brief (SVG je [[signaturN]]) + Feinjustierung, für die finale Generierung.
- signatures_manual: (body.signatures && typeof body.signatures === 'object' && Object.keys(body.signatures).length)
- ? { signatures: body.signatures, offsets: (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {} }
- : null,
- // Mehrseitiges Schriftstück (Auto-Fluss). Seiten werden bei der Generierung erzeugt.
- multi_page: body.multi_page === true,
- source: 'operator', status: 'in_queue', production_status: 'eingegangen',
- payment_status: 'bezahlt', payment_method: 'rechnung',
- shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
- needs_envelope: !!body.needs_envelope,
- envelope_labeling: body.envelope_labeling ?? null,
- envelope_text: clean(body.envelope_text) ?? null,
- envelope_format: envelopeFormat,
- envelope_sender: clean(body.envelope_sender) ?? null,
- envelope_sender_scale: Math.max(0.5, Math.min(2, Number(body.envelope_sender_scale) || 1)),
- text_template: clean(body.text_template) ?? null,
- preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
- entries_count: entries.length,
- count_inland: entries.length - countAusland, count_ausland: countAusland,
- net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
- price_lines: quote?.lines ?? null,
- };
-
- let orderId, orderNumber;
- for (let v = 0; v < 6; v += 1) {
- orderNumber = await naechsteNummer();
- try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
- catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
- }
-
- const entriesSvc = svc('order_entries');
- let n = 0;
- for (const e of entries) {
- n += 1;
- await entriesSvc.createOne({
- order: orderId, letter_number: n,
- salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
- street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
- city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
- placeholders: e.placeholders && typeof e.placeholders === 'object'
- ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
- });
- }
-
- await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
-
- // Generierung IMMER anstoßen – für jedes Produkt. Das Backend entscheidet,
- // was zu erzeugen ist (Schriftstücke bei Handschrift-Produkten, Kuverts immer
- // wenn gewünscht; bei Datei-Produkten NUR die Kuverts). Nichts = No-Op.
- fetch(`${BACKEND_URL}/api/order/from-directus`, {
- method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({ orderId }),
- }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
-
- return res.json({ ok: true, order_number: orderNumber, id: orderId });
- } catch (err) {
- logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
- return res.status(400).json({ error: err.message });
- }
- });
-
- // ── Auftrag anlegen ──────────────────────────────────────────────────────
- router.post('/', async (req, res) => {
- const body = req.body || {};
- const errors = validate(body);
- if (errors.length) return res.status(400).json({ errors });
-
- try {
- const schema = await getSchema();
- const svc = (c) => new ItemsService(c, { schema, accountability: null });
- const email = norm(body.email);
- const entries = body.entries;
-
- const { settings } = await loadPricingContext(schema);
- const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
-
- const quote = await calculatePrice(schema, { ...body, quantity: entries.length, customerId: req.accountability?.user || null });
-
- // Zahlungsart erzwingen, wenn über dem PayPal-Limit
- let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung';
- if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung';
-
- // Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail.
- const users = new UsersService({ schema, accountability: null });
- let customer = (await users.readByQuery({
- filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'billing_address'],
- }))?.[0];
- if (!customer) {
- const rolesSvc = new RolesService({ schema, accountability: null });
- const role = (await rolesSvc.readByQuery({
- filter: { name: { _eq: 'Kunde' } }, limit: 1, fields: ['id'],
- }))?.[0];
- const id = await users.createOne({
- email,
- password: zufallsPasswort(),
- role: role?.id ?? null,
- status: 'active',
- person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
- first_name: clean(body.first_name) ?? null,
- last_name: clean(body.last_name) ?? null,
- company: clean(body.company) ?? null,
- phone: clean(body.phone) ?? null,
- });
- customer = { id, billing_address: null };
- }
-
- // Rechnungsadresse automatisch ins Konto übernehmen, wenn dort noch KEINE
- // hinterlegt ist (bestehende Adresse wird nicht überschrieben).
- try {
- const b = body.billing && typeof body.billing === 'object' ? body.billing : null;
- const hatAdresse = customer.billing_address
- && (customer.billing_address.street || customer.billing_address.city);
- if (b && (b.street || b.city) && !hatAdresse) {
- await users.updateOne(customer.id, {
- billing_address: {
- street: clean(b.street) || '', zip: clean(b.zip) || '',
- city: clean(b.city) || '', country: clean(b.country) || '',
- },
- });
- }
- } catch (e) { logger.warn(`[skrift-orders] Konto-Adresse nicht übernommen: ${e.message}`); }
-
- // Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur
- // bestellbar, wenn sie für dieses Konto freigeschaltet wurden.
- if (quote.product.type === 'contact') {
- const frei = await svc('customer_products').readByQuery({
- filter: { customer: { _eq: customer.id }, product: { _eq: quote.product.id } },
- limit: 1, fields: ['id'],
- });
- if (!frei?.length) {
- return res.status(403).json({ errors: ['Dieses Produkt ist für Ihr Konto nicht freigeschaltet.'] });
- }
- }
-
- // Format auflösen (optional)
- let formatId = null;
- if (body.format) {
- formatId = (await svc('formats').readByQuery({
- filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'],
- }))?.[0]?.id ?? null;
- }
-
- // Umschlagformat automatisch: A6/Postkarte → C6, sonst Standard DIN Lang.
- const envelopeFormat = body.needs_envelope
- ? (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')
- : null;
-
- // Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht).
- const istInland = zielInland;
- const countAusland = entries.filter((e) => !istInland(e)).length;
- const countInland = entries.length - countAusland;
-
- // Fortlaufende Bestellnummer je Tag: dd-mm-yy-NNN (NNN ab 001).
- const jt = new Date();
- const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
- const naechsteNummer = async () => {
- const heutige = await svc('orders').readByQuery({
- filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'],
- });
- return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
- };
-
- // Anlegen mit Wiederholung, falls zwei Bestellungen gleichzeitig dieselbe
- // Nummer träfen (order_number ist eindeutig).
- const orderData = {
- customer: customer.id,
- customer_email: email,
- product: quote.product.id,
- // Gewähltes Kundenangebot (Aufschläge sind bereits im Preis) + Namens-Zusatz.
- customer_offer: quote.offer?.id ?? null,
- product_name_suffix: quote.offer?.name_suffix ?? null,
- format: formatId,
- person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
- font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
- // Relative Schriftgröße (v.a. manuelle Aufträge), getrennt Brief/Kuvert. 1 = Standard.
- font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
- font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
- source: ['operator', 'portal', 'extern'].includes(body.source) ? body.source : 'configurator',
- // Extern (z. B. Etsy) wird über die Plattform bezahlt → nicht auf Zahlung warten.
- status: body.source === 'extern' ? 'bezahlt' : 'wartet_auf_zahlung',
- shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
- needs_envelope: !!body.needs_envelope,
- envelope_labeling: body.envelope_labeling ?? null,
- envelope_text: clean(body.envelope_text) ?? null,
- envelope_format: envelopeFormat,
- motif_mode: body.motif_mode ?? null,
- motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null,
- motif_upload: body.motif_upload || null,
- source_file: body.source_file || null,
- source_files: Array.isArray(body.source_files) && body.source_files.length
- ? body.source_files
- : (body.source_file ? [body.source_file] : null),
- billing: body.billing && typeof body.billing === 'object' ? body.billing : null,
- shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null,
- own_cards: body.motif_mode === 'eigene_karten',
- text_template: clean(body.text_template) ?? null,
- text_briefing: clean(body.text_briefing) ?? null,
- preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
- entries_count: entries.length,
- count_inland: countInland,
- count_ausland: countAusland,
- net_total: quote.net_total,
- vat_amount: quote.vat_amount,
- gross_total: quote.gross_total,
- price_lines: quote.lines ?? null,
- voucher: quote.voucher?.id ?? null,
- payment_method: paymentMethod,
- payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
- };
-
- let orderId, orderNumber;
- for (let versuch = 0; versuch < 6; versuch += 1) {
- orderNumber = await naechsteNummer();
- try {
- orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData });
- break;
- } catch (e) {
- const dup = /unique|duplicate|bereits|exists/i.test(String(e?.message || ''));
- if (versuch === 5 || !dup) throw e;
- }
- }
-
- // Empfängerzeilen – Briefnummer wird IMMER vom System vergeben
- const entriesSvc = svc('order_entries');
- let n = 0;
- for (const e of entries) {
- n += 1;
- await entriesSvc.createOne({
- order: orderId,
- letter_number: n,
- salutation: clean(e.salutation) ?? null,
- first_name: clean(e.first_name) ?? null,
- last_name: clean(e.last_name) ?? null,
- street: clean(e.street) ?? null,
- house_no: clean(e.house_no) ?? null,
- zip: clean(e.zip) ?? null,
- city: clean(e.city) ?? null,
- country: clean(e.country) ?? null,
- free_text: clean(e.free_text) ?? null,
- placeholders: e.placeholders && typeof e.placeholders === 'object'
- ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
- : null,
- // Abweichender Text nur für dieses Schriftstück (#5).
- text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null,
- });
- }
-
- // Zusatzleistungen verknüpfen
- if (Array.isArray(body.addons) && body.addons.length) {
- const items = await svc('price_items').readByQuery({
- filter: { key: { _in: body.addons }, category: { _eq: 'zusatzleistung' } },
- limit: -1, fields: ['id'],
- });
- for (const it of items || []) await svc('order_addons').createOne({ order: orderId, price_item: it.id });
- }
-
- // Gutschein-Einlösung protokollieren
- if (quote.voucher) {
- await svc('voucher_redemptions').createOne({ voucher: quote.voucher.id, order: orderId });
- await svc('vouchers').updateOne(quote.voucher.id, { used_count: (quote.voucher.used_count ?? 0) + 1 });
- }
-
- await svc('status_history').createOne({
- order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
- });
-
- // Motiv-Upload (Postkarte) → A6-Entwurf erzeugen + Kundenfreigabe anfordern.
- let freigabeToken = null;
- const istMotivUpload = body.motif_mode === 'upload' && (body.motif_upload || body.source_file);
- if (istMotivUpload) {
- freigabeToken = crypto.randomBytes(24).toString('base64url');
- try { await svc('orders').updateOne(orderId, { approval_status: 'pending', approval_token: freigabeToken }); }
- catch (e) { logger.warn(`[skrift-orders] Freigabe-Setup: ${e.message}`); }
- // A6-Layout asynchron erzeugen – blockiert die Antwort an den Kunden nicht.
- erzeugeMotivEntwurf(schema, orderId, body.motif_upload || body.source_file)
- .catch((e) => logger.warn(`[skrift-orders] Motiv-Entwurf ${orderNumber}: ${e.message}`));
- }
-
- // Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
- const kundeEmail = norm(body.email);
- if (isEmail(kundeEmail)) {
- let portal = (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim();
- try { const es = await svc('email_settings').readSingleton({ fields: ['portal_url'] }); if (es?.portal_url) portal = String(es.portal_url).trim(); } catch { /* Default */ }
- const vars = {
- order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' '),
- freigabe_link: freigabeToken ? `${portal.replace(/\/$/, '')}/freigabe/${freigabeToken}` : '',
- };
- if (istMotivUpload) sendeVorlage(schema, 'motiv_freigabe', kundeEmail, vars);
- else if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
- else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
- }
-
- // SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt – IMMER
- // und für jedes Produkt (das Backend entscheidet, was zu erzeugen ist:
- // Schriftstücke bei Handschrift-Produkten, Kuverts immer wenn gewünscht;
- // bei Datei-Produkten NUR die Kuverts, nicht das hochgeladene Dokument).
- // Feuern & vergessen – blockiert die Antwort an den Kunden nicht.
- fetch(`${BACKEND_URL}/api/order/from-directus`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
- body: JSON.stringify({ orderId }),
- }).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`));
-
- return res.json({
- order_number: orderNumber,
- net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
- payment_method: paymentMethod, paypal_allowed: quote.paypal_allowed,
- });
- } catch (err) {
- logger.error(`[skrift-orders] ${err.stack || err.message}`);
- return res.status(400).json({ error: err.message });
- }
- });
-};
-
-export default { id: 'skrift-orders', handler };
+/**
+ * Skrift – Bestell-Endpunkt
+ * ---------------------------------------------------------------------------
+ * POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt)
+ * POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben)
+ *
+ * Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer
+ * serverseitig aus products/price_items/pricing_settings neu berechnet.
+ *
+ * Preislogik:
+ * - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet
+ * von einem sicheren Mini-Interpreter (kein eval).
+ * - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im
+ * Code, Werte aus price_items.
+ * - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung.
+ */
+
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+import { Readable } from 'node:stream';
+
+/** Basisordner der generierten Auftragsdateien (read-only in Directus gemountet). */
+const OUTPUT_DIR = process.env.SKRIFT_OUTPUT_DIR || '/var/skrift-output';
+/** Directus-Upload-Ordner (lokaler Storage) – zum direkten Datei-Lesen. */
+const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
+
+// ── Sicherer Formel-Interpreter (kein eval) ─────────────────────────────────
+// Erlaubt: Zahlen, Variablen, + - * / ( ), unäres Minus, Vergleiche
+// (>= <= > < == !=), Ternär (cond ? a : b) und Funktionen sqrt/abs/min/max/
+// round/floor/ceil. Schreibweisen %var% und Math.fn werden vorab normalisiert.
+const FORMULA_FUNCS = {
+ sqrt: Math.sqrt, abs: Math.abs, min: Math.min, max: Math.max,
+ round: Math.round, floor: Math.floor, ceil: Math.ceil, pow: Math.pow,
+};
+function evalFormula(expr, vars) {
+ const src = String(expr).replace(/%(\w+)%/g, '$1').replace(/Math\./g, '');
+ const tokens = src.match(/\d+(?:\.\d+)?|[A-Za-z_]\w*|>=|<=|==|!=|[+\-*/()?:<>,]/g);
+ if (!tokens) throw new Error('Formel ist leer oder ungültig.');
+
+ let pos = 0;
+ const peek = () => tokens[pos];
+ const next = () => tokens[pos++];
+ const expect = (t) => { if (next() !== t) throw new Error(`Erwartet "${t}" in der Formel.`); };
+
+ function parseTernary() {
+ const cond = parseCompare();
+ if (peek() === '?') {
+ next();
+ const a = parseTernary();
+ expect(':');
+ const b = parseTernary();
+ return cond ? a : b;
+ }
+ return cond;
+ }
+ function parseCompare() {
+ const left = parseAdd();
+ const op = peek();
+ if (['>=', '<=', '>', '<', '==', '!='].includes(op)) {
+ next();
+ const right = parseAdd();
+ if (op === '>=') return left >= right ? 1 : 0;
+ if (op === '<=') return left <= right ? 1 : 0;
+ if (op === '>') return left > right ? 1 : 0;
+ if (op === '<') return left < right ? 1 : 0;
+ if (op === '==') return left === right ? 1 : 0;
+ return left !== right ? 1 : 0;
+ }
+ return left;
+ }
+ function parseAdd() {
+ let left = parseMul();
+ while (peek() === '+' || peek() === '-') {
+ const op = next();
+ const right = parseMul();
+ left = op === '+' ? left + right : left - right;
+ }
+ return left;
+ }
+ function parseMul() {
+ let left = parseUnary();
+ while (peek() === '*' || peek() === '/') {
+ const op = next();
+ const right = parseUnary();
+ if (op === '/' && right === 0) throw new Error('Division durch 0 in der Formel.');
+ left = op === '*' ? left * right : left / right;
+ }
+ return left;
+ }
+ function parseUnary() {
+ if (peek() === '-') { next(); return -parseUnary(); }
+ return parsePrimary();
+ }
+ function parsePrimary() {
+ const t = next();
+ if (t === undefined) throw new Error('Formel unvollständig.');
+ if (t === '(') { const v = parseTernary(); expect(')'); return v; }
+ if (/^\d/.test(t)) return parseFloat(t);
+ if (/^[A-Za-z_]/.test(t)) {
+ if (peek() === '(') { // Funktionsaufruf
+ next();
+ const args = [];
+ if (peek() !== ')') {
+ args.push(parseTernary());
+ while (peek() === ',') { next(); args.push(parseTernary()); }
+ }
+ expect(')');
+ const fn = FORMULA_FUNCS[t.toLowerCase()];
+ if (!fn) throw new Error(`Unbekannte Funktion: "${t}"`);
+ return fn(...args);
+ }
+ if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
+ throw new Error(`Unbekannte Variable in der Formel: "${t}"`);
+ }
+ throw new Error(`Unerwartetes Token: "${t}"`);
+ }
+
+ const result = parseTernary();
+ if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.');
+ if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Wert.');
+ return result;
+}
+
+const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
+
+/** Adresse, an die Angebotsanfragen gemeldet werden. */
+const TEAM_EMAIL = process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de';
+
+/** Backend, das die SVGs erzeugt. */
+const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
+const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
+
+/** Vorschauen pro Kennung und Tag. */
+const PREVIEW_LIMIT = Number(process.env.SKRIFT_PREVIEW_LIMIT || 10);
+/** Wie viele Dokumente eine Vorschau maximal zurückgibt. */
+const PREVIEW_MAX_DOCS = 3;
+
+/** PayPal – Server-Zugangsdaten (Secret liegt NUR hier, nie im Frontend). */
+const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || '';
+const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || '';
+const PAYPAL_API = (process.env.PAYPAL_ENV || 'sandbox') === 'live'
+ ? 'https://api-m.paypal.com'
+ : 'https://api-m.sandbox.paypal.com';
+
+/** OAuth-Token für die PayPal-REST-API holen. */
+async function paypalToken() {
+ const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_SECRET}`).toString('base64');
+ const r = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
+ method: 'POST',
+ headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'grant_type=client_credentials',
+ });
+ if (!r.ok) throw new Error(`PayPal-Token: ${r.status}`);
+ return (await r.json()).access_token;
+}
+
+/**
+ * Zielland-Erkennung je Empfänger für den Länder-Split.
+ * Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext,
+ * „D-" vor der PLZ, oder nur eine PLZ ohne Auslandshinweis. Sonst → weltweit (INT).
+ */
+// Auslandsländer als GANZE Wörter (\b) – sonst matchte „schweiz" in „Schweizer
+// Straße", „usa" in „Neusass", „polen" in „Polenz" usw. und flaggte fälschlich Ausland.
+const AUSLAND_RE = /\b(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)\b/i;
+// HTML-Escaping + Feld-Tabelle für vollständige Anfrage-Team-Mails.
+const escH = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
+function felderHtml(pairs) {
+ const rows = pairs
+ .filter(([, v]) => v != null && String(v).trim())
+ .map(([k, v]) => `| ${escH(k)} | `
+ + `${escH(v).replace(/\n/g, ' ')} |
`)
+ .join('');
+ return ``;
+}
+
+// Erstes nicht-leeres Feld aus mehreren möglichen Namen (App + WordPress-Varianten).
+function feldWert(body, ...keys) {
+ for (const k of keys) {
+ const v = body && body[k];
+ if (v != null && String(v).trim()) return String(v).trim();
+ }
+ return '';
+}
+function zielInland(e) {
+ const land = String(e?.country || '').trim();
+ if (land) {
+ if (/^(de|deu|ger|d|brd|deutschland|germany)$/i.test(land)) return true;
+ if (/deutschland|germany/i.test(land)) return true;
+ if (AUSLAND_RE.test(land)) return false;
+ return true; // unbekannter/leerer Ländertext → NICHT automatisch Ausland
+ }
+ const txt = String(e?.free_text || '');
+ if (/deutschland|germany/i.test(txt)) return true;
+ if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true;
+ if (AUSLAND_RE.test(txt)) return false;
+ return true;
+}
+
+const handler = (router, { services, getSchema, logger }) => {
+ const { ItemsService, MailService, UsersService, RolesService, AssetsService, FilesService } = services;
+
+ // Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar).
+ const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, '');
+
+ /** Liest eine Directus-Datei (Upload) serverseitig als Buffer – direkt von der
+ * Platte (lokaler Storage), wie der Mailer. Vermeidet AssetsService.getAsset,
+ * dessen Signatur je nach Directus-Version „transforms"-Fehler wirft. */
+ async function dateiBytes(schema, id) {
+ const f = await new ItemsService('directus_files', { schema, accountability: null })
+ .readOne(id, { fields: ['filename_disk'] });
+ if (!f?.filename_disk) throw new Error(`Datei ${id} nicht gefunden.`);
+ return fs.readFileSync(path.join(UPLOAD_ROOT, f.filename_disk));
+ }
+
+ /** Erzeugt aus einem hochgeladenen Motiv das A6-Drucklayout (152×109 mm, 2 mm
+ * Beschnitt, Endformat 148×105) – Motiv mittig, randabfallend – und legt es als
+ * Datei ab (orders.motif_layout). Rein serverseitig, für die Kundenfreigabe. */
+ async function erzeugeMotivEntwurf(schema, orderId, fileId) {
+ const meta = await new ItemsService('directus_files', { schema, accountability: null })
+ .readOne(fileId, { fields: ['type', 'filename_disk'] });
+ if (!meta?.filename_disk) throw new Error(`Motiv-Datei ${fileId} nicht gefunden.`);
+ const base64 = fs.readFileSync(path.join(UPLOAD_ROOT, meta.filename_disk)).toString('base64');
+ const mime = meta.type || 'image/png';
+ // A6 quer: Motiv wird VOLLSTÄNDIG in die Trim-Fläche (148×105) eingepasst
+ // (mittig, nichts beschnitten) auf weißem A6-Grund inkl. 2 mm Beschnitt.
+ const svg = ``;
+ const files = new FilesService({ schema, accountability: null });
+ const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
+ const layoutId = await files.uploadOne(
+ Readable.from(Buffer.from(svg, 'utf8')),
+ { storage, filename_download: `a6-entwurf-${orderId}.svg`, title: `A6-Entwurf ${orderId}`, type: 'image/svg+xml' },
+ undefined, { emitEvents: false },
+ );
+ await new ItemsService('orders', { schema, accountability: null }).updateOne(orderId, { motif_layout: layoutId });
+ return layoutId;
+ }
+
+ /** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
+ async function signatureForm(schema, pdfId, sigList) {
+ const pdfBytes = await dateiBytes(schema, pdfId);
+ const form = new FormData();
+ form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf');
+ const jobs = [];
+ for (let i = 0; i < sigList.length; i += 1) {
+ const s = sigList[i] || {};
+ if (!s.file_id) continue;
+ const svgBytes = await dateiBytes(schema, s.file_id);
+ const svgName = `sig${i + 1}.svg`;
+ form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName);
+ const marker = String(s.marker_key || `SIG${i + 1}`).replace(/[§[\]]/g, '').trim();
+ jobs.push({
+ token: `[[${marker}]]`,
+ svg_id: svgName,
+ position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau',
+ breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45,
+ versatz_x_mm: Number(s.offset_x_mm) || 0,
+ versatz_y_mm: Number(s.offset_y_mm) || 0,
+ });
+ }
+ form.append('job', JSON.stringify(jobs));
+ return { form, jobCount: jobs.length };
+ }
+
+ /** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */
+ async function signatureAufruf(req, res, pfad, contentType, key) {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ // Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem
+ // Dokument (document_id) – eins von beiden muss vorhanden sein.
+ if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ let ord = null;
+ if (body.order) {
+ ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] });
+ if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
+ }
+ const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
+ if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' });
+ const sigList = Array.isArray(body.signatures) ? body.signatures : [];
+ if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' });
+
+ const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
+ if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
+
+ let r;
+ try {
+ r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
+ } catch (e) {
+ // Netzwerkfehler (Service down / Containername nicht auflösbar / falsche URL).
+ logger.warn(`[skrift-orders] Signatur-Service nicht erreichbar (${SIGN_URL}): ${e.message}`);
+ return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}. SKRIFT_SIGN_URL / Container prüfen.` });
+ }
+ if (!r.ok) {
+ const t = await r.text();
+ return res.status(502).json({ error: 'Signatur-Service-Fehler (evtl. Marker nicht im PDF gefunden).', detail: `${r.status}: ${String(t).slice(0, 400)}` });
+ }
+ const buf = Buffer.from(await r.arrayBuffer());
+ return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' });
+ } catch (err) {
+ logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.', detail: String(err.message || err).slice(0, 400) });
+ }
+ }
+
+ // Vorschau-PDF (Dokument mit eingefügten Unterschriften).
+ router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64'));
+ // A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion).
+ router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64'));
+
+ // Unterschriftenservice als ECHTEN Auftrag anlegen: erzeugt je PDF-Seite eine
+ // A4-SVG (leer, wo kein Marker), schreibt sie als Schriftstück in den Auftrags-
+ // ordner und legt den Auftrag an. Danach läuft alles über die normale Produktion
+ // (Drucken → Plotter). KEIN neuer Job-Typ. Setzt den schreibbaren Output-Mount voraus.
+ router.post('/signature-order', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
+ const sigList = Array.isArray(body.signatures) ? body.signatures : [];
+ if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Unterschrift wählen.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+
+ let ord = null;
+ if (body.order) {
+ ord = await svc('orders').readOne(body.order, { fields: ['id', 'source_file', 'source_files'] });
+ if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
+ }
+ const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
+ if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (document_id fehlt).' });
+
+ const prodKey = body.product || 'unterschriftenservice';
+ let product = (await svc('products').readByQuery({ filter: { key: { _eq: prodKey } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
+ if (!product) product = (await svc('products').readByQuery({ filter: { input_mode: { _eq: 'datei' } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
+ if (!product) return res.status(400).json({ error: 'Kein Unterschriftenservice-/Datei-Produkt gefunden.' });
+
+ let customerId = null, customerEmail = null;
+ if (body.customer_email && isEmail(norm(body.customer_email))) {
+ const u = (await new UsersService({ schema, accountability: null }).readByQuery({
+ filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
+ }))?.[0];
+ if (u) { customerId = u.id; customerEmail = u.email; } else customerEmail = norm(body.customer_email);
+ }
+
+ // A4-Signaturen erzeugen (eine je Seite) – JSON-Variante.
+ const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
+ if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
+ let r;
+ try { r = await fetch(`${SIGN_URL}/render-json`, { method: 'POST', body: form }); }
+ catch (e) { return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}` }); }
+ if (!r.ok) {
+ let d = ''; try { d = (await r.json())?.detail || ''; } catch { /* ignore */ }
+ return res.status(502).json({ error: 'Signatur-Service-Fehler (Marker im PDF?).', detail: String(Array.isArray(d) ? d.join('; ') : d).slice(0, 400) });
+ }
+ const pages = (await r.json())?.pages || [];
+ if (!pages.length) return res.status(422).json({ error: 'Keine Seiten erzeugt.' });
+
+ const jt = new Date();
+ const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
+ const naechsteNummer = async () => {
+ const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
+ return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
+ };
+ const orderData = {
+ customer: customerId, customer_email: customerEmail, product: product.id,
+ person_type: 'privat', source: 'operator', status: 'in_queue', production_status: 'eingegangen',
+ payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel',
+ needs_envelope: false, envelope_labeling: 'keine',
+ source_file: pdfId, source_files: [pdfId],
+ entries_count: pages.length, count_inland: pages.length, count_ausland: 0,
+ };
+ let orderId, orderNumber;
+ for (let v = 0; v < 6; v += 1) {
+ orderNumber = await naechsteNummer();
+ try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
+ catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
+ }
+
+ const entriesSvc = svc('order_entries');
+ for (let i = 0; i < pages.length; i += 1) {
+ await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` });
+ }
+ await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Unterschriftenservice – manuell angelegt.' }).catch(() => {});
+
+ // Seiten-SVGs als Schriftstück in den Auftragsordner schreiben.
+ const ordner = path.join(OUTPUT_DIR, orderNumber);
+ fs.mkdirSync(ordner, { recursive: true });
+ pages.forEach((p, i) => {
+ fs.writeFileSync(path.join(ordner, `letter_${String(i + 1).padStart(3, '0')}.svg`), p.svg, 'utf8');
+ });
+
+ return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length });
+ } catch (err) {
+ logger.error(`[skrift-orders] signature-order: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Auftrag konnte nicht angelegt werden.', detail: String(err.message || err).slice(0, 400) });
+ }
+ });
+
+ // Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
+ const zufallsPasswort = () =>
+ `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
+
+ const norm = (e) => String(e || '').trim().toLowerCase();
+ const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
+
+ /** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */
+ function makeSanitizer(whitelist) {
+ let re;
+ try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); }
+ catch { re = null; }
+ return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
+ }
+
+ async function loadPricingContext(schema) {
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const [settingsRaw, priceItems] = await Promise.all([
+ svc('pricing_settings').readSingleton({}),
+ svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1,
+ fields: ['key', 'price', 'unit', 'category'] }),
+ ]);
+ const settings = settingsRaw || {};
+ const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p]));
+ return { settings, priceItems: byKey };
+ }
+
+ /** Berechnet den Preis vollständig serverseitig. */
+ async function calculatePrice(schema, input) {
+ const { settings, priceItems } = await loadPricingContext(schema);
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+
+ const product = (await svc('products').readByQuery({
+ filter: { key: { _eq: input.product } }, limit: 1,
+ fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'],
+ }))?.[0];
+ if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
+ if (product.pricing_mode === 'auf_anfrage')
+ throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`);
+
+ const menge = Math.max(0, parseInt(input.quantity, 10) || 0);
+ if (menge < 1) throw new Error('Menge muss mindestens 1 sein.');
+
+ const lines = [];
+ // perUnit=true kennzeichnet Positionen, die pro Stück anfallen (fließen in den
+ // „Preis pro Stück"); einmalige Kosten (Sammelversand, Gutschein …) nicht.
+ const add = (label, amount, perUnit = false) => { if (amount) lines.push({ label, amount: round2(amount), per_unit: perUnit }); };
+ const priceOf = (key) => Number(priceItems[key]?.price) || 0;
+ const unitOf = (key) => priceItems[key]?.unit || 'einmalig';
+ const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key));
+
+ // 1) Grundpreis über die editierbare Formel × Mengen-Multiplikator.
+ // Der Multiplikator bildet den Kleinmengen-Aufschlag ab und ist je Segment
+ // (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
+ // Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
+ const multFormula = settings.multiplier_formula || '1';
+ // ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
+ // überschatten, die weiter unten für norm(input.email) gebraucht wird.
+ const normMenge = Number(product.norm_qty) || 0;
+ // Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
+ // Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
+ const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
+ const faktorA = zahlOder(product.price_factor_a, 1);
+ const faktorE = zahlOder(product.price_factor_e, 3);
+ const multVars = {
+ qty: menge, menge, norm: normMenge,
+ a: faktorA, e: faktorE,
+ base_price: Number(product.base_price) || 0,
+ };
+ // Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
+ let multiplier = 1;
+ if (normMenge > 0) {
+ try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
+ }
+ if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
+
+ // Grundpreis-Formel. Variablen sind bewusst nur base_price und menge.
+ // Ist die Formel fehlerhaft (z. B. versehentlich die Multiplikator-Formel
+ // eingetragen), fällt der Preis auf die Standardformel zurück, statt die
+ // gesamte Preisauskunft scheitern zu lassen.
+ const formula = settings.formula || 'base_price * menge';
+ const grundVars = { base_price: Number(product.base_price) || 0, menge };
+ let basis;
+ try {
+ basis = evalFormula(formula, grundVars);
+ } catch (e) {
+ logger.warn(`[skrift-orders] Grundpreis-Formel ungültig ("${formula}"): ${e.message} – nutze base_price * menge.`);
+ basis = evalFormula('base_price * menge', grundVars);
+ }
+ if (!Number.isFinite(basis)) basis = grundVars.base_price * menge;
+ const grund = basis * multiplier;
+ add(`${product.name} (${menge} Stk.)`, grund, true);
+
+ // 1b) Mengenrabatt-Staffel: höchste passende Stufe (auf den Grundpreis).
+ const tiers = Array.isArray(settings.quantity_discounts) ? settings.quantity_discounts : [];
+ const stufe = tiers
+ .filter((t) => menge >= (Number(t.min_qty) || 0) && Number(t.percent) > 0)
+ .sort((a, b) => (Number(b.min_qty) || 0) - (Number(a.min_qty) || 0)
+ || (Number(b.percent) || 0) - (Number(a.percent) || 0))[0];
+ if (stufe) add(`Mengenrabatt (${stufe.percent} %)`, -(grund * (Number(stufe.percent) / 100)), true);
+
+ // 2) Versand – Kuvert ist bei Einzelversand Pflicht
+ if (input.shipping_type === 'einzeln') {
+ const auslandCount = (input.entries || [])
+ .filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length;
+ const inlandCount = menge - auslandCount;
+ add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
+ add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
+ } else {
+ // Sammelversand-Staffel: bis 5 Schriftstücke günstiger (sammelversand_klein),
+ // ab 6 der reguläre Satz. Fallback 1,80 €, falls der kleine Satz fehlt.
+ const sammelKlein = priceItems['sammelversand_klein'] ? priceOf('sammelversand_klein') : 1.80;
+ add('Sammelversand', menge <= 5 ? sammelKlein : priceOf('sammelversand'), false);
+ }
+
+ // 3) Kuvert + Beschriftung – NUR bei Sammelversand.
+ // Bei Einzelversand an die Empfänger ist Kuvert + Beschriftung bereits im
+ // Einzelversand-Wert (Porto) enthalten und wird NICHT separat berechnet.
+ if (input.needs_envelope && input.shipping_type !== 'einzeln') {
+ add('Kuvert', applyUnit('kuvert'), unitOf('kuvert') === 'pro_stueck');
+ if (input.envelope_labeling && input.envelope_labeling !== 'keine')
+ add('Kuvert-Beschriftung', applyUnit('beschriftung'), unitOf('beschriftung') === 'pro_stueck');
+ }
+
+ // 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
+ if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'), unitOf('motiv_upload') === 'pro_stueck');
+ for (const key of input.addons || []) {
+ const item = priceItems[key];
+ if (!item || item.category !== 'zusatzleistung') continue;
+ add(item.key, applyUnit(key), unitOf(key) === 'pro_stueck');
+ }
+
+ // 4b) Kundenrabatt – nur bei bekanntem Konto (E-Mail). Rabatt gilt auf den
+ // Schriftstück-Grundpreis. „erste_n_stueck" zählt KUMULATIV über alle
+ // bisherigen (nicht stornierten) Aufträge des Kunden.
+ if (input.email && isEmail(norm(input.email))) {
+ try {
+ const users = new UsersService({ schema, accountability: null });
+ const kunde = (await users.readByQuery({
+ filter: { email: { _eq: norm(input.email) } }, limit: 1, fields: ['id'],
+ }))?.[0];
+ if (kunde) {
+ const jetzt = new Date().toISOString();
+ const rabatt = (await svc('customer_discounts').readByQuery({
+ filter: {
+ customer: { _eq: kunde.id }, active: { _eq: true },
+ _or: [{ valid_until: { _null: true } }, { valid_until: { _gte: jetzt } }],
+ },
+ limit: 1, sort: ['-value'],
+ fields: ['label', 'type', 'value', 'scope', 'limit_stueck'],
+ }))?.[0];
+ if (rabatt && Number(rabatt.value) > 0) {
+ const proStueck = menge > 0 ? grund / menge : 0;
+ let stueck = menge; // scope 'alle'
+ if (rabatt.scope === 'erste_n_stueck') {
+ const frueher = await svc('orders').readByQuery({
+ filter: { customer: { _eq: kunde.id }, status: { _neq: 'storniert' } },
+ limit: -1, fields: ['entries_count'],
+ });
+ const genutzt = (frueher || []).reduce((s, o) => s + (Number(o.entries_count) || 0), 0);
+ stueck = Math.min(menge, Math.max(0, (Number(rabatt.limit_stueck) || 0) - genutzt));
+ }
+ if (stueck > 0) {
+ const betrag = rabatt.type === 'prozent'
+ ? proStueck * stueck * (Number(rabatt.value) / 100)
+ : Number(rabatt.value) * stueck;
+ add(`Kundenrabatt${rabatt.label ? ` (${rabatt.label})` : ''}`, -Math.min(betrag, grund), true);
+ }
+ }
+ }
+ } catch (e) { logger.warn(`[skrift-orders] Kundenrabatt übersprungen: ${e.message}`); }
+ }
+
+ let net = lines.reduce((s, l) => s + l.amount, 0);
+
+ // 5) Gutschein
+ // Kundenangebot (customer_offers): frei benannte Aufschläge pro Stück, wenn der
+ // eingeloggte Kunde ein Angebot gewählt hat. Serverseitig geprüft (Zugehörigkeit).
+ let offer = null;
+ if (input.offer) {
+ const off = (await svc('customer_offers').readByQuery({
+ filter: { id: { _eq: input.offer }, active: { _eq: true } }, limit: 1,
+ fields: ['id', 'customer', 'product.key', 'name_suffix', 'surcharges', 'allow_multi_page'],
+ }))?.[0];
+ // Nur anwenden, wenn das Angebot dem anfragenden Kunden UND dem Produkt gehört.
+ if (off && input.customerId && String(off.customer) === String(input.customerId)
+ && (!off.product?.key || off.product.key === input.product)) {
+ offer = off;
+ for (const s of (Array.isArray(off.surcharges) ? off.surcharges : [])) {
+ const betrag = Number(s?.amount) || 0;
+ if (betrag) { const gesamt = betrag * menge; add(s.label || 'Aufschlag', gesamt, true); net += gesamt; }
+ }
+ }
+ }
+
+ let voucher = null;
+ if (input.voucher_code) {
+ const v = (await svc('vouchers').readByQuery({
+ filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } },
+ limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'],
+ }))?.[0];
+ const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now();
+ const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses;
+ if (v && !expired && !exhausted) {
+ const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value);
+ const capped = Math.min(discount, net);
+ add(`Gutschein ${v.code}`, -capped);
+ net -= capped;
+ voucher = v;
+ }
+ }
+
+ net = round2(Math.max(0, net));
+ // Netto-Summe NUR der pro-Stück-Positionen (für „Preis pro Stück" ohne Einmalkosten).
+ const unitNet = round2(Math.max(0, lines.filter((l) => l.per_unit).reduce((s, l) => s + l.amount, 0)));
+ const mwst = Number(settings.mwst_percent ?? 19);
+ const vat = round2(net * (mwst / 100));
+ const gross = round2(net + vat);
+
+ // 6) Zahlungsart: ab Limit nur noch Rechnung
+ const limit = Number(settings.paypal_limit_net ?? 200);
+ const paypalAllowed = net < limit;
+
+ return { product, lines, net_total: net, unit_net_total: unitNet, vat_amount: vat, gross_total: gross,
+ mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher, offer };
+ }
+
+ function validate(body) {
+ const errors = [];
+ if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.');
+ if (!body?.product) errors.push('Produkt fehlt.');
+ const entries = Array.isArray(body?.entries) ? body.entries : [];
+ if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.');
+ // Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller)
+ if (body?.shipping_type === 'einzeln' && !body?.needs_envelope)
+ errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.');
+ return errors;
+ }
+
+ // ── Nur Preis berechnen (Live-Anzeige) ───────────────────────────────────
+ router.post('/quote', async (req, res) => {
+ try {
+ const schema = await getSchema();
+ const body = req.body || {};
+ const quote = await calculatePrice(schema, {
+ ...body, quantity: body.quantity ?? (body.entries || []).length,
+ customerId: req.accountability?.user || null,
+ });
+ return res.json({
+ lines: quote.lines, net_total: quote.net_total, unit_net_total: quote.unit_net_total,
+ vat_amount: quote.vat_amount,
+ gross_total: quote.gross_total, mwst_percent: quote.mwst_percent,
+ paypal_allowed: quote.paypal_allowed,
+ });
+ } catch (err) {
+ return res.status(400).json({ error: err.message });
+ }
+ });
+
+ // ── Angebote des eingeloggten Kunden (Auswahl im Konfigurator) ────────────
+ router.get('/my-offers', async (req, res) => {
+ const uid = req.accountability?.user;
+ if (!uid) return res.json({ offers: [] });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const filter = { customer: { _eq: uid }, active: { _eq: true } };
+ if (req.query.product) filter.product = { key: { _eq: String(req.query.product) } };
+ const offers = await svc('customer_offers').readByQuery({
+ filter, limit: -1, sort: ['label'],
+ fields: ['id', 'label', 'name_suffix', 'note', 'allow_multi_page', 'surcharges', 'product.key', 'product.name'],
+ });
+ return res.json({ offers: offers || [] });
+ } catch (err) {
+ logger.error(`[skrift-orders] my-offers: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Angebote konnten nicht geladen werden.' });
+ }
+ });
+
+ // ── Vorschau ─────────────────────────────────────────────────────────────
+ /**
+ * Erzeugt bis zu PREVIEW_MAX_DOCS Vorschau-SVGs über das Backend.
+ * Das Kontingent wird serverseitig geführt – jede Generierung kostet einen
+ * Scriptalizer-Aufruf, deshalb darf der Client das nicht bestimmen.
+ */
+ router.post('/preview', async (req, res) => {
+ const body = req.body || {};
+ // Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
+ // und liefert keine SVGs zurück – nur has_overflow/overflow.
+ const nurPruefen = body.validate === true;
+ // Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
+ const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
+ const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
+ if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
+ // Ohne Brieftext nur erlaubt, wenn Kuverts gewünscht sind (dann nur Kuvert-Vorschau).
+ const willKuvertVorschau = !!(body.envelope_labeling && body.envelope_labeling !== 'keine');
+ if ((!body.text || !String(body.text).trim()) && !willKuvertVorschau)
+ return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const heute = new Date().toISOString().slice(0, 10);
+
+ const vorhanden = (nurPruefen || istIntern) ? null : (await svc('preview_usage').readByQuery({
+ filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
+ }))?.[0];
+ const verbraucht = vorhanden?.count ?? 0;
+ if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
+ return res.status(429).json({
+ error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
+ remaining: 0, limit: PREVIEW_LIMIT,
+ });
+ }
+
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const entries = (Array.isArray(body.entries) ? body.entries : []).slice(0, PREVIEW_MAX_DOCS);
+ const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
+ const realistisch = body.realistic !== false;
+ // App-Formatschlüssel → Backend-Formate (wie im directus-controller).
+ // Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
+ // Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
+ // Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
+ const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
+ const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
+ // A4 → DIN Lang, A6 → C6
+ const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
+ // Postkarte (Text links, Adresse rechts, ein SVG) – Erkennung über render_formats.kind.
+ const istPk = (await svc('render_formats').readByQuery({
+ filter: { key: { _eq: briefFormat } }, limit: 1, fields: ['kind'],
+ }))?.[0]?.kind === 'postkarte';
+
+ const platzhalterVon = (e, i) => {
+ // 5 Adresszeilen (5-Zeilen-Modus) auch als [[Zeile1]]..[[Zeile5]].
+ const zeilen = String(e.free_text || '').split('\n');
+ return {
+ Anrede: clean(e.salutation || ''),
+ Vorname: clean(e.first_name || ''),
+ Nachname: clean(e.last_name || ''),
+ Strasse: clean([e.street, e.house_no].filter(Boolean).join(' ')),
+ PLZ: clean(e.zip || ''),
+ Ort: clean(e.city || ''),
+ Land: clean(e.country || ''),
+ Briefnummer: String(i + 1),
+ Zeile1: clean(zeilen[0] || ''), Zeile2: clean(zeilen[1] || ''), Zeile3: clean(zeilen[2] || ''),
+ Zeile4: clean(zeilen[3] || ''), Zeile5: clean(zeilen[4] || ''),
+ ...(e.placeholders && typeof e.placeholders === 'object'
+ ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
+ : {}),
+ };
+ };
+
+ const adressBlock = (e) => {
+ if (e.free_text) return clean(String(e.free_text));
+ const name = [e.first_name, e.last_name].filter(Boolean).join(' ');
+ const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
+ const ort = [e.zip, e.city].filter(Boolean).join(' ');
+ return clean([name, strasse, ort].filter(Boolean).join('\n'));
+ };
+
+ // Relative Schriftgröße (manuelle Aufträge), getrennt für Brief/Kuvert. 1 = Standard.
+ const skala = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0.8, Math.min(1.5, n)) : 1; };
+ const fsLetter = skala(body.font_scale_letter);
+ const fsEnv = skala(body.font_scale_envelope);
+ // Signaturen (SVG je [[signaturN]]) + Feinjustierung – nur Brief, nicht Kuvert.
+ const sigMap = (body.signatures && typeof body.signatures === 'object') ? body.signatures : {};
+ const sigOff = (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {};
+ // Mehrseitiges Schriftstück: Text fließt über mehrere Seiten (kein Überlauf-Fehler).
+ const mehrseitig = body.multi_page === true;
+ const basis = entries.length ? entries : [{}];
+ const letters = [];
+ basis.forEach((e, i) => {
+ // Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
+ const nr = i + 1;
+ // Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
+ const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
+
+ // Postkarte: EIN Dokument mit Text + Absender + Empfänger (Marker-getrennt).
+ // Ohne Brieftext KEINE Karte erzeugen (dann greift ggf. nur der Kuvert-Zweig).
+ if (istPk && String(briefText).trim()) {
+ const sender = clean(String(body.envelope_sender || '')).trim();
+ const recip = adressBlock(e);
+ let combined = String(clean(briefText) || '');
+ if (sender) combined += `[[pk_abs]]${sender}`;
+ if (recip) combined += `[[pk_adr]]${recip}`;
+ letters.push({
+ index: nr, type: 'postcard', text: combined, format: briefFormat, font,
+ realisticHandwriting: realistisch, placeholders: platzhalterVon(e, i),
+ fontScale: fsLetter, senderScale: Number(body.envelope_sender_scale) || 1, recipientScale: fsEnv,
+ });
+ return;
+ }
+
+ // Kein Schriftstück, wenn kein Text da ist (reiner Kuvert-Druck).
+ if (String(briefText).trim()) {
+ letters.push({
+ index: nr, type: 'letter', text: clean(briefText),
+ format: briefFormat, font, realisticHandwriting: realistisch,
+ placeholders: platzhalterVon(e, i), fontScale: fsLetter,
+ signatures: sigMap, signatureOffsets: sigOff, multiPage: mehrseitig,
+ });
+ }
+
+ // Umschlag nur, wenn er auch beschriftet wird.
+ if (body.envelope_labeling && body.envelope_labeling !== 'keine') {
+ const istFreitext = body.envelope_labeling === 'freitext';
+ letters.push({
+ index: nr, type: 'envelope',
+ envelopeType: istFreitext ? 'custom' : 'recipient',
+ text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
+ format: umschlagFormat, font, realisticHandwriting: realistisch,
+ placeholders: platzhalterVon(e, i), fontScale: fsEnv,
+ // Absender aufs Kuvert (oben links); Empfänger rutscht dann unten rechts.
+ sender: clean(String(body.envelope_sender || '')),
+ senderScale: Number(body.envelope_sender_scale) || 1,
+ });
+ }
+ });
+
+ const sessionId = `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const kopf = { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN };
+
+ // Das Backend liest `letters` direkt aus dem Body (nicht aus `config`).
+ // Umschläge stecken als Einträge mit type:'envelope' bereits in `letters`.
+ const antwort = await fetch(`${BACKEND_URL}/api/preview/batch`, {
+ method: 'POST',
+ headers: kopf,
+ body: JSON.stringify({ sessionId, letters }),
+ });
+ if (!antwort.ok) {
+ const txt = await antwort.text();
+ logger.error(`[skrift-orders] Vorschau-Backend: ${antwort.status} ${txt}`);
+ // Grund für die Diagnose mitgeben (Status + gekürzte Backend-Meldung).
+ return res.status(502).json({
+ error: 'Vorschau konnte nicht erzeugt werden.',
+ detail: `Backend ${antwort.status}: ${String(txt).slice(0, 200)}`,
+ });
+ }
+ const daten = await antwort.json();
+
+ // Zeilen-Überlauf (Schriftstück zu lang für das Format) durchreichen.
+ const overflow = (daten.overflowFiles || [])
+ .map((f) => ({ index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit }));
+
+ // Reine Prüfung (beim „Weiter"): kein Download, kein Kontingent-Verbrauch.
+ if (nurPruefen) {
+ return res.json({ has_overflow: !!daten.hasOverflow, overflow });
+ }
+
+ // SVGs direkt einsammeln – so braucht der Browser keinen Backend-Zugang.
+ // Schriftstücke und Umschläge kommen gemeinsam zurück.
+ // Umschlag vs. Schriftstück steckt im Dateinamen-Präfix (envelope_ / letter_) –
+ // die Datei-Objekte des Backends tragen kein eigenes type-Feld.
+ const docs = [];
+ for (const datei of (daten.files || []).slice(0, PREVIEW_MAX_DOCS * 2)) {
+ const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } });
+ if (r.ok) {
+ const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || '');
+ docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text(), page: datei.page, pageCount: datei.pageCount });
+ }
+ }
+
+ // Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
+ if (!istIntern && !nurPruefen && kennung) {
+ if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
+ else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
+ }
+
+ return res.json({
+ docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
+ has_overflow: !!daten.hasOverflow, overflow,
+ // Session zurückgeben, damit die gezeigten Dokumente bei der Bestellung
+ // 1:1 übernommen werden können.
+ session: sessionId,
+ });
+ } catch (err) {
+ logger.error(`[skrift-orders] preview: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Vorschau fehlgeschlagen.' });
+ }
+ });
+
+ /** Seitenzahl je Schriftstück (mehrseitig) – schlank, ohne Scriptalizer/SVG. */
+ router.post('/pagecount', async (req, res) => {
+ const body = req.body || {};
+ if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
+ try {
+ const schema = await getSchema();
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+ const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
+ const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
+ const scale = Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1));
+ const entries = (Array.isArray(body.entries) ? body.entries : []).map((e) => ({
+ placeholders: (e && typeof e.placeholders === 'object')
+ ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
+ : {},
+ }));
+ const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ text: clean(String(body.text)), format: briefFormat, fontScale: scale, entries }),
+ });
+ if (!r.ok) return res.status(502).json({ error: 'Seitenzählung fehlgeschlagen.' });
+ return res.json(await r.json());
+ } catch (err) {
+ logger.error(`[skrift-orders] pagecount: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Seitenzählung fehlgeschlagen.' });
+ }
+ });
+
+ /**
+ * Öffentlicher Datei-Upload (Motiv/Quelldatei). Gäste bestellen ohne Login und
+ * haben keinen Directus-Token für /files – daher hier serverseitig via FilesService
+ * (admin) ablegen. Datei kommt als Base64 im JSON. Nur Bilder + PDF, max. 20 MB.
+ */
+ router.post('/upload', async (req, res) => {
+ const body = req.body || {};
+ const roh = typeof body.data_base64 === 'string' ? body.data_base64.replace(/^data:[^;]+;base64,/, '') : '';
+ if (!roh) return res.status(400).json({ error: 'Keine Datei übergeben.' });
+ const type = String(body.content_type || 'application/octet-stream');
+ if (!/^image\/(png|jpe?g|gif|webp|svg\+xml)$|^application\/pdf$/i.test(type)) {
+ return res.status(415).json({ error: 'Dateityp nicht erlaubt (nur Bilder oder PDF).' });
+ }
+ let buffer;
+ try { buffer = Buffer.from(roh, 'base64'); } catch { return res.status(400).json({ error: 'Ungültige Datei.' }); }
+ if (!buffer.length) return res.status(400).json({ error: 'Leere Datei.' });
+ if (buffer.length > 20 * 1024 * 1024) return res.status(413).json({ error: 'Datei zu groß (max. 20 MB).' });
+ try {
+ const schema = await getSchema();
+ const files = new FilesService({ schema, accountability: null });
+ const name = (String(body.filename || 'upload').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'upload');
+ const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
+ const id = await files.uploadOne(
+ Readable.from(buffer),
+ { storage, filename_download: name, title: name, type },
+ undefined,
+ { emitEvents: false },
+ );
+ return res.json({ id });
+ } catch (err) {
+ logger.error(`[skrift-orders] upload: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Upload fehlgeschlagen.' });
+ }
+ });
+
+ // ── Motiv-Freigabe (öffentlich, per Einmal-Token) ──────────────────────────
+ const escHtml = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
+ const freigabeAuftrag = async (schema, token, fields) => {
+ const t = String(token || '').trim();
+ if (!t) return null;
+ return (await new ItemsService('orders', { schema, accountability: null }).readByQuery({
+ filter: { approval_token: { _eq: t } }, limit: 1, fields,
+ }))?.[0] || null;
+ };
+
+ // Auftrags-/Statusinfo für die Freigabe-Seite (kein Login nötig – Token genügt).
+ router.get('/freigabe/:token', async (req, res) => {
+ try {
+ const schema = await getSchema();
+ const o = await freigabeAuftrag(schema, req.params.token,
+ ['id', 'order_number', 'approval_status', 'approval_note', 'motif_layout', 'product.name']);
+ if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
+ return res.json({
+ order_number: o.order_number,
+ product: o.product?.name || 'Postkarte',
+ status: o.approval_status || 'pending',
+ note: o.approval_note || '',
+ has_layout: !!o.motif_layout,
+ });
+ } catch (e) {
+ logger.error(`[skrift-orders] freigabe GET: ${e.message}`);
+ return res.status(500).json({ error: 'Fehler beim Laden.' });
+ }
+ });
+
+ // Das A6-Entwurfs-SVG direkt ausliefern (Token-gated, kein Asset-Login nötig).
+ router.get('/freigabe/:token/layout', async (req, res) => {
+ try {
+ const schema = await getSchema();
+ const o = await freigabeAuftrag(schema, req.params.token, ['motif_layout']);
+ if (!o?.motif_layout) return res.status(404).send('Kein Entwurf.');
+ const bytes = await dateiBytes(schema, o.motif_layout);
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Cache-Control', 'no-store');
+ return res.send(bytes);
+ } catch (e) {
+ logger.error(`[skrift-orders] freigabe layout: ${e.message}`);
+ return res.status(500).send('Fehler.');
+ }
+ });
+
+ // Freigabe oder Änderungswunsch speichern + Team benachrichtigen.
+ router.post('/freigabe/:token', async (req, res) => {
+ const action = String(req.body?.action || '');
+ const note = String(req.body?.note || '').slice(0, 2000).trim();
+ if (!['approve', 'changes'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion.' });
+ if (action === 'changes' && !note) return res.status(400).json({ error: 'Bitte den Änderungswunsch beschreiben.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const o = await freigabeAuftrag(schema, req.params.token, ['id', 'order_number', 'approval_status']);
+ if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
+ const neu = action === 'approve' ? 'approved' : 'changes';
+ await svc('orders').updateOne(o.id, { approval_status: neu, approval_note: action === 'changes' ? note : null });
+ await svc('status_history').createOne({ order: o.id, status: 'eingegangen',
+ note: action === 'approve' ? 'Motiv-Entwurf vom Kunden freigegeben.' : `Motiv-Änderungswunsch: ${note}` }).catch(() => {});
+ await teamMail(schema,
+ action === 'approve' ? `✅ Motiv freigegeben: ${o.order_number}` : `✏️ Motiv-Änderungswunsch: ${o.order_number}`,
+ action === 'approve'
+ ? `Der Kunde hat den Motiv-Entwurf zu ${escHtml(o.order_number)} freigegeben – kann in den Druck.
`
+ : `Änderungswunsch zum Motiv-Entwurf ${escHtml(o.order_number)}:
${escHtml(note)}
`);
+ return res.json({ ok: true, status: neu });
+ } catch (e) {
+ logger.error(`[skrift-orders] freigabe POST: ${e.message}`);
+ return res.status(500).json({ error: 'Konnte nicht gespeichert werden.' });
+ }
+ });
+
+ /** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
+ router.get('/preview/quota', async (req, res) => {
+ try {
+ const kennung = norm(req.query.email) || String(req.query.client_id || '').slice(0, 64);
+ if (!kennung) return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
+ const schema = await getSchema();
+ const heute = new Date().toISOString().slice(0, 10);
+ const row = (await new ItemsService('preview_usage', { schema, accountability: null }).readByQuery({
+ filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['count'],
+ }))?.[0];
+ return res.json({ remaining: Math.max(0, PREVIEW_LIMIT - (row?.count ?? 0)), limit: PREVIEW_LIMIT });
+ } catch {
+ return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
+ }
+ });
+
+ // ── Auftragsdateien (Direktzugriff auf den Ausgabe-Ordner, keine Kopie) ────
+ // Zugriff: App-Nutzer (Staff/Admin) ODER der Produktions-Agent (Rolle Service).
+ let _serviceRoleId;
+ async function serviceRoleId() {
+ if (_serviceRoleId !== undefined) return _serviceRoleId;
+ try {
+ const rows = await new ItemsService('directus_roles', { schema: await getSchema(), accountability: null })
+ .readByQuery({ filter: { name: { _eq: 'Service' } }, limit: 1, fields: ['id'] });
+ _serviceRoleId = rows?.[0]?.id ?? null;
+ } catch { _serviceRoleId = null; }
+ return _serviceRoleId;
+ }
+ async function darfDateien(req) {
+ const acc = req.accountability;
+ if (!acc || !acc.user) return false;
+ if (acc.admin || acc.app) return true; // Operator im Admin
+ const svc = await serviceRoleId();
+ return !!(svc && acc.role === svc); // Produktions-Agent
+ }
+ const safeOrder = (s) => (/^[A-Za-z0-9_-]+$/.test(String(s || '')) ? String(s) : null);
+
+ /** Listet die Dateien eines Auftrags aus dem gemounteten Ausgabe-Ordner. */
+ function listeDateien(orderNummer) {
+ const basis = path.join(OUTPUT_DIR, orderNummer);
+ let root;
+ try { root = fs.readdirSync(basis); } catch { return null; } // Ordner (noch) nicht da
+ const out = [];
+ const add = (rel, kind) => {
+ try {
+ const st = fs.statSync(path.join(basis, rel));
+ if (st.isFile()) out.push({ name: path.basename(rel), rel, kind, size: st.size });
+ } catch { /* ignore */ }
+ };
+ for (const f of root) {
+ if (/\.csv$/i.test(f)) add(f, 'platzhalter');
+ else if (/\.svg$/i.test(f)) add(f, 'schriftstueck');
+ }
+ try {
+ for (const f of fs.readdirSync(path.join(basis, 'umschlaege'))) {
+ if (/\.svg$/i.test(f)) add(path.join('umschlaege', f), 'umschlag');
+ }
+ } catch { /* keine Umschläge */ }
+ return out;
+ }
+
+ router.get('/files/:order', async (req, res) => {
+ if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const nummer = safeOrder(req.params.order);
+ if (!nummer) return res.status(400).json({ error: 'Ungültige Auftragsnummer.' });
+ const files = listeDateien(nummer);
+ if (files === null) return res.json({ files: [], exists: false });
+ return res.json({ files, exists: true });
+ });
+
+ // Zeichenreihenfolge „spaltenweise": permutiert NUR die Glyph--Elemente
+ // nach x (Spalte, aufsteigend), innerhalb einer Spalte nach y (oben→unten).
+ // Andere Elemente (Hintergrund-Rect, Signatur-, Trennstrich bleibt ein Pfad)
+ // behalten ihre Position. Das sichtbare Ergebnis ist identisch – nur die
+ // Druckreihenfolge ändert sich, sodass der rollengeführte Plotter monoton in
+ // Vorschubrichtung fährt statt je Zeile die Rolle zurückzuspulen (A6 quer).
+ function svgSpaltenweise(svg) {
+ // …-Blöcke (z. B. eingebettete Signaturen) als Ganzes schützen – deren
+ // innere Pfade dürfen NICHT umsortiert werden (sie hängen an einem eigenen
+ // Gruppen-Transform). Signaturen stehen hinter dem Text, daher greedy bis zum
+ // letzten . Ohne bleibt alles unverändert.
+ const bloecke = [];
+ const safe = svg.replace(//, (m) => { bloecke.push(m); return ' G0 '; });
+ const RE = /]*\/>/g;
+ const paths = safe.match(RE);
+ if (!paths || paths.length < 2) return svg;
+ const key = (p) => {
+ const m = p.match(/matrix\(\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*[-\d.]+\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
+ if (m) return [parseFloat(m[1]), parseFloat(m[2])];
+ const d = p.match(/\bd\s*=\s*"\s*M\s*([-\d.]+)[\s,]+([-\d.]+)/i);
+ if (d) return [parseFloat(d[1]), parseFloat(d[2])];
+ return [0, 0];
+ };
+ const sorted = paths
+ .map((p) => ({ p, k: key(p) }))
+ .sort((a, b) => (a.k[0] - b.k[0]) || (a.k[1] - b.k[1]))
+ .map((x) => x.p);
+ let i = 0;
+ let out = safe.replace(RE, () => sorted[i++]);
+ out = out.replace(/ G0 /, () => bloecke[0]);
+ return out;
+ }
+
+ router.get('/files/:order/download', async (req, res) => {
+ if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const nummer = safeOrder(req.params.order);
+ const rel = String(req.query.rel || '');
+ if (!nummer || rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) {
+ return res.status(400).json({ error: 'Ungültiger Pfad.' });
+ }
+ const basis = path.resolve(OUTPUT_DIR, nummer);
+ const ziel = path.resolve(basis, rel);
+ // Pfad muss innerhalb des Auftragsordners liegen (kein Ausbruch).
+ if (ziel !== basis && !ziel.startsWith(basis + path.sep)) {
+ return res.status(400).json({ error: 'Ungültiger Pfad.' });
+ }
+ if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) {
+ return res.status(404).json({ error: 'Datei nicht gefunden.' });
+ }
+ // Optional: SVG spaltenweise umsortieren (rollenschonend, A6 quer).
+ if (ziel.endsWith('.svg') && String(req.query.reorder || '') === 'column') {
+ try {
+ const umsortiert = svgSpaltenweise(fs.readFileSync(ziel, 'utf8'));
+ res.setHeader('Content-Type', 'image/svg+xml');
+ res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`);
+ return res.send(umsortiert);
+ } catch (e) {
+ logger.warn(`[skrift-orders] spaltenweise fehlgeschlagen (${rel}): ${e.message}`);
+ // Fallback: unverändert streamen.
+ }
+ }
+ const typ = ziel.endsWith('.svg') ? 'image/svg+xml'
+ : ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream';
+ res.setHeader('Content-Type', typ);
+ res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`);
+ fs.createReadStream(ziel).pipe(res);
+ });
+
+ // ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
+ // Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
+ // EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
+ // Body: { orderIds: (number|string)[] }
+ router.post('/charge/pdf', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
+ if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
+ try {
+ const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ orderIds }),
+ });
+ if (!r.ok) {
+ let msg = 'Sammel-PDF fehlgeschlagen.';
+ try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
+ return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
+ }
+ const buf = Buffer.from(await r.arrayBuffer());
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
+ return res.send(buf);
+ } catch (e) {
+ logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
+ return res.status(502).json({ error: 'Backend nicht erreichbar.' });
+ }
+ });
+
+ // ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
+ // Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
+ // für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
+ const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
+ const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
+ // Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
+ // Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
+ const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
+ const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
+ const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
+ const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
+
+ router.post('/enqueue', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ if (!body.order || !body.machine) return res.status(400).json({ error: 'order und machine erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const order = await svc('orders').readOne(body.order, {
+ fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
+ });
+ if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
+
+ const tplByKey = async (key) => (await svc('format_templates').readByQuery({
+ filter: { key: { _eq: key } }, limit: 1, fields: ['id'],
+ }))?.[0]?.id ?? null;
+
+ // Läuft auf dieser Maschine gerade ein Druck? → nur Warteschlange möglich.
+ const laufend = await svc('jobs').readByQuery({
+ filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'],
+ });
+ const busy = !!(laufend && laufend.length);
+ const modus = (body.mode === 'direct' && !busy) ? 'direct' : 'queue';
+ const prio = modus === 'direct' ? 100 : 0;
+
+ // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null).
+ const clampParams = (p) => {
+ if (!p || typeof p !== 'object') return null;
+ const out = {};
+ for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
+ if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
+ }
+ return Object.keys(out).length ? out : null;
+ };
+
+ const jobs = [];
+ // Neue, ausführliche Form: explizite Abschnitte (Brief/Umschlag) aus dem Popup.
+ const s = body.sections;
+ if (s && typeof s === 'object') {
+ for (const typ of ['brief', 'umschlag']) {
+ const sec = s[typ];
+ if (!sec || sec.enabled === false) continue;
+ if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
+ const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
+ jobs.push(await svc('jobs').createOne({
+ order: order.id, machine: body.machine, type: typ, template: sec.template,
+ format: fmt.key, format_label: fmt.label,
+ params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
+ // Rollenschonende Zeichenreihenfolge (A6 quer) – nur fürs Schriftstück sinnvoll.
+ spaltenweise: !!(sec.spaltenweise || body.spaltenweise),
+ status: 'queued', priority: prio,
+ }));
+ }
+ if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
+ } else {
+ // Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
+ const bf = briefFormatOf(order);
+ const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
+ jobs.push(await svc('jobs').createOne({
+ order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
+ format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
+ }));
+ if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
+ const ef = umschlagFormatOf(order);
+ const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
+ jobs.push(await svc('jobs').createOne({
+ order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
+ format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
+ }));
+ }
+ }
+ return res.json({ ok: true, jobs, mode: modus, busy });
+ } catch (err) {
+ logger.error(`[skrift-orders] enqueue: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Einreihen fehlgeschlagen.' });
+ }
+ });
+
+ // ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
+ // Body: { orders:[id…], mode:'queue'|'direct',
+ // byFormat:{ '': { template, machine, label } } }
+ // Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
+ // Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
+ router.post('/enqueue-bulk', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ const orderIds = Array.isArray(body.orders) ? body.orders : [];
+ const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
+ if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const ordersRoh = await svc('orders').readMany(orderIds, {
+ fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
+ });
+ // WICHTIG: readMany bewahrt die Reihenfolge der übergebenen IDs NICHT. Die
+ // Jobs werden in der Iterationsreihenfolge erzeugt (→ date_created → Druck-
+ // reihenfolge am Plotter). Damit Kuvert-/Brief-Reihenfolge zur ebenfalls nach
+ // Auftragsnummer sortierten Sammel-PDF passt, hier explizit nach order_number
+ // sortieren – sonst driften die Stapel auseinander (Handarbeit).
+ const orders = (ordersRoh || []).slice().sort((a, b) =>
+ String(a.order_number || '').localeCompare(String(b.order_number || ''), undefined, { numeric: true }));
+
+ // Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
+ const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
+ const busySet = new Set((printing || []).map((j) => String(j.machine)));
+ const mode = body.mode === 'direct' ? 'direct' : 'queue';
+ const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
+
+ // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null) –
+ // wie in /enqueue, damit der Sammel-Druck Template-Werte übersteuern kann.
+ const clampParams = (p) => {
+ if (!p || typeof p !== 'object') return null;
+ const out = {};
+ for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
+ if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
+ }
+ return Object.keys(out).length ? out : null;
+ };
+
+ const jobs = [];
+ const uebersprungen = [];
+ const mkJob = async (order, typ, fmt) => {
+ const cfg = byFormat[fmt.key];
+ if (!cfg || !cfg.template || !cfg.machine) {
+ uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
+ return;
+ }
+ jobs.push(await svc('jobs').createOne({
+ order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
+ format: fmt.key, format_label: cfg.label || fmt.label, params: clampParams(cfg.params),
+ status: 'queued', priority: prioFor(cfg.machine),
+ }));
+ };
+ for (const order of orders) {
+ await mkJob(order, 'brief', briefFormatOf(order));
+ if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
+ await mkJob(order, 'umschlag', umschlagFormatOf(order));
+ }
+ }
+ return res.json({ ok: true, jobs, mode, uebersprungen });
+ } catch (err) {
+ logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Sammel-Einreihen fehlgeschlagen.' });
+ }
+ });
+
+ // ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
+ const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
+ router.post('/test-print', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const { id } = req.body || {};
+ if (!id) return res.status(400).json({ error: 'id erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const tp = await svc('test_prints').readOne(id, {
+ fields: ['id', 'text', 'font', 'format', 'machine', 'template'],
+ });
+ if (!tp) return res.status(404).json({ error: 'Testdruck nicht gefunden.' });
+ if (!tp.template || !tp.machine) return res.status(400).json({ error: 'Bitte Template und Maschine wählen.' });
+
+ const ref = `TEST-${id}-${Date.now().toString(36)}`;
+ const font = ['tilda', 'alva', 'ellie'].includes(tp.font) ? tp.font : 'tilda';
+ const format = TEST_FORMAT[tp.format] || 'a4';
+
+ // 1) Test-SVG über das bestehende Backend erzeugen (schreibt in den Ausgabeordner).
+ const gen = await fetch(`${BACKEND_URL}/api/order/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({
+ orderNumber: ref,
+ letters: [{ index: 0, type: 'letter', text: tp.text || 'Skrift Testdruck', format, font, realisticHandwriting: false }],
+ metadata: {},
+ }),
+ });
+ if (!gen.ok) {
+ const txt = await gen.text();
+ await svc('test_prints').updateOne(id, { status: 'fehler' });
+ return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` });
+ }
+
+ // 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
+ const job = await svc('jobs').createOne({
+ machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
+ format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
+ });
+ await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
+ return res.json({ ok: true, ref, job });
+ } catch (err) {
+ logger.error(`[skrift-orders] test-print: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Testdruck fehlgeschlagen.' });
+ }
+ });
+
+ // ── Manueller Druck (Muster) ───────────────────────────────────────────────
+ // Generiert ein einzelnes Schriftstück aus freiem Text (Adresse/Standardtext
+ // wurden im Dialog bereits eingesetzt) und reiht es als Job ein. Optional wird
+ // die zugehörige Musteranfrage auf „in_bearbeitung" gesetzt.
+ router.post('/manual-print', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
+ if (!body.template || !body.machine) return res.status(400).json({ error: 'Template und Maschine erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const ref = `MUSTER-${(body.offer_request || 'x')}-${Date.now().toString(36)}`;
+ const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
+ const format = TEST_FORMAT[body.format] || 'a4';
+
+ // Kuvert (Adresse aus der Anfrage) optional mitdrucken.
+ const ENV_FMT = { dinlang: 'din_lang', c6: 'c6' };
+ const envAddr = clean(String(body.envelope || '')).trim();
+ const wantsEnv = !!(envAddr && body.envelope_template);
+ const envFormat = ENV_FMT[body.envelope_format] || 'din_lang';
+
+ const letters = [{ index: 1, type: 'letter', text: clean(String(body.text)), format, font, realisticHandwriting: body.realistic !== false }];
+ if (wantsEnv) {
+ letters.push({ index: 1, type: 'envelope', envelopeType: 'recipient', text: envAddr, format: envFormat, font, realisticHandwriting: body.realistic !== false });
+ }
+
+ const gen = await fetch(`${BACKEND_URL}/api/order/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({
+ orderNumber: ref,
+ letters,
+ metadata: { rawText: String(body.text) },
+ }),
+ });
+ if (!gen.ok) {
+ const txt = await gen.text();
+ return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` });
+ }
+
+ const busyList = await svc('jobs').readByQuery({
+ filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'],
+ });
+ const busy = !!(busyList && busyList.length);
+ const prio = (body.mode === 'direct' && !busy) ? 100 : 0;
+
+ const job = await svc('jobs').createOne({
+ machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
+ format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
+ });
+ // Zweiter Job für das Kuvert (gleicher Ausgabeordner, Typ umschlag).
+ if (wantsEnv) {
+ await svc('jobs').createOne({
+ machine: body.machine, template: body.envelope_template, type: 'umschlag', status: 'queued', output_ref: ref, priority: prio,
+ format: body.envelope_format || 'dinlang', format_label: ENV_LABEL[body.envelope_format] || 'Kuvert',
+ });
+ }
+ if (body.offer_request) {
+ try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
+ }
+ return res.json({ ok: true, ref, job, mode: prio ? 'direct' : 'queue', busy, kuvert: wantsEnv });
+ } catch (err) {
+ logger.error(`[skrift-orders] manual-print: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Manueller Druck fehlgeschlagen.' });
+ }
+ });
+
+ // ── Zahlung bestätigen (serverseitig verifiziert) ────────────────────────
+ /**
+ * Verifiziert die PayPal-Zahlung gegen die PayPal-API:
+ * - Capture existiert und ist COMPLETED
+ * - gezahlter Betrag == serverseitig gespeicherter Auftragsbetrag (gross_total)
+ * Erst dann wird der Auftrag als bezahlt markiert. Dem Client wird nicht
+ * vertraut – weder Betrag noch Status.
+ */
+ router.post('/payment', async (req, res) => {
+ const { order_number, payment_ref } = req.body || {};
+ if (!order_number || !payment_ref)
+ return res.status(400).json({ error: 'order_number und payment_ref erforderlich.' });
+ if (!PAYPAL_CLIENT_ID || !PAYPAL_SECRET) {
+ logger.error('[skrift-orders] PayPal-Server-Zugangsdaten fehlen.');
+ return res.status(500).json({ error: 'Zahlungsprüfung nicht konfiguriert.' });
+ }
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+
+ const order = (await svc('orders').readByQuery({
+ filter: { order_number: { _eq: String(order_number) } },
+ limit: 1, fields: ['id', 'payment_status', 'gross_total'],
+ }))?.[0];
+ if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
+ if (order.payment_status === 'bezahlt') return res.json({ ok: true, bereits: true });
+
+ // Capture bei PayPal abfragen und prüfen.
+ const token = await paypalToken();
+ const r = await fetch(`${PAYPAL_API}/v2/payments/captures/${encodeURIComponent(String(payment_ref))}`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!r.ok) {
+ logger.error(`[skrift-orders] PayPal-Capture ${payment_ref}: ${r.status}`);
+ return res.status(400).json({ error: 'Zahlung konnte nicht verifiziert werden.' });
+ }
+ const capture = await r.json();
+
+ const bezahlt = Number(capture?.amount?.value);
+ const soll = Number(order.gross_total);
+ const betragOk = Number.isFinite(bezahlt) && Math.abs(bezahlt - soll) < 0.01;
+
+ if (capture?.status !== 'COMPLETED' || capture?.amount?.currency_code !== 'EUR' || !betragOk) {
+ logger.warn(`[skrift-orders] Zahlung ungültig: status=${capture?.status} ` +
+ `betrag=${capture?.amount?.value} soll=${soll}`);
+ return res.status(400).json({ error: 'Zahlung stimmt nicht mit dem Auftrag überein.' });
+ }
+
+ await svc('orders').updateOne(order.id, {
+ payment_status: 'bezahlt',
+ payment_ref: String(payment_ref).slice(0, 128),
+ status: 'bezahlt',
+ });
+ await svc('status_history').createOne({
+ order: order.id, status: 'bezahlt',
+ note: `Zahlung über PayPal verifiziert (${bezahlt.toFixed(2)} €).`,
+ });
+
+ return res.json({ ok: true });
+ } catch (err) {
+ logger.error(`[skrift-orders] payment: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Zahlung konnte nicht verifiziert werden.' });
+ }
+ });
+
+ /**
+ * Absender aus email_settings („Skrift "). Ohne das greift der
+ * Directus-Standardabsender – dann steht „Directus" im Postfach.
+ */
+ async function absender(schema) {
+ try {
+ const s = await new ItemsService('email_settings', { schema, accountability: null })
+ .readSingleton({ fields: ['sender_name', 'sender_email'] });
+ if (s && s.sender_email) {
+ return s.sender_name ? `${s.sender_name} <${s.sender_email}>` : s.sender_email;
+ }
+ } catch { /* Standard verwenden */ }
+ return null;
+ }
+
+ /**
+ * Schlanke interne Benachrichtigung (neue Bestellung/Muster/Anfrage). Empfänger
+ * ist konfigurierbar (email_settings.team_email, Fallback SKRIFT_TEAM_EMAIL).
+ * Leer → keine Team-Mail.
+ */
+ async function teamMail(schema, subject, html) {
+ try {
+ let to = TEAM_EMAIL;
+ try {
+ const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['team_email'] });
+ if (es && es.team_email !== undefined && es.team_email !== null) to = String(es.team_email).trim();
+ } catch { /* Feld evtl. noch nicht gebootstrapt → Fallback */ }
+ if (!to) return; // bewusst leer = keine Team-Mails
+ const mail = new MailService({ schema, accountability: null });
+ const von = await absender(schema);
+ await mail.send({ ...(von ? { from: von } : {}), to, subject, html });
+ } catch (e) {
+ logger.warn(`[skrift-orders] Team-Mail nicht versendet: ${e.message}`);
+ }
+ }
+
+ /**
+ * Sendet eine im Admin editierbare E-Mail-Vorlage (email_templates) an den
+ * Kunden. Platzhalter {{feld}} werden aus `vars` gefüllt. Fehlt/deaktiviert die
+ * Vorlage, passiert nichts (kein Fehler).
+ */
+ async function sendeVorlage(schema, key, to, vars) {
+ try {
+ const tpl = (await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
+ filter: { key: { _eq: key }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
+ }))?.[0];
+ if (!tpl) return;
+ const fuelle = (t) => String(t || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? ''));
+ let signature = '';
+ try {
+ const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['signature'] });
+ if (es?.signature) signature = es.signature;
+ } catch { /* optional */ }
+ const mail = new MailService({ schema, accountability: null });
+ const von = await absender(schema);
+ await mail.send({ ...(von ? { from: von } : {}), to, subject: fuelle(tpl.subject), html: fuelle(tpl.body) + signature });
+ } catch (e) {
+ logger.warn(`[skrift-orders] Vorlagen-Mail „${key}" nicht versendet: ${e.message}`);
+ }
+ }
+
+ // ── Angebotsanfrage („Individuelles Angebot") ────────────────────────────
+ router.post('/offer-request', async (req, res) => {
+ const body = req.body || {};
+ try { logger.info('[skrift-orders] offer-request Felder: ' + JSON.stringify(Object.keys(body))); } catch { /* egal */ }
+ // Feld-Aliase (App + WordPress + Varianten). App-Feldnamen haben Vorrang.
+ const vorname = feldWert(body, 'first_name', 'vorname', 'firstname', 'first', 'given_name');
+ const nachname = feldWert(body, 'last_name', 'nachname', 'name', 'lastname', 'surname', 'familienname');
+ const name = [vorname, nachname].filter(Boolean).join(' ');
+ const phone = feldWert(body, 'phone', 'telefon', 'tel', 'telephone', 'telefonnummer');
+ const product = feldWert(body, 'product', 'produkt_interesse', 'produkt', 'produkt_interese');
+ const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
+ const message = String(body.message || body.nachricht || body.vorhaben || '').trim();
+
+ const errors = [];
+ if (!name) errors.push('Name fehlt.');
+ if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
+ if (!message && !product) errors.push('Bitte beschreiben Sie Ihr Vorhaben.');
+ if (message.length > 5000) errors.push('Beschreibung ist zu lang.');
+ if (errors.length) return res.status(400).json({ errors });
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ // Falls es zu der E-Mail schon ein Konto gibt, hängen wir die Anfrage an.
+ const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
+ filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
+ }))?.[0];
+
+ const quantity = Number.parseInt(body.quantity, 10);
+
+ await svc('offer_requests').createOne({
+ type: 'angebot',
+ name: clean(name),
+ email,
+ phone: clean(phone) || null,
+ quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : null,
+ product: product ? clean(product) : null,
+ message: clean(message),
+ address: (vorname || nachname) ? { first_name: clean(vorname), last_name: clean(nachname) } : null,
+ status: 'neu',
+ customer: customer?.id ?? null,
+ });
+
+ // Vollständige interne Benachrichtigung.
+ await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
+ `Neue Angebotsanfrage – im Board unter „Neu".
`
+ + felderHtml([
+ ['Vorname', clean(vorname)], ['Nachname', clean(nachname)],
+ ['E-Mail', email], ['Telefon', clean(phone)],
+ ['Produkt', product ? clean(product) : ''],
+ ['Menge', Number.isFinite(quantity) && quantity > 0 ? quantity : ''],
+ ['Vorhaben', clean(message)],
+ ]));
+
+ return res.json({ ok: true });
+ } catch (err) {
+ logger.error(`[skrift-orders] offer-request: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
+ }
+ });
+
+ // ── Musterbestellung (kostenlos) ─────────────────────────────────────────
+ router.post('/sample-request', async (req, res) => {
+ const body = req.body || {};
+ // Diagnose: welche Feldnamen kommen rein + welche davon GEFÜLLT (Werte selbst
+ // NICHT loggen – Datenschutz). So sieht man, ob WP z. B. name/ort leer schickt.
+ try {
+ logger.info('[skrift-orders] sample-request Felder: ' + JSON.stringify(Object.keys(body))
+ + ' | gefüllt: ' + JSON.stringify(Object.keys(body).filter((k) => String(body[k] ?? '').trim())));
+ } catch { /* egal */ }
+ const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
+ // Feld-Aliase (App + WordPress + gängige Varianten). App-Feldnamen haben Vorrang.
+ const first = feldWert(body, 'first_name', 'vorname', 'firstname', 'first', 'given_name');
+ const last = feldWert(body, 'last_name', 'nachname', 'name', 'lastname', 'surname', 'familienname');
+ const product = feldWert(body, 'product', 'produkt_interesse', 'produkt', 'produkt_interese');
+ const phone = feldWert(body, 'phone', 'telefon', 'tel', 'telephone', 'telefonnummer');
+ const street = feldWert(body, 'street', 'strasse', 'straße', 'adresse', 'address', 'strasse_hausnummer');
+ const zip = feldWert(body, 'zip', 'zipcode', 'plz', 'postleitzahl', 'postal_code');
+ const city = feldWert(body, 'city', 'ort', 'stadt', 'wohnort');
+ const country = feldWert(body, 'country', 'land');
+ const vollName = [first, last].filter(Boolean).join(' ');
+ const errors = [];
+ if (!product) errors.push('Produkt fehlt.');
+ if (!vollName) errors.push('Name fehlt.');
+ if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
+ if (!street || !zip || !city) errors.push('Lieferadresse unvollständig.');
+ if (errors.length) return res.status(400).json({ errors });
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
+ filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
+ }))?.[0];
+
+ await svc('offer_requests').createOne({
+ type: 'muster',
+ name: clean(vollName),
+ email,
+ phone: clean(phone) || null,
+ product: clean(product),
+ message: clean(String(body.message || '').trim().slice(0, 1000)) || null,
+ address: {
+ first_name: clean(first),
+ last_name: clean(last),
+ street: clean(street),
+ zip: clean(zip),
+ city: clean(city),
+ country: clean(country),
+ },
+ status: 'neu',
+ customer: customer?.id ?? null,
+ });
+
+ const musterNachricht = clean(String(body.message || '').trim().slice(0, 2000));
+ await teamMail(schema, `Neue Musteranfrage: ${clean(vollName)}`,
+ `Neue Musteranfrage – im Board unter „Neu".
`
+ + felderHtml([
+ ['Vorname', clean(first)], ['Nachname', clean(last)],
+ ['E-Mail', email], ['Telefon', clean(phone)], ['Produkt', clean(product)],
+ ['Straße', clean(street)], ['PLZ', clean(zip)], ['Ort', clean(city)], ['Land', clean(country)],
+ ['Nachricht', musterNachricht],
+ ]));
+
+ return res.json({ ok: true });
+ } catch (err) {
+ logger.error(`[skrift-orders] sample-request: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Musterbestellung konnte nicht gespeichert werden.' });
+ }
+ });
+
+ // ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
+ // POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
+ // Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
+ // per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
+ // JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
+ // Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
+ // Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
+ router.use('/inbound', (req, res, next) => {
+ if (req.method !== 'POST') return next();
+ if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
+ let raw = '';
+ req.setEncoding('utf8');
+ req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
+ req.on('end', () => {
+ const s = raw.trim();
+ if (s) {
+ try { req.body = JSON.parse(s); }
+ catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
+ }
+ next();
+ });
+ req.on('error', () => next());
+ });
+
+ router.post('/inbound/:kind', async (req, res) => {
+ // Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
+ let body = req.body || {};
+ if (typeof body === 'string') {
+ try { body = JSON.parse(body); }
+ catch { body = Object.fromEntries(new URLSearchParams(body)); }
+ }
+ const kind = String(req.params.kind || '').toLowerCase();
+ // Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
+ // „v2" + gefüllt=[…] zeigt, dass der neue Mapping-Code läuft und WELCHE Felder
+ // wirklich einen Wert haben (leere werden nicht gelistet).
+ const gefuellt = Object.keys(body).filter((k) => String(body[k] ?? '').trim());
+ logger.info(`[skrift-orders] inbound/${kind} v2: content-type=${req.get('content-type') || '—'}, `
+ + `felder=[${Object.keys(body).join(',')}], gefüllt=[${gefuellt.join(',')}], `
+ + `token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
+
+ const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
+ const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
+ if (!secret) {
+ logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
+ return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
+ }
+ if (String(provided) !== secret) {
+ return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
+ }
+ if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
+
+ // WP schickt Vor-/Nachname GETRENNT: vorname + name(=Nachname). Anzeigename = beides.
+ const vorname = feldWert(body, 'vorname', 'first_name', 'firstname');
+ const nachname = feldWert(body, 'name', 'nachname', 'last_name', 'lastname');
+ const name = [vorname, nachname].filter(Boolean).join(' ');
+ const email = norm(feldWert(body, 'email', 'e_mail', 'e-mail', 'mail'));
+ if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
+ if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
+ filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
+ }))?.[0];
+
+ const phone = clean(feldWert(body, 'telefon', 'phone', 'tel', 'telefonnummer')) || null;
+
+ let record; let subject; let mailHtml;
+ if (kind === 'muster') {
+ const produkt = feldWert(body, 'produkt_interesse', 'product', 'produkt');
+ const strasse = feldWert(body, 'street', 'strasse', 'straße', 'adresse');
+ const plz = feldWert(body, 'zipcode', 'zip', 'plz', 'postleitzahl');
+ const ort = feldWert(body, 'ort', 'city', 'stadt', 'wohnort');
+ const land = feldWert(body, 'country', 'land');
+ record = {
+ type: 'muster', name: clean(name), email, phone, product: produkt ? clean(produkt) : null, message: null,
+ address: {
+ first_name: clean(vorname),
+ last_name: clean(nachname),
+ street: clean(strasse),
+ zip: clean(plz),
+ city: clean(ort),
+ country: clean(land),
+ },
+ status: 'neu', customer: customer?.id ?? null,
+ };
+ subject = `Neue Musteranfrage: ${name}`;
+ mailHtml = felderHtml([
+ ['Vorname', clean(vorname)], ['Nachname', clean(nachname)],
+ ['E-Mail', email], ['Telefon', phone], ['Produkt', produkt ? clean(produkt) : ''],
+ ['Straße', clean(strasse)], ['PLZ', clean(plz)], ['Ort', clean(ort)], ['Land', clean(land)],
+ ]);
+ } else {
+ const unternehmen = clean(String(body.unternehmen || '').trim());
+ const nachricht = clean(String(body.nachricht || body.message || '').trim());
+ // offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
+ const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
+ record = {
+ type: 'angebot', name: clean(name), email, phone, product: null, message,
+ status: 'neu', customer: customer?.id ?? null,
+ };
+ subject = `Neue Angebotsanfrage: ${name}`;
+ mailHtml = felderHtml([
+ ['Vorname', clean(vorname)], ['Nachname', clean(nachname)], ['Unternehmen', unternehmen],
+ ['E-Mail', email], ['Telefon', phone], ['Nachricht', nachricht],
+ ]);
+ }
+
+ // „Gesamter Inhalt": alle übermittelten Felder (außer technische) mit anhängen,
+ // damit auch nicht standardisierte Formularfelder in der Mail landen.
+ const TECH = new Set(['token', 'dataprotection', '__form_id', '__refer', '__is_ajax', '__is_ajax_submit']);
+ const rest = Object.entries(body).filter(([k, v]) => !TECH.has(k.toLowerCase()) && String(v ?? '').trim());
+ if (rest.length) mailHtml += `Alle übermittelten Felder:
` + felderHtml(rest.map(([k, v]) => [k, clean(String(v))]));
+
+ await svc('offer_requests').createOne(record);
+ logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
+
+ // Schlanke interne Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
+ await teamMail(schema, subject, mailHtml);
+
+ return res.json({ ok: true });
+ } catch (err) {
+ logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
+ }
+ });
+
+ // ── Manueller Auftrag (Admin) ────────────────────────────────────────────
+ // Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
+ // die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
+ // dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
+ // Schriftstück(e) + Kuvert(e) eines/mehrerer Aufträge neu generieren (Operator).
+ router.post('/regenerate', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ const ids = Array.isArray(body.orders) ? body.orders : (body.order != null ? [body.order] : []);
+ if (!ids.length) return res.status(400).json({ error: 'Keine Auftrags-ID.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ let ok = 0;
+ for (const id of ids) {
+ // Vorschau-Session lösen → frisch rendern (nicht 1:1 aus der alten Vorschau kopieren).
+ try { await svc('orders').updateOne(id, { preview_session: null }); } catch { /* egal */ }
+ try {
+ const r = await fetch(`${BACKEND_URL}/api/order/from-directus`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ orderId: id }),
+ });
+ if (r.ok) ok += 1;
+ } catch (e) { logger.warn(`[skrift-orders] regenerate ${id}: ${e.message}`); }
+ }
+ return res.json({ ok: true, regeneriert: ok, gesamt: ids.length });
+ } catch (err) {
+ logger.error(`[skrift-orders] regenerate: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Neu-Generierung fehlgeschlagen.' });
+ }
+ });
+
+ router.post('/manual-order', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ const entries = Array.isArray(body.entries) ? body.entries : [];
+ if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
+ if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const product = (await svc('products').readByQuery({
+ filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
+ }))?.[0];
+ if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
+
+ // Preis nur zur Aufzeichnung (kein Gate).
+ let quote = null;
+ try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
+
+ // Kunde per E-Mail verknüpfen – existiert er noch nicht, automatisch anlegen.
+ let customerId = null, customerEmail = null;
+ if (body.customer_email && isEmail(norm(body.customer_email))) {
+ const kundeMail = norm(body.customer_email);
+ const users = new UsersService({ schema, accountability: null });
+ const u = (await users.readByQuery({ filter: { email: { _eq: kundeMail } }, limit: 1, fields: ['id', 'email'] }))?.[0];
+ if (u) { customerId = u.id; customerEmail = u.email; }
+ else {
+ try {
+ const role = (await new RolesService({ schema, accountability: null }).readByQuery({
+ filter: { name: { _eq: 'Kunde' } }, limit: 1, fields: ['id'],
+ }))?.[0];
+ customerId = await users.createOne({ email: kundeMail, password: zufallsPasswort(), role: role?.id ?? null, status: 'active' });
+ customerEmail = kundeMail;
+ } catch (e) { logger.warn(`[skrift-orders] manual-order Kunde anlegen: ${e.message}`); customerEmail = kundeMail; }
+ }
+ }
+
+ let formatId = null;
+ if (body.format) {
+ formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
+ }
+ // Kuvertformat: explizite Angabe gewinnt; Datei-Produkte (Unterschriften)
+ // immer DIN Lang; A6/Postkarten → C6; sonst Standard DIN Lang.
+ const envelopeFormat = body.needs_envelope
+ ? (body.envelope_format
+ || (quote.product?.input_mode === 'datei' ? 'dinlang'
+ : (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')))
+ : null;
+
+ const istInland = zielInland;
+ const countAusland = entries.filter((e) => !istInland(e)).length;
+
+ const jt = new Date();
+ const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
+ const naechsteNummer = async () => {
+ const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
+ return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
+ };
+
+ const orderData = {
+ customer: customerId, customer_email: customerEmail,
+ product: product.id, format: formatId,
+ person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
+ font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
+ realistic: body.realistic !== false,
+ // Relative Schriftgröße (getrennt Brief/Kuvert), 1 = Standard.
+ font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
+ font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
+ // Signatur(en) auf dem Brief (SVG je [[signaturN]]) + Feinjustierung, für die finale Generierung.
+ signatures_manual: (body.signatures && typeof body.signatures === 'object' && Object.keys(body.signatures).length)
+ ? { signatures: body.signatures, offsets: (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {} }
+ : null,
+ // Mehrseitiges Schriftstück (Auto-Fluss). Seiten werden bei der Generierung erzeugt.
+ multi_page: body.multi_page === true,
+ source: 'operator', status: 'in_queue', production_status: 'eingegangen',
+ payment_status: 'bezahlt', payment_method: 'rechnung',
+ shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
+ needs_envelope: !!body.needs_envelope,
+ envelope_labeling: body.envelope_labeling ?? null,
+ envelope_text: clean(body.envelope_text) ?? null,
+ envelope_format: envelopeFormat,
+ envelope_sender: clean(body.envelope_sender) ?? null,
+ envelope_sender_scale: Math.max(0.5, Math.min(2, Number(body.envelope_sender_scale) || 1)),
+ text_template: clean(body.text_template) ?? null,
+ preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
+ entries_count: entries.length,
+ count_inland: entries.length - countAusland, count_ausland: countAusland,
+ net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
+ price_lines: quote?.lines ?? null,
+ };
+
+ let orderId, orderNumber;
+ for (let v = 0; v < 6; v += 1) {
+ orderNumber = await naechsteNummer();
+ try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
+ catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
+ }
+
+ const entriesSvc = svc('order_entries');
+ let n = 0;
+ for (const e of entries) {
+ n += 1;
+ await entriesSvc.createOne({
+ order: orderId, letter_number: n,
+ salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
+ street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
+ city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
+ placeholders: e.placeholders && typeof e.placeholders === 'object'
+ ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
+ });
+ }
+
+ await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
+
+ // Generierung IMMER anstoßen – für jedes Produkt. Das Backend entscheidet,
+ // was zu erzeugen ist (Schriftstücke bei Handschrift-Produkten, Kuverts immer
+ // wenn gewünscht; bei Datei-Produkten NUR die Kuverts). Nichts = No-Op.
+ fetch(`${BACKEND_URL}/api/order/from-directus`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ orderId }),
+ }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
+
+ return res.json({ ok: true, order_number: orderNumber, id: orderId });
+ } catch (err) {
+ logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
+ return res.status(400).json({ error: err.message });
+ }
+ });
+
+ // ── Auftrag anlegen ──────────────────────────────────────────────────────
+ router.post('/', async (req, res) => {
+ const body = req.body || {};
+ const errors = validate(body);
+ if (errors.length) return res.status(400).json({ errors });
+
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const email = norm(body.email);
+ const entries = body.entries;
+
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const quote = await calculatePrice(schema, { ...body, quantity: entries.length, customerId: req.accountability?.user || null });
+
+ // Zahlungsart erzwingen, wenn über dem PayPal-Limit
+ let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung';
+ if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung';
+
+ // Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail.
+ const users = new UsersService({ schema, accountability: null });
+ let customer = (await users.readByQuery({
+ filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'billing_address'],
+ }))?.[0];
+ if (!customer) {
+ const rolesSvc = new RolesService({ schema, accountability: null });
+ const role = (await rolesSvc.readByQuery({
+ filter: { name: { _eq: 'Kunde' } }, limit: 1, fields: ['id'],
+ }))?.[0];
+ const id = await users.createOne({
+ email,
+ password: zufallsPasswort(),
+ role: role?.id ?? null,
+ status: 'active',
+ person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
+ first_name: clean(body.first_name) ?? null,
+ last_name: clean(body.last_name) ?? null,
+ company: clean(body.company) ?? null,
+ phone: clean(body.phone) ?? null,
+ });
+ customer = { id, billing_address: null };
+ }
+
+ // Rechnungsadresse automatisch ins Konto übernehmen, wenn dort noch KEINE
+ // hinterlegt ist (bestehende Adresse wird nicht überschrieben).
+ try {
+ const b = body.billing && typeof body.billing === 'object' ? body.billing : null;
+ const hatAdresse = customer.billing_address
+ && (customer.billing_address.street || customer.billing_address.city);
+ if (b && (b.street || b.city) && !hatAdresse) {
+ await users.updateOne(customer.id, {
+ billing_address: {
+ street: clean(b.street) || '', zip: clean(b.zip) || '',
+ city: clean(b.city) || '', country: clean(b.country) || '',
+ },
+ });
+ }
+ } catch (e) { logger.warn(`[skrift-orders] Konto-Adresse nicht übernommen: ${e.message}`); }
+
+ // Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur
+ // bestellbar, wenn sie für dieses Konto freigeschaltet wurden.
+ if (quote.product.type === 'contact') {
+ const frei = await svc('customer_products').readByQuery({
+ filter: { customer: { _eq: customer.id }, product: { _eq: quote.product.id } },
+ limit: 1, fields: ['id'],
+ });
+ if (!frei?.length) {
+ return res.status(403).json({ errors: ['Dieses Produkt ist für Ihr Konto nicht freigeschaltet.'] });
+ }
+ }
+
+ // Format auflösen (optional)
+ let formatId = null;
+ if (body.format) {
+ formatId = (await svc('formats').readByQuery({
+ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'],
+ }))?.[0]?.id ?? null;
+ }
+
+ // Umschlagformat automatisch: A6/Postkarte → C6, sonst Standard DIN Lang.
+ const envelopeFormat = body.needs_envelope
+ ? (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')
+ : null;
+
+ // Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht).
+ const istInland = zielInland;
+ const countAusland = entries.filter((e) => !istInland(e)).length;
+ const countInland = entries.length - countAusland;
+
+ // Fortlaufende Bestellnummer je Tag: dd-mm-yy-NNN (NNN ab 001).
+ const jt = new Date();
+ const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
+ const naechsteNummer = async () => {
+ const heutige = await svc('orders').readByQuery({
+ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'],
+ });
+ return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
+ };
+
+ // Anlegen mit Wiederholung, falls zwei Bestellungen gleichzeitig dieselbe
+ // Nummer träfen (order_number ist eindeutig).
+ const orderData = {
+ customer: customer.id,
+ customer_email: email,
+ product: quote.product.id,
+ // Gewähltes Kundenangebot (Aufschläge sind bereits im Preis) + Namens-Zusatz.
+ customer_offer: quote.offer?.id ?? null,
+ product_name_suffix: quote.offer?.name_suffix ?? null,
+ format: formatId,
+ person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
+ font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
+ // Relative Schriftgröße (v.a. manuelle Aufträge), getrennt Brief/Kuvert. 1 = Standard.
+ font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
+ font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
+ source: ['operator', 'portal', 'extern'].includes(body.source) ? body.source : 'configurator',
+ // Extern (z. B. Etsy) wird über die Plattform bezahlt → nicht auf Zahlung warten.
+ status: body.source === 'extern' ? 'bezahlt' : 'wartet_auf_zahlung',
+ shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
+ needs_envelope: !!body.needs_envelope,
+ envelope_labeling: body.envelope_labeling ?? null,
+ envelope_text: clean(body.envelope_text) ?? null,
+ envelope_format: envelopeFormat,
+ motif_mode: body.motif_mode ?? null,
+ motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null,
+ motif_upload: body.motif_upload || null,
+ source_file: body.source_file || null,
+ source_files: Array.isArray(body.source_files) && body.source_files.length
+ ? body.source_files
+ : (body.source_file ? [body.source_file] : null),
+ billing: body.billing && typeof body.billing === 'object' ? body.billing : null,
+ shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null,
+ own_cards: body.motif_mode === 'eigene_karten',
+ text_template: clean(body.text_template) ?? null,
+ text_briefing: clean(body.text_briefing) ?? null,
+ preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
+ entries_count: entries.length,
+ count_inland: countInland,
+ count_ausland: countAusland,
+ net_total: quote.net_total,
+ vat_amount: quote.vat_amount,
+ gross_total: quote.gross_total,
+ price_lines: quote.lines ?? null,
+ voucher: quote.voucher?.id ?? null,
+ payment_method: paymentMethod,
+ payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
+ };
+
+ let orderId, orderNumber;
+ for (let versuch = 0; versuch < 6; versuch += 1) {
+ orderNumber = await naechsteNummer();
+ try {
+ orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData });
+ break;
+ } catch (e) {
+ const dup = /unique|duplicate|bereits|exists/i.test(String(e?.message || ''));
+ if (versuch === 5 || !dup) throw e;
+ }
+ }
+
+ // Empfängerzeilen – Briefnummer wird IMMER vom System vergeben
+ const entriesSvc = svc('order_entries');
+ let n = 0;
+ for (const e of entries) {
+ n += 1;
+ await entriesSvc.createOne({
+ order: orderId,
+ letter_number: n,
+ salutation: clean(e.salutation) ?? null,
+ first_name: clean(e.first_name) ?? null,
+ last_name: clean(e.last_name) ?? null,
+ street: clean(e.street) ?? null,
+ house_no: clean(e.house_no) ?? null,
+ zip: clean(e.zip) ?? null,
+ city: clean(e.city) ?? null,
+ country: clean(e.country) ?? null,
+ free_text: clean(e.free_text) ?? null,
+ placeholders: e.placeholders && typeof e.placeholders === 'object'
+ ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
+ : null,
+ // Abweichender Text nur für dieses Schriftstück (#5).
+ text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null,
+ });
+ }
+
+ // Zusatzleistungen verknüpfen
+ if (Array.isArray(body.addons) && body.addons.length) {
+ const items = await svc('price_items').readByQuery({
+ filter: { key: { _in: body.addons }, category: { _eq: 'zusatzleistung' } },
+ limit: -1, fields: ['id'],
+ });
+ for (const it of items || []) await svc('order_addons').createOne({ order: orderId, price_item: it.id });
+ }
+
+ // Gutschein-Einlösung protokollieren
+ if (quote.voucher) {
+ await svc('voucher_redemptions').createOne({ voucher: quote.voucher.id, order: orderId });
+ await svc('vouchers').updateOne(quote.voucher.id, { used_count: (quote.voucher.used_count ?? 0) + 1 });
+ }
+
+ await svc('status_history').createOne({
+ order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
+ });
+
+ // Motiv-Upload (Postkarte) → A6-Entwurf erzeugen + Kundenfreigabe anfordern.
+ let freigabeToken = null;
+ const istMotivUpload = body.motif_mode === 'upload' && (body.motif_upload || body.source_file);
+ if (istMotivUpload) {
+ freigabeToken = crypto.randomBytes(24).toString('base64url');
+ try { await svc('orders').updateOne(orderId, { approval_status: 'pending', approval_token: freigabeToken }); }
+ catch (e) { logger.warn(`[skrift-orders] Freigabe-Setup: ${e.message}`); }
+ // A6-Layout asynchron erzeugen – blockiert die Antwort an den Kunden nicht.
+ erzeugeMotivEntwurf(schema, orderId, body.motif_upload || body.source_file)
+ .catch((e) => logger.warn(`[skrift-orders] Motiv-Entwurf ${orderNumber}: ${e.message}`));
+ }
+
+ // Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
+ const kundeEmail = norm(body.email);
+ if (isEmail(kundeEmail)) {
+ let portal = (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim();
+ try { const es = await svc('email_settings').readSingleton({ fields: ['portal_url'] }); if (es?.portal_url) portal = String(es.portal_url).trim(); } catch { /* Default */ }
+ const vars = {
+ order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' '),
+ freigabe_link: freigabeToken ? `${portal.replace(/\/$/, '')}/freigabe/${freigabeToken}` : '',
+ };
+ if (istMotivUpload) sendeVorlage(schema, 'motiv_freigabe', kundeEmail, vars);
+ else if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
+ else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
+ }
+
+ // SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt – IMMER
+ // und für jedes Produkt (das Backend entscheidet, was zu erzeugen ist:
+ // Schriftstücke bei Handschrift-Produkten, Kuverts immer wenn gewünscht;
+ // bei Datei-Produkten NUR die Kuverts, nicht das hochgeladene Dokument).
+ // Feuern & vergessen – blockiert die Antwort an den Kunden nicht.
+ fetch(`${BACKEND_URL}/api/order/from-directus`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ orderId }),
+ }).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`));
+
+ return res.json({
+ order_number: orderNumber,
+ net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
+ payment_method: paymentMethod, paypal_allowed: quote.paypal_allowed,
+ });
+ } catch (err) {
+ logger.error(`[skrift-orders] ${err.stack || err.message}`);
+ return res.status(400).json({ error: err.message });
+ }
+ });
+};
+
+export default { id: 'skrift-orders', handler };
diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js
index 175f65d..b411adc 100644
--- a/extensions/directus-extension-skrift-produktion/dist/index.js
+++ b/extensions/directus-extension-skrift-produktion/dist/index.js
@@ -194,7 +194,7 @@ const Board = defineComponent({
const musterListe = ref([]);
// ── Modale ──
- const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, msg: '', err: '', deNums: '', intNums: '', deN: 0, intN: 0 });
+ const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, spaltenweise: false, msg: '', err: '', deNums: '', intNums: '', deN: 0, intN: 0 });
const manuell = reactive({ open: false, req: null, form: 'sie', vorlageId: '', roh: '', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '',
// Adresse automatisch aufs Kuvert (Standard an) + eigenes Kuvert-Template/-Format.
kuvert: true, envFormat: 'dinlang', envTemplate: null, msg: '', err: '' });
@@ -756,6 +756,8 @@ const Board = defineComponent({
druck.order = order;
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
druck.mode = 'queue'; druck.msg = ''; druck.err = '';
+ // Rollenschonende Zeichenreihenfolge nur bei A6 anbieten (Default aus).
+ druck.spaltenweise = false;
druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) };
druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null;
druck.deNums = ''; druck.intNums = ''; druck.deN = 0; druck.intN = 0;
@@ -789,7 +791,7 @@ const Board = defineComponent({
const sections = {};
const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '',
params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } });
- if (druck.brief?.enabled) sections.brief = bau(druck.brief);
+ if (druck.brief?.enabled) sections.brief = { ...bau(druck.brief), spaltenweise: !!druck.spaltenweise };
if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag);
if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; }
druck.err = ''; druck.msg = 'Wird eingereiht …';
@@ -1478,6 +1480,14 @@ const Board = defineComponent({
]),
druckSektion('brief', 'Briefe', '📄', 'brief'),
druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'),
+ // Rollenschonende Zeichenreihenfolge – nur bei A6 sinnvoll (Karte quer).
+ String(druck.order.format?.key || '').startsWith('a6') ? h('div', { style: SECTION + 'margin-bottom:12px;' }, [
+ h('label', { style: 'display:flex;align-items:center;gap:10px;font-size:14px;cursor:pointer;' }, [
+ h('input', { type: 'checkbox', checked: druck.spaltenweise, onChange: (e) => { druck.spaltenweise = e.target.checked; }, style: 'width:18px;height:18px;' }),
+ h('span', { style: 'font-weight:600;' }, 'Rollenschonend drucken (spaltenweise)'),
+ ]),
+ subdued('Für A6 quer: Zeichen werden spaltenweise (kurze Seite, oben→unten) gedruckt, damit die Rolle nicht je Zeile zurückfährt. Das sichtbare Ergebnis auf der Karte bleibt gleich – nur die Schriftstücke (nicht die Kuverts).', 'margin-top:6px;'),
+ ]) : null,
h('div', { style: 'margin:12px 0 4px;' }, [
segToggle(busy ? 'queue' : druck.mode, [
{ key: 'direct', label: '⚡ Direktdruck', disabled: busy },