Files
skrift-directus/extensions/directus-extension-skrift-orders/dist/index.js
s4luorth 73e7a878f1 A6 quer: Bahnbreite auf 2 Zeichen je Reihe
Default proReihe/group von 5 auf 2 gesenkt. Weiterhin über ?group=N
uebersteuerbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-24 12:21:11 +02:00

2307 lines
122 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
function felderHtml(pairs) {
const rows = pairs
.filter(([, v]) => v != null && String(v).trim())
.map(([k, v]) => `<tr><td style="padding:3px 14px 3px 0;color:#666;vertical-align:top;white-space:nowrap;">${escH(k)}</td>`
+ `<td style="padding:3px 0;"><strong>${escH(v).replace(/\n/g, '<br>')}</strong></td></tr>`)
.join('');
return `<table style="border-collapse:collapse;font-size:14px;line-height:1.5;">${rows}</table>`;
}
// 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="152mm" height="109mm" viewBox="0 0 152 109">`
+ `<rect x="0" y="0" width="152" height="109" fill="#ffffff"/>`
+ `<image href="data:${mime};base64,${base64}" x="2" y="2" width="148" height="105" preserveAspectRatio="xMidYMid meet"/>`
+ `</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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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'
? `<p>Der Kunde hat den Motiv-Entwurf zu <strong>${escHtml(o.order_number)}</strong> freigegeben – kann in den Druck.</p>`
: `<p>Änderungswunsch zum Motiv-Entwurf <strong>${escHtml(o.order_number)}</strong>:</p><p style="white-space:pre-wrap">${escHtml(note)}</p>`);
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-<path>-Elemente
// nach x (Spalte, aufsteigend), innerhalb einer Spalte nach y (oben→unten).
// Andere Elemente (Hintergrund-Rect, Signatur-<g>, 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) {
// <g>…</g>-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 </g>. Ohne <g> bleibt alles unverändert.
const bloecke = [];
const safe = svg.replace(/<g\b[\s\S]*<\/g>/, (m) => { bloecke.push(m); return 'G0'; });
const RE = /<path\b[^>]*\/>/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:{ '<formatKey>': { 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 <noreply@…>"). 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}`,
`<p>Neue Angebotsanfrage – im Board unter „Neu".</p>`
+ 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)}`,
`<p>Neue Musteranfrage – im Board unter „Neu".</p>`
+ 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 += `<p style="margin:14px 0 4px;color:#888;font-size:12px;">Alle übermittelten Felder:</p>` + 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 };