Produktions-Board: - Massenauswahl (+ "Alle auswählen") mit Sammel-Druck und Sammel-Status - Warteschlange je Maschine nach Format gebündelt; Formatabfrage (machines.current_format) statt automatischem Anschluss - resiliente Ladeabfragen (Board bleibt ohne neue Felder nutzbar) Agent (skrift-agent): - druckt strikt ein Dokument nach dem anderen (Idle-Gate) -> behebt Verlust von Folgejobs während eines laufenden Drucks - druckt nur Jobs des eingelegten Formats (current_format), kein Auto-Anschluss - Heartbeat gedrosselt (nur bei Statuswechsel / alle 60 s) Neues Modul "Manueller Druck" (skrift-neuauftrag): - interner Auftrag ohne Zahlung, Vorschau, Platzhalter/Excel-Import, Felder bleiben nach dem Absenden stehen Backend/Endpunkte (skrift-orders): - jobs tragen format/format_label; /enqueue-bulk für Sammeldruck - Webhook /inbound/:kind (JetFormBuilder) mit Token + tolerantem Body-Parser - Kleinmengen-Aufschlag je Produkt über Faktoren a (Deckel) und e (Exponent) - Fix: norm()-Shadowing in calculatePrice (norm is not a function) Bootstrap: - jobs.format/format_label, machines.current_format - products.price_factor_a/e; Multiplikator-Formel auf a/e umgestellt inkl. verhaltensneutraler Migration bestehender Formel/Produkte skrift-app (Konfigurator): - Zeilenumbrüche im Textfeld erlaubt; Umschlag-Individualtext als Textfeld - Platzhalter-Tabelle im Text-Schritt immer verfügbar (auch Sammelversand ohne Kuvert) inkl. Excel-Upload nur für Platzhalterwerte - Danke-Seite: Button zurück zu skrift.de - z-index-Fix: Platzhalter-Tabelle überlagert die Vorschau nicht mehr WordPress-Preisrechner: a/e-Faktoren in PHP- und JS-Rechnung Directus: SKRIFT_WEBHOOK_TOKEN in compose/.env.example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1525 lines
74 KiB
JavaScript
1525 lines
74 KiB
JavaScript
/**
|
||
* 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 } = services;
|
||
|
||
// 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', '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 {
|
||
add('Sammelversand', 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'));
|
||
};
|
||
|
||
const basis = entries.length ? entries : [{}];
|
||
const letters = [];
|
||
basis.forEach((e, i) => {
|
||
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
|
||
const nr = i + 1;
|
||
letters.push({
|
||
index: nr, type: 'letter', text: clean(String(body.text)),
|
||
format: briefFormat, font, realisticHandwriting: realistisch,
|
||
placeholders: platzhalterVon(e, i),
|
||
});
|
||
|
||
// 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),
|
||
});
|
||
}
|
||
});
|
||
|
||
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() });
|
||
}
|
||
}
|
||
|
||
// 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.' });
|
||
}
|
||
});
|
||
|
||
/** 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);
|
||
});
|
||
|
||
// ── 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 orders = await svc('orders').readMany(orderIds, {
|
||
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
|
||
// ── 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,
|
||
});
|
||
|
||
// Benachrichtigung – darf den Erfolg der Anfrage nicht gefährden.
|
||
try {
|
||
const mail = new MailService({ schema, accountability: null });
|
||
const von = await absender(schema);
|
||
// Angefragtes Produkt (z. B. Unterschriftenservice, Follow-ups) mit ausweisen.
|
||
const produkt = body.product ? clean(String(body.product)) : null;
|
||
await mail.send({
|
||
...(von ? { from: von } : {}),
|
||
to: TEAM_EMAIL,
|
||
subject: produkt ? `Angebotsanfrage: ${produkt} – ${name}` : `Angebotsanfrage von ${name}`,
|
||
html:
|
||
`<p><strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
|
||
`<p>Anfrage betrifft: <strong>${produkt || 'Individuelles Angebot'}</strong></p>` +
|
||
`<p>Menge: ${Number.isFinite(quantity) ? quantity : 'k. A.'}</p>` +
|
||
`<p style="white-space:pre-wrap">${clean(message)}</p>`,
|
||
});
|
||
} catch (mailErr) {
|
||
logger.warn(`[skrift-orders] Angebots-Mail nicht versendet: ${mailErr.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 || {};
|
||
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: 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,
|
||
});
|
||
|
||
try {
|
||
const mail = new MailService({ schema, accountability: null });
|
||
const von = await absender(schema);
|
||
await mail.send({
|
||
...(von ? { from: von } : {}),
|
||
to: TEAM_EMAIL,
|
||
subject: `Musterbestellung: ${clean(String(body.product))} – ${body.name}`,
|
||
html:
|
||
`<p><strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
|
||
`<p>Produkt: <strong>${clean(String(body.product))}</strong></p>` +
|
||
`<p>${clean(String(body.street))}, ${clean(String(body.zip))} ${clean(String(body.city))}, ` +
|
||
`${clean(String(body.country || ''))}</p>`,
|
||
});
|
||
} catch (mailErr) {
|
||
logger.warn(`[skrift-orders] Muster-Mail nicht versendet: ${mailErr.message}`);
|
||
}
|
||
|
||
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 = `Musteranfrage (Web): ${produkt || '—'} – ${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 = `Angebotsanfrage (Web) von ${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.`);
|
||
|
||
// Interne Team-Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
|
||
try {
|
||
const mail = new MailService({ schema, accountability: null });
|
||
const von = await absender(schema);
|
||
await mail.send({ ...(von ? { from: von } : {}), to: TEAM_EMAIL, subject, html: mailHtml });
|
||
} catch (mailErr) {
|
||
logger.warn(`[skrift-orders] Webhook-Mail nicht versendet: ${mailErr.message}`);
|
||
}
|
||
|
||
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;
|
||
}
|
||
const envelopeFormat = body.needs_envelope
|
||
? (body.envelope_format || (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6'))
|
||
: 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',
|
||
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.' });
|
||
|
||
if (product.type === 'letter' || product.type === 'postcard') {
|
||
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'],
|
||
}))?.[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 };
|
||
}
|
||
|
||
// 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: A4 → DIN Lang, A6 → C6
|
||
const envelopeFormat = body.needs_envelope
|
||
? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')
|
||
: 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',
|
||
source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'),
|
||
status: '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,
|
||
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: '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,
|
||
});
|
||
}
|
||
|
||
// 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.',
|
||
});
|
||
|
||
// SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt.
|
||
// Handschrift-Produkte werden gerendert (gezeigte Vorschau-Dokumente 1:1);
|
||
// Datei-Produkte (Upload) nicht. Feuern & vergessen – blockiert die Antwort
|
||
// an den Kunden nicht (das Backend generiert im Hintergrund und schreibt
|
||
// Status/artifact_path selbst zurück).
|
||
if (quote.product.type === 'letter' || quote.product.type === 'postcard') {
|
||
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 };
|