Directus als Daten-/Auth-Schicht + Backend-Anbindung

Directus (Postgres, hinter NPM) wird die zentrale Datenquelle für Kunden,
Aufträge, Produkte, Preise und Gutscheine; das skrift-backend bleibt reine
Generierungs-Engine.

- Docker/directus: docker-compose (Directus 11 + Postgres), Bootstrap-Skript
  legt Collections, Rollen/Policies/Rechte und Seed idempotent an.
- Extension skrift-auth: passwortloser Login per E-Mail-Code (Codes nur als
  Hash, Ablauf, Versuchslimit) sowie Kontoanlage aus dem Checkout.
- Extension skrift-orders: kontrollierter Bestell-Endpunkt für Gäste und
  eingeloggte Kunden; Preis wird immer serverseitig berechnet (editierbare
  Formel via sicherem Interpreter, Werte aus price_items).
- skrift-backend: neuer Endpunkt /api/order/from-directus erzeugt SVGs aus
  einem Directus-Auftrag und schreibt Status + artifact_path zurück. Dafür
  wurde die Kernlogik aus generateOrder als runGeneration herausgelöst
  (Verhalten unverändert); generateOrder ist jetzt ein HTTP-Wrapper.
- compose: NPM-Netz ergänzt, API_TOKEN/DIRECTUS_* werden durchgereicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 19:13:35 +02:00
parent 0341c4eaea
commit 2dbbc4611a
17 changed files with 1571 additions and 24 deletions

View File

@@ -0,0 +1,349 @@
/**
* 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.
*/
// ── Sicherer Formel-Interpreter ─────────────────────────────────────────────
// Erlaubt: Zahlen, Variablen, + - * / ( ) und unäres Minus. Sonst nichts.
function evalFormula(expr, vars) {
const tokens = String(expr).match(/\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]/g);
if (!tokens) throw new Error('Formel ist leer oder ungültig.');
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
// Ausdruck := Term (('+'|'-') Term)*
function parseExpr() {
let left = parseTerm();
while (peek() === '+' || peek() === '-') {
const op = next();
const right = parseTerm();
left = op === '+' ? left + right : left - right;
}
return left;
}
// Term := Faktor (('*'|'/') Faktor)*
function parseTerm() {
let left = parseFactor();
while (peek() === '*' || peek() === '/') {
const op = next();
const right = parseFactor();
if (op === '/' && right === 0) throw new Error('Division durch 0 in der Preisformel.');
left = op === '*' ? left * right : left / right;
}
return left;
}
// Faktor := '-' Faktor | '(' Ausdruck ')' | Zahl | Variable
function parseFactor() {
const t = next();
if (t === undefined) throw new Error('Formel unvollständig.');
if (t === '-') return -parseFactor();
if (t === '(') {
const v = parseExpr();
if (next() !== ')') throw new Error('Fehlende schließende Klammer.');
return v;
}
if (/^\d/.test(t)) return parseFloat(t);
if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
throw new Error(`Unbekannte Variable in der Preisformel: "${t}"`);
}
const result = parseExpr();
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 Betrag.');
return result;
}
const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const handler = (router, { services, getSchema, logger }) => {
const { ItemsService } = services;
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', '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 = [];
const add = (label, amount) => { if (amount) lines.push({ label, amount: round2(amount) }); };
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
const formula = settings.formula || 'base_price * menge';
const grund = evalFormula(formula, { base_price: Number(product.base_price) || 0, menge });
add(`${product.name} (${menge} Stk.)`, grund);
// 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);
add('Porto Ausland', priceOf('porto_ausland') * auslandCount);
} else {
add('Sammelversand', priceOf('sammelversand'));
}
// 3) Kuvert + Beschriftung
if (input.needs_envelope) {
add('Kuvert', applyUnit('kuvert'));
if (input.envelope_labeling && input.envelope_labeling !== 'keine')
add('Kuvert-Beschriftung', applyUnit('beschriftung'));
}
// 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'));
for (const key of input.addons || []) {
const item = priceItems[key];
if (!item || item.category !== 'zusatzleistung') continue;
add(item.key, applyUnit(key));
}
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));
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, 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, 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 });
}
});
// ── 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 finden oder anlegen – Zuordnung immer per E-Mail
const customers = svc('customers');
let customer = (await customers.readByQuery({
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
}))?.[0];
if (!customer) {
const id = await customers.createOne({
email,
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 };
}
// 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;
const orderNumber = `SK-${new Date().toISOString().slice(0, 10)}-${
String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
const orderId = await svc('orders').createOne({
order_number: orderNumber,
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_format: envelopeFormat,
motif_mode: body.motif_mode ?? null,
own_cards: body.motif_mode === 'eigene_karten',
text_template: clean(body.text_template) ?? null,
text_briefing: clean(body.text_briefing) ?? null,
entries_count: entries.length,
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',
});
// 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.',
});
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 };

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-orders",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Kontrollierter Bestell-Endpunkt (Gast + eingeloggt) mit serverseitiger Preisberechnung.",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}