/** * 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, proReihe = 2) { // …-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 pos = (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 { x: parseFloat(m[1]), y: parseFloat(m[2]) }; const d = p.match(/\bd\s*=\s*"\s*M\s*([-\d.]+)[\s,]+([-\d.]+)/i); if (d) return { x: parseFloat(d[1]), y: parseFloat(d[2]) }; return { x: 0, y: 0 }; }; const items = paths.map((p) => ({ p, ...pos(p) })); // Zeichenbreite aus den x-Abstaenden aufeinanderfolgender Glyphen INNERHALB // einer Zeile schaetzen (x steigt in Leserichtung; Zeilenumbruch = x faellt). const gaps = []; let prevX = null; for (const it of items) { if (prevX !== null && it.x - prevX > 0) gaps.push(it.x - prevX); prevX = it.x; } gaps.sort((a, b) => a - b); const zeichenBreite = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0; if (!(zeichenBreite > 0)) return svg; // ohne Schaetzung nicht anfassen const bahn = Math.max(1, proReihe) * zeichenBreite; // Bahn (Spalte) primaer, darin Zeile (y) oben->unten, dann x links->rechts. const sorted = items .map((it, idx) => ({ it, idx, b: Math.floor(it.x / bahn) })) .sort((a, b) => (a.b - b.b) || (a.it.y - b.it.y) || (a.it.x - b.it.x) || (a.idx - b.idx)) .map((o) => o.it.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 proReihe = Math.max(1, Math.min(100, parseInt(req.query.group, 10) || 2)); const umsortiert = svgSpaltenweise(fs.readFileSync(ziel, 'utf8'), proReihe); 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 };