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 `${rows}
`; -} - -// 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 `${rows}
`; +} + +// 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 },