Files
skrift-directus/extensions/directus-extension-skrift-orders/dist/index.js

1747 lines
87 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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';
/** Basisordner der generierten Auftragsdateien (read-only in Directus gemountet). */
const OUTPUT_DIR = process.env.SKRIFT_OUTPUT_DIR || '/var/skrift-output';
// ── 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;
}
const handler = (router, { services, getSchema, logger }) => {
const { ItemsService, MailService, UsersService, RolesService, AssetsService } = 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. */
async function dateiBytes(schema, id) {
const assets = new AssetsService({ schema, accountability: null });
const { stream } = await assets.getAsset(id, {});
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks);
}
/** 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).' });
const r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
if (!r.ok) {
const t = await r.text();
return res.status(502).json({ error: 'Signatur-Service-Fehler.', 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.' });
}
}
// 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'));
// 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
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 };
}
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,
});
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 });
}
});
// ── 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.' });
if (!body.text || !String(body.text).trim())
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';
const platzhalterVon = (e, i) => ({
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),
...(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);
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,
});
}
});
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.' });
}
});
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
router.get('/preview/quota', async (req, res) => {
try {
const kennung = norm(req.query.email) || String(req.query.client_id || '').slice(0, 64);
if (!kennung) return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
const schema = await getSchema();
const heute = new Date().toISOString().slice(0, 10);
const row = (await new ItemsService('preview_usage', { schema, accountability: null }).readByQuery({
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['count'],
}))?.[0];
return res.json({ remaining: Math.max(0, PREVIEW_LIMIT - (row?.count ?? 0)), limit: PREVIEW_LIMIT });
} catch {
return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
}
});
// ── Auftragsdateien (Direktzugriff auf den Ausgabe-Ordner, keine Kopie) ────
// Zugriff: App-Nutzer (Staff/Admin) ODER der Produktions-Agent (Rolle Service).
let _serviceRoleId;
async function serviceRoleId() {
if (_serviceRoleId !== undefined) return _serviceRoleId;
try {
const rows = await new ItemsService('directus_roles', { schema: await getSchema(), accountability: null })
.readByQuery({ filter: { name: { _eq: 'Service' } }, limit: 1, fields: ['id'] });
_serviceRoleId = rows?.[0]?.id ?? null;
} catch { _serviceRoleId = null; }
return _serviceRoleId;
}
async function darfDateien(req) {
const acc = req.accountability;
if (!acc || !acc.user) return false;
if (acc.admin || acc.app) return true; // Operator im Admin
const svc = await serviceRoleId();
return !!(svc && acc.role === svc); // Produktions-Agent
}
const safeOrder = (s) => (/^[A-Za-z0-9_-]+$/.test(String(s || '')) ? String(s) : null);
/** Listet die Dateien eines Auftrags aus dem gemounteten Ausgabe-Ordner. */
function listeDateien(orderNummer) {
const basis = path.join(OUTPUT_DIR, orderNummer);
let root;
try { root = fs.readdirSync(basis); } catch { return null; } // Ordner (noch) nicht da
const out = [];
const add = (rel, kind) => {
try {
const st = fs.statSync(path.join(basis, rel));
if (st.isFile()) out.push({ name: path.basename(rel), rel, kind, size: st.size });
} catch { /* ignore */ }
};
for (const f of root) {
if (/\.csv$/i.test(f)) add(f, 'platzhalter');
else if (/\.svg$/i.test(f)) add(f, 'schriftstueck');
}
try {
for (const f of fs.readdirSync(path.join(basis, 'umschlaege'))) {
if (/\.svg$/i.test(f)) add(path.join('umschlaege', f), 'umschlag');
}
} catch { /* keine Umschläge */ }
return out;
}
router.get('/files/:order', async (req, res) => {
if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
const nummer = safeOrder(req.params.order);
if (!nummer) return res.status(400).json({ error: 'Ungültige Auftragsnummer.' });
const files = listeDateien(nummer);
if (files === null) return res.json({ files: [], exists: false });
return res.json({ files, exists: true });
});
router.get('/files/:order/download', async (req, res) => {
if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
const nummer = safeOrder(req.params.order);
const rel = String(req.query.rel || '');
if (!nummer || rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) {
return res.status(400).json({ error: 'Ungültiger Pfad.' });
}
const basis = path.resolve(OUTPUT_DIR, nummer);
const ziel = path.resolve(basis, rel);
// Pfad muss innerhalb des Auftragsordners liegen (kein Ausbruch).
if (ziel !== basis && !ziel.startsWith(basis + path.sep)) {
return res.status(400).json({ error: 'Ungültiger Pfad.' });
}
if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) {
return res.status(404).json({ error: 'Datei nicht gefunden.' });
}
const typ = ziel.endsWith('.svg') ? 'image/svg+xml'
: ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream';
res.setHeader('Content-Type', typ);
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`);
fs.createReadStream(ziel).pipe(res);
});
// ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
// Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
// EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
// Body: { orderIds: (number|string)[] }
router.post('/charge/pdf', async (req, res) => {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
try {
const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderIds }),
});
if (!r.ok) {
let msg = 'Sammel-PDF fehlgeschlagen.';
try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
}
const buf = Buffer.from(await r.arrayBuffer());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
return res.send(buf);
} catch (e) {
logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
return res.status(502).json({ error: 'Backend nicht erreichbar.' });
}
});
// ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
// Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
// Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
// Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
router.post('/enqueue', async (req, res) => {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
const body = req.body || {};
if (!body.order || !body.machine) return res.status(400).json({ error: 'order und machine erforderlich.' });
try {
const schema = await getSchema();
const svc = (c) => new ItemsService(c, { schema, accountability: null });
const order = await svc('orders').readOne(body.order, {
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
});
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
const tplByKey = async (key) => (await svc('format_templates').readByQuery({
filter: { key: { _eq: key } }, limit: 1, fields: ['id'],
}))?.[0]?.id ?? null;
// Läuft auf dieser Maschine gerade ein Druck? → nur Warteschlange möglich.
const laufend = await svc('jobs').readByQuery({
filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'],
});
const busy = !!(laufend && laufend.length);
const modus = (body.mode === 'direct' && !busy) ? 'direct' : 'queue';
const prio = modus === 'direct' ? 100 : 0;
// Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null).
const clampParams = (p) => {
if (!p || typeof p !== 'object') return null;
const out = {};
for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
}
return Object.keys(out).length ? out : null;
};
const jobs = [];
// Neue, ausführliche Form: explizite Abschnitte (Brief/Umschlag) aus dem Popup.
const s = body.sections;
if (s && typeof s === 'object') {
for (const typ of ['brief', 'umschlag']) {
const sec = s[typ];
if (!sec || sec.enabled === false) continue;
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
jobs.push(await svc('jobs').createOne({
order: order.id, machine: body.machine, type: typ, template: sec.template,
format: fmt.key, format_label: fmt.label,
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
status: 'queued', priority: prio,
}));
}
if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
} else {
// Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
const bf = briefFormatOf(order);
const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
jobs.push(await svc('jobs').createOne({
order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
}));
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
const ef = umschlagFormatOf(order);
const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
jobs.push(await svc('jobs').createOne({
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
}));
}
}
return res.json({ ok: true, jobs, mode: modus, busy });
} catch (err) {
logger.error(`[skrift-orders] enqueue: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Einreihen fehlgeschlagen.' });
}
});
// ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
// Body: { orders:[id…], mode:'queue'|'direct',
// byFormat:{ '<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;
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, 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';
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: 1, type: 'letter', text: clean(String(body.text)), format, font, realisticHandwriting: body.realistic !== false }],
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',
});
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 });
} 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 || {};
const name = String(body.name || '').trim();
const email = norm(body.email);
const message = String(body.message || '').trim();
const errors = [];
if (!name) errors.push('Name fehlt.');
if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
if (!message && !body.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(String(body.phone || '').trim()) || null,
quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : null,
product: body.product ? clean(String(body.product)) : null,
message: clean(message),
status: 'neu',
customer: customer?.id ?? null,
});
// Schlanke interne Benachrichtigung.
const produkt = body.product ? clean(String(body.product)) : null;
await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
`<p>Neue Angebotsanfrage von <strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
+ `${produkt ? ` · ${produkt}` : ''} – im Board unter „Neu".</p>`);
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 || {};
const email = norm(body.email);
const errors = [];
if (!body.product) errors.push('Produkt fehlt.');
if (!String(body.name || '').trim()) errors.push('Name fehlt.');
if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.');
if (!String(body.street || '').trim() || !String(body.zip || '').trim() || !String(body.city || '').trim())
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(String(body.name).trim()),
email,
phone: clean(String(body.phone || '').trim()) || null,
product: clean(String(body.product)),
message: clean(String(body.message || '').trim().slice(0, 1000)) || null,
address: {
street: clean(String(body.street || '')),
zip: clean(String(body.zip || '')),
city: clean(String(body.city || '')),
country: clean(String(body.country || '')),
},
status: 'neu',
customer: customer?.id ?? null,
});
const musterNachricht = clean(String(body.message || '').trim().slice(0, 1000));
await teamMail(schema, `Neue Musteranfrage: ${clean(String(body.name))}`,
`<p>Neue Musteranfrage von <strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
+ ` · Produkt: ${clean(String(body.product))} – im Board unter „Neu".</p>`
+ (musterNachricht ? `<p><strong>Nachricht:</strong><br>${musterNachricht.replace(/\n/g, '<br>')}</p>` : ''));
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.
logger.info(`[skrift-orders] inbound/${kind}: content-type=${req.get('content-type') || '—'}, `
+ `felder=[${Object.keys(body).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).' });
const name = String(body.name || '').trim();
const email = norm(body.email);
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(String(body.telefon || body.phone || '').trim()) || null;
let record; let subject; let mailHtml;
if (kind === 'muster') {
const produkt = body.produkt_interesse ? clean(String(body.produkt_interesse)) : null;
record = {
type: 'muster', name: clean(name), email, phone, product: produkt, message: null,
address: {
street: clean(String(body.street || '')),
zip: clean(String(body.zipcode || body.zip || '')),
city: clean(String(body.city || '')),
country: clean(String(body.country || '')),
},
status: 'neu', customer: customer?.id ?? null,
};
subject = `Neue Musteranfrage: ${name}`;
mailHtml =
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
`<p>${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}</p>`;
} 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 =
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
}
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.
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 optional verknüpfen (per E-Mail oder ID).
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);
}
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 = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
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,
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,
};
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 });
// 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 = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
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,
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,
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.',
});
// Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
const kundeEmail = norm(body.email);
if (isEmail(kundeEmail)) {
const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') };
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 };