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:
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal 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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal 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 };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user