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,230 @@
/**
* Skrift – Passwortloser Login per E-Mail-Code
* ---------------------------------------------------------------------------
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
* Kunden-Login per Einmalcode:
*
* POST /skrift-auth/request-code { email } → Code per Mail
* POST /skrift-auth/verify-code { email, code } → Tokens
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
*
* Sicherheitsprinzipien:
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
*
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
*/
import crypto from 'node:crypto';
const CODE_TTL_MIN = 10;
const MAX_ATTEMPTS = 5;
const RESEND_COOLDOWN_SEC = 60;
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
const handler = (router, { services, getSchema, env, logger }) => {
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
const norm = (e) => String(e || '').trim().toLowerCase();
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const hashCode = (code, email) =>
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
const ctx = async () => ({ schema: await getSchema(), accountability: null });
async function findUserByEmail(email, schema) {
const users = new UsersService({ schema, accountability: null });
const found = await users.readByQuery({
filter: { email: { _eq: email } },
fields: ['id', 'email', 'status'],
limit: 1,
});
return found?.[0] || null;
}
async function sendCodeMail(email, code) {
const mail = new MailService({ schema: await getSchema(), accountability: null });
await mail.send({
to: email,
subject: `Ihr Anmeldecode: ${code}`,
html: `
<p>Guten Tag,</p>
<p>Ihr Anmeldecode für Skrift lautet:</p>
<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>
<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung
angefordert haben, können Sie diese E-Mail ignorieren.</p>
`,
});
}
// ── Code anfordern ────────────────────────────────────────────────────────
router.post('/request-code', async (req, res) => {
const email = norm(req.body?.email);
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
const generic = () => res.json({ ok: true });
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
// Wiederhol-Sperre
const recent = await codes.readByQuery({
filter: { email: { _eq: email } },
sort: ['-date_created'], limit: 1, fields: ['date_created'],
});
if (recent?.[0]?.date_created) {
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
if (age < RESEND_COOLDOWN_SEC) return generic();
}
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active') return generic();
const code = genCode();
await codes.createOne({
email,
code_hash: hashCode(code, email),
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
attempts: 0,
used: false,
});
await sendCodeMail(email, code);
return generic();
} catch (err) {
logger.error(`[skrift-auth] request-code: ${err.message}`);
return generic();
}
});
// ── Code prüfen + anmelden ────────────────────────────────────────────────
router.post('/verify-code', async (req, res) => {
const email = norm(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!isEmail(email) || !/^\d{6}$/.test(code))
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
try {
const { schema } = await ctx();
const codes = new ItemsService('login_codes', { schema, accountability: null });
const rows = await codes.readByQuery({
filter: { email: { _eq: email }, used: { _eq: false } },
sort: ['-date_created'], limit: 1,
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
});
const row = rows?.[0];
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
if (new Date(row.expires_at).getTime() < Date.now()) {
await codes.updateOne(row.id, { used: true });
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
}
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
await codes.updateOne(row.id, { used: true });
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
}
const expected = Buffer.from(row.code_hash, 'utf8');
const actual = Buffer.from(hashCode(code, email), 'utf8');
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
if (!ok) {
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
return res.status(401).json({ error: 'Code ungültig.' });
}
await codes.updateOne(row.id, { used: true });
const user = await findUserByEmail(email, schema);
if (!user || user.status !== 'active')
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
const users = new UsersService({ schema, accountability: null });
const tempPassword = crypto.randomBytes(32).toString('base64url');
await users.updateOne(user.id, { password: tempPassword });
const auth = new AuthenticationService({ schema, accountability: null });
const result = await auth.login('default', { email, password: tempPassword });
// Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen.
const customers = new ItemsService('customers', { schema, accountability: null });
const cust = await customers.readByQuery({
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'],
});
if (cust?.[0] && !cust[0].linked_user) {
await customers.updateOne(cust[0].id, { linked_user: user.id });
}
return res.json({
access_token: result.accessToken ?? result.access_token,
refresh_token: result.refreshToken ?? result.refresh_token,
expires: result.expires,
});
} catch (err) {
logger.error(`[skrift-auth] verify-code: ${err.message}`);
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
}
});
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
router.post('/register', async (req, res) => {
const email = norm(req.body?.email);
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
try {
const { schema } = await ctx();
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
const existing = await findUserByEmail(email, schema);
if (existing) return res.json({ ok: true, existed: true });
const roles = new RolesService({ schema, accountability: null });
const role = (await roles.readByQuery({
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
}))?.[0];
if (!role) {
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
const users = new UsersService({ schema, accountability: null });
const userId = await users.createOne({
email,
password: crypto.randomBytes(32).toString('base64url'),
role: role.id,
status: 'active',
first_name: req.body?.first_name ?? null,
last_name: req.body?.last_name ?? null,
});
// Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail).
const customers = new ItemsService('customers', { schema, accountability: null });
const cust = (await customers.readByQuery({
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
}))?.[0];
if (cust) await customers.updateOne(cust.id, { linked_user: userId });
else await customers.createOne({
email, linked_user: userId,
first_name: req.body?.first_name ?? null,
last_name: req.body?.last_name ?? null,
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
company: req.body?.company ?? null,
});
return res.json({ ok: true, existed: false });
} catch (err) {
logger.error(`[skrift-auth] register: ${err.message}`);
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
}
});
};
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
// unabhängig von Ordner- oder Paketnamen.
export default { id: 'skrift-auth', handler };

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-auth",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Passwortloser Login per E-Mail-Code für Skrift-Kunden.",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

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"
}
}