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,33 @@
# Directus – kopiere zu .env und fülle die Werte aus. .env NICHT committen.
# ── Postgres ────────────────────────────────────────────────
DB_DATABASE=skrift
DB_USER=directus
DB_PASSWORD=CHANGE-ME-strong-db-password
# ── Directus ────────────────────────────────────────────────
# SECRET: langer Zufallswert, z. B. openssl rand -hex 32
DIRECTUS_SECRET=CHANGE-ME-64-hex-random
PUBLIC_URL=https://admin.skrift.de
# Erster Admin (nur beim ersten Start relevant)
ADMIN_EMAIL=admin@skrift.de
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
# CORS: URL des Frontends (Portal/Konfigurator). Kommagetrennt möglich.
CORS_ORIGIN=https://app.skrift.de
# ── E-Mail (SMTP) — für passwortlosen Login-Code + Benachrichtigungen ──
# WICHTIG: PORT und SECURE müssen zusammenpassen, sonst schlägt die Verbindung
# mit "SSL routines: wrong version number" fehl:
# Port 587 → STARTTLS → EMAIL_SMTP_SECURE=false
# Port 465 → implizites TLS → EMAIL_SMTP_SECURE=true
EMAIL_FROM=noreply@skrift.de
EMAIL_SMTP_HOST=
EMAIL_SMTP_PORT=587
EMAIL_SMTP_USER=
EMAIL_SMTP_PASSWORD=
EMAIL_SMTP_SECURE=false
# Nur falls der Mailserver ein selbstsigniertes Zertifikat nutzt:
# EMAIL_SMTP_TLS_REJECT_UNAUTHORIZED=false

View File

@@ -0,0 +1,52 @@
# Directus Bootstrap
Legt das Skrift-Datenmodell (Collections, Beziehungen, Rollen/Rechte, Seed) idempotent in einer laufenden Directus-Instanz an. Mehrfach ausführbar – Vorhandenes wird übersprungen.
## Voraussetzungen
- Directus läuft (siehe `../docker-compose.yml`) und ist erreichbar.
- Node ≥ 18 (kein `npm install` nötig – nutzt nur Bordmittel).
- Ein Admin-Zugang: entweder ein **statischer Admin-Token** oder Admin-E-Mail/Passwort.
## Ausführen
Mit Token (empfohlen – Token im Directus-Admin unter dem Admin-Nutzer erzeugen):
```bash
DIRECTUS_URL=https://admin.skrift.de \
DIRECTUS_TOKEN=<admin-static-token> \
node bootstrap.mjs
```
Alternativ mit Login:
```bash
DIRECTUS_URL=https://admin.skrift.de \
ADMIN_EMAIL=admin@skrift.de \
ADMIN_PASSWORD=... \
node bootstrap.mjs
```
Nur Schema + Seed, ohne Rollen/Rechte (zum stufenweisen Testen):
```bash
SKIP_PERMISSIONS=1 DIRECTUS_URL=... DIRECTUS_TOKEN=... node bootstrap.mjs
```
## Was es anlegt
- **Collections:** customers, products, formats, motifs, price_items, pricing_settings,
vouchers, voucher_redemptions, orders, order_entries, order_addons, jobs, status_history.
- **Rollen/Policies:** Staff (volle Verwaltung), Customer (nur Eigenes + Katalog lesen),
Public (aktive Kataloge lesen), Service (Backend/Produktion: lesen + Status schreiben).
- **Seed:** Produkte (inkl. Kontakt-Produkte), Formate, `price_items` mit den
Konfigurator-Preisen, `pricing_settings` inkl. Zeichen-Whitelist (1:1 aus job-manager).
## Nach dem Lauf – manuell
- **Service-Token:** Nutzer in Rolle *Service* anlegen, statischen Token erzeugen und in
die `.env` von skrift-backend und skrift-produktion eintragen.
- **Preise prüfen:** `price_items` mit Preis `0` (Schreib-/Gestaltungsservice, Motiv-Upload)
und die Produkt-Basispreise im Admin auf die echten Werte setzen.
- **Preisformel:** `pricing_settings.formula` ist ein Platzhalter – hier die reale Formel hinterlegen.
> Hinweis: Das Skript zielt auf Directus 11 (Policy-Rechtemodell) und wurde nicht gegen
> eine Live-Instanz getestet. Falls beim Lauf ein Schritt fehlschlägt, die Konsolenausgabe
> schicken – dann wird die betroffene Stelle gezielt korrigiert.

View File

@@ -0,0 +1,473 @@
/**
* Skrift – Directus Bootstrap
* ---------------------------------------------------------------------------
* Legt Collections, Felder, Beziehungen, Rollen/Policies/Rechte und Seed-Daten
* idempotent an (mehrfach ausführbar – Vorhandenes wird übersprungen).
*
* Ausführen (Node >= 18, kein npm install nötig):
* DIRECTUS_URL=https://admin.skrift.de \
* DIRECTUS_TOKEN=<admin-static-token> node bootstrap.mjs
* ODER mit Login statt Token:
* DIRECTUS_URL=... ADMIN_EMAIL=... ADMIN_PASSWORD=... node bootstrap.mjs
*
* Flags (optional):
* SKIP_PERMISSIONS=1 nur Schema + Seed, ohne Rollen/Rechte
*
* Zielt auf Directus 11 (Policy-basiertes Rechtemodell). Da nicht gegen eine
* Live-Instanz getestet: bei Fehlern die Konsolenausgabe schicken, dann fixe ich
* die betroffene Stelle gezielt.
*/
const DIRECTUS_URL = (process.env.DIRECTUS_URL || '').replace(/\/$/, '');
let token = process.env.DIRECTUS_TOKEN || '';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const SKIP_PERMISSIONS = process.env.SKIP_PERMISSIONS === '1';
if (!DIRECTUS_URL) { console.error('DIRECTUS_URL fehlt.'); process.exit(1); }
// ── Erlaubter Zeichensatz: 1:1 aus skrift-job-manager (job-app.js) ──────────
// Basis-Klasse (ohne \n\r\t – die fügt das Frontend für mehrzeilige Felder an).
const CHAR_WHITELIST =
'\\x20-\\x5F\\x61-\\x7B\\x7D' +
'ÄÖÜäöüß' + // ÄÖÜäöüß
'¡£§«»¿' + // ¡£§«»¿
'‐–—‘’“”€'; // ‐ – — ' ' " " €
// ── HTTP-Helfer ─────────────────────────────────────────────────────────────
async function api(method, path, body) {
const res = await fetch(DIRECTUS_URL + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* non-json */ }
if (!res.ok) {
const msg = data?.errors?.[0]?.message || text || res.statusText;
const err = new Error(`${method} ${path} → ${res.status}: ${msg}`);
err.status = res.status;
throw err;
}
return data?.data ?? data;
}
async function login() {
if (token) return;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error('Weder DIRECTUS_TOKEN noch ADMIN_EMAIL/ADMIN_PASSWORD gesetzt.');
process.exit(1);
}
const res = await fetch(DIRECTUS_URL + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) { console.error('Login fehlgeschlagen:', await res.text()); process.exit(1); }
token = (await res.json()).data.access_token;
}
const log = (...a) => console.log(' ', ...a);
// ── Feld-DSL ────────────────────────────────────────────────────────────────
const ID = {
field: 'id', type: 'integer',
meta: { hidden: true, interface: 'input', readonly: true },
schema: { is_primary_key: true, has_auto_increment: true },
};
const f = (field, type, extra = {}) => ({
field, type,
meta: { interface: extra.interface, options: extra.options, note: extra.note,
required: extra.required || false, special: extra.special },
schema: { is_nullable: extra.nullable !== false, is_unique: extra.unique || false,
default_value: extra.default },
});
const dropdown = (field, choices, extra = {}) =>
f(field, 'string', { interface: 'select-dropdown',
options: { choices: choices.map((v) => ({ text: v, value: v })) }, ...extra });
const m2o = (field, related, extra = {}) =>
({ field, type: related === 'directus_users' || related === 'directus_files' ? 'uuid' : 'integer',
meta: { interface: 'select-dropdown-m2o', note: extra.note, special: null },
schema: { is_nullable: extra.nullable !== false }, __related: related, __onDelete: extra.onDelete || 'SET NULL' });
// ── Collections ─────────────────────────────────────────────────────────────
const collections = [
{ collection: 'customers', meta: { icon: 'person', note: 'Kunde – existiert auch ohne Login (Zuordnung per E-Mail).' }, fields: [
f('email', 'string', { interface: 'input', unique: true, required: true }),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('company', 'string', { interface: 'input' }),
f('phone', 'string', { interface: 'input' }),
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden' }),
m2o('linked_user', 'directus_users', { note: 'Verknüpftes Login-Konto (falls aktiviert).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
{ collection: 'products', meta: { icon: 'inventory_2', note: 'Produkte inkl. Kontakt-Produkte.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('name', 'string', { interface: 'input', required: true }),
f('description', 'text', { interface: 'input-multiline' }),
dropdown('type', ['letter', 'postcard', 'sample', 'contact'], { required: true }),
dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }),
f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
{ collection: 'formats', meta: { icon: 'aspect_ratio', note: 'Formate je Produkt (admin-erweiterbar).' }, fields: [
m2o('product', 'products', { onDelete: 'CASCADE' }),
f('key', 'string', { interface: 'input', required: true }),
f('name', 'string', { interface: 'input', required: true }),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ collection: 'motifs', meta: { icon: 'image', note: 'Motiv-Katalog für Postkarten.' }, fields: [
f('name', 'string', { interface: 'input', required: true }),
m2o('image', 'directus_files'),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
{ collection: 'price_items', meta: { icon: 'sell', note: 'Alle Options-/Zusatzpreise – hier editierbar.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('label', 'string', { interface: 'input', required: true }),
dropdown('category', ['versand', 'kuvert', 'beschriftung', 'zusatzleistung', 'zuschlag'], { required: true }),
f('price', 'float', { interface: 'input', required: true }),
dropdown('unit', ['einmalig', 'pro_stueck', 'prozent'], { default: 'einmalig' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ collection: 'pricing_settings', meta: { icon: 'calculate', singleton: true, note: 'Formel + globale Werte.' }, fields: [
f('formula', 'text', { interface: 'input-multiline', note: 'Preisformel (editierbar).' }),
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
f('paypal_limit_net', 'float', { interface: 'input', default: 200, note: 'Ab diesem Netto-Wert nur Rechnung.' }),
f('char_whitelist', 'text', { interface: 'input-multiline', note: 'Erlaubte Zeichen (Regex-Klasse, 1:1 aus job-manager).' }),
f('currency', 'string', { interface: 'input', default: 'EUR' }),
] },
{ collection: 'vouchers', meta: { icon: 'confirmation_number', note: 'Gutscheine.' }, fields: [
f('code', 'string', { interface: 'input', unique: true, required: true }),
dropdown('type', ['prozent', 'fest'], { required: true }),
f('value', 'float', { interface: 'input', required: true }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('valid_until', 'timestamp', { interface: 'datetime' }),
f('max_uses', 'integer', { interface: 'input' }),
f('used_count', 'integer', { interface: 'input', default: 0 }),
] },
{ collection: 'orders', meta: { icon: 'receipt_long', note: 'Auftrag.' }, fields: [
f('order_number', 'string', { interface: 'input', unique: true, required: true }),
m2o('customer', 'customers'),
f('customer_email', 'string', { interface: 'input', required: true, note: 'Zuordnungsschlüssel.' }),
m2o('product', 'products'),
m2o('format', 'formats'),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
dropdown('font', ['tilda', 'alva', 'ellie']),
dropdown('source', ['configurator', 'portal', 'operator'], { default: 'configurator' }),
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
{ default: 'entwurf' }),
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
m2o('motif', 'motifs'),
m2o('motif_upload', 'directus_files'),
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
f('entries_count', 'integer', { interface: 'input', default: 0 }),
f('net_total', 'float', { interface: 'input' }),
f('vat_amount', 'float', { interface: 'input' }),
f('gross_total', 'float', { interface: 'input' }),
m2o('voucher', 'vouchers'),
dropdown('payment_method', ['paypal', 'rechnung']),
dropdown('payment_status', ['offen', 'bezahlt', 'storniert'], { default: 'offen' }),
f('payment_ref', 'string', { interface: 'input' }),
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
// m2m-Alias zu price_items über order_addons:
{ field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null },
] },
{ collection: 'order_entries', meta: { icon: 'table_rows', note: 'Empfängerzeilen (Tabelle/Excel).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('letter_number', 'integer', { interface: 'input', note: 'Briefnummer – systemvergeben, immer gesetzt.' }),
f('salutation', 'string', { interface: 'input' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('street', 'string', { interface: 'input' }),
f('house_no', 'string', { interface: 'input' }),
f('zip', 'string', { interface: 'input' }),
f('city', 'string', { interface: 'input' }),
f('country', 'string', { interface: 'input' }),
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
] },
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
m2o('price_item', 'price_items', { onDelete: 'CASCADE' }),
] },
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Queue – wird später verfeinert).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
f('priority', 'integer', { interface: 'input', default: 0 }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('status', 'string', { interface: 'input' }),
f('note', 'text', { interface: 'input-multiline' }),
m2o('changed_by', 'directus_users'),
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Einmalcodes für den passwortlosen Login (nur die Extension greift darauf zu).
{ collection: 'login_codes', meta: { icon: 'key', hidden: true, note: 'Einmalcodes für den passwortlosen Login (nur Hashes).' }, fields: [
f('email', 'string', { interface: 'input', required: true }),
f('code_hash', 'string', { interface: 'input', required: true }),
f('expires_at', 'timestamp', { interface: 'datetime', required: true }),
f('attempts', 'integer', { interface: 'input', default: 0 }),
f('used', 'boolean', { interface: 'boolean', default: false }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
];
// ── Schema anlegen ──────────────────────────────────────────────────────────
async function applySchema() {
console.log('› Collections & Felder');
const existingCols = new Set((await api('GET', '/collections')).map((c) => c.collection));
const allFields = await api('GET', '/fields');
const existingFields = new Set(allFields.map((x) => `${x.collection}.${x.field}`));
const existingRels = new Set((await api('GET', '/relations')).map((r) => `${r.collection}.${r.field}`));
const relationsToCreate = [];
for (const c of collections) {
if (!existingCols.has(c.collection)) {
await api('POST', '/collections', { collection: c.collection, meta: c.meta, schema: {}, fields: [ID] });
log('collection +', c.collection);
} else { log('collection ok', c.collection); }
for (const fld of c.fields) {
const { __related, __onDelete, ...clean } = fld;
if (!existingFields.has(`${c.collection}.${fld.field}`)) {
await api('POST', `/fields/${c.collection}`, clean);
log('field +', `${c.collection}.${fld.field}`);
}
if (__related) relationsToCreate.push({ collection: c.collection, field: fld.field, related: __related, onDelete: __onDelete });
}
}
console.log('› Beziehungen (m2o)');
for (const r of relationsToCreate) {
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
await api('POST', '/relations', {
collection: r.collection, field: r.field, related_collection: r.related,
schema: { on_delete: r.onDelete }, meta: {},
});
log('relation +', `${r.collection}.${r.field} → ${r.related}`);
}
// m2m-Verdrahtung orders.addons ↔ price_items über order_addons
console.log('› m2m orders.addons');
try {
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
log('m2m verdrahtet');
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
}
// ── Seed-Daten ──────────────────────────────────────────────────────────────
async function seedItem(collection, uniqueField, item) {
const existing = await api('GET', `/items/${collection}?filter[${uniqueField}][_eq]=${encodeURIComponent(item[uniqueField])}&limit=1`);
if (existing && existing.length) return existing[0];
const created = await api('POST', `/items/${collection}`, item);
log('seed +', `${collection}.${item[uniqueField]}`);
return created;
}
async function applySeed() {
console.log('› Seed');
const products = [
{ key: 'briefe', name: 'Briefe', type: 'letter', pricing_mode: 'formel', base_price: 4.0, active: true, sort: 1 },
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', pricing_mode: 'formel', base_price: 1.3, active: true, sort: 2 },
{ key: 'muster', name: 'Muster', type: 'sample', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 4 },
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
];
const productByKey = {};
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
const formats = [
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true },
{ product: productByKey.postkarten.id, key: 'a6_hoch', name: 'A6 Hochformat', active: true },
{ product: productByKey.postkarten.id, key: 'a6_quer', name: 'A6 Querformat', active: true },
];
for (const fmt of formats) await seedItem('formats', 'key', fmt);
// Preise: kundenseitige Werte aus dem Konfigurator; 0 = im Admin setzen.
const priceItems = [
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
{ key: 'gestaltungsservice', label: 'Gestaltungsservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
{ key: 'motiv_upload', label: 'Motiv-Upload', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
];
for (const pi of priceItems) await seedItem('price_items', 'key', pi);
// pricing_settings (Singleton)
const ps = await api('GET', '/items/pricing_settings');
if (!ps || !ps.formula) {
await api('PATCH', '/items/pricing_settings', {
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
});
log('seed singleton pricing_settings');
}
}
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
const BIZ = ['customers', 'products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history'];
const OWN_ORDER = { customer: { linked_user: { _eq: '$CURRENT_USER' } } };
const OWN_VIA_ORDER = { order: { customer: { linked_user: { _eq: '$CURRENT_USER' } } } };
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
async function renameRole(oldName, newName) {
const neu = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(newName)}&limit=1`);
if (neu && neu.length) return;
const alt = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(oldName)}&limit=1`);
if (alt && alt.length) {
await api('PATCH', `/roles/${alt[0].id}`, { name: newName });
log('role umbenannt', `${oldName} → ${newName}`);
}
}
async function ensureRole(name, icon) {
const roles = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
if (roles && roles.length) return roles[0];
const r = await api('POST', '/roles', { name, icon });
log('role +', name);
return r;
}
async function ensurePolicy(name, opts = {}) {
const ps = await api('GET', `/policies?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
if (ps && ps.length) return ps[0];
const p = await api('POST', '/policies', { name, app_access: !!opts.app, admin_access: !!opts.admin, enforce_tfa: false });
log('policy +', name);
return p;
}
async function ensureAccess(roleId, policyId) {
const filt = roleId
? `filter[role][_eq]=${roleId}&filter[policy][_eq]=${policyId}`
: `filter[role][_null]=true&filter[policy][_eq]=${policyId}`;
const a = await api('GET', `/access?${filt}&limit=1`);
if (a && a.length) return;
await api('POST', '/access', { role: roleId, policy: policyId });
log('access +', `${roleId || 'public'} ↔ policy`);
}
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
const existing = await api('GET',
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1`);
if (existing && existing.length) return;
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
}
async function applyPermissions() {
console.log('› Rollen, Policies, Rechte');
// Staff: volle Verwaltung
const staffRole = await ensureRole('Staff', 'badge');
const staffPol = await ensurePolicy('Staff', { app: true });
await ensureAccess(staffRole.id, staffPol.id);
for (const col of BIZ)
for (const act of ['create', 'read', 'update', 'delete'])
await ensurePermission(staffPol.id, col, act);
// Customer: nur Eigenes + Lesen der Kataloge
// (Alt-Name "Customer" umbenennen, damit er sich von der Collection "Kunden" unterscheidet.)
await renameRole('Customer', CUSTOMER_ROLE_NAME);
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
const custPol = await ensurePolicy('Customer', { app: false });
await ensureAccess(custRole.id, custPol.id);
await ensurePermission(custPol.id, 'customers', 'read', ['*'], { linked_user: { _eq: '$CURRENT_USER' } });
await ensurePermission(custPol.id, 'customers', 'update', ['first_name', 'last_name', 'company', 'phone', 'addresses'], { linked_user: { _eq: '$CURRENT_USER' } });
for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER);
for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
await ensurePermission(custPol.id, col, 'read');
// Public (Gast): nur aktive Kataloge lesen
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
await ensureAccess(null, pubPol.id);
for (const col of ['products', 'formats', 'motifs', 'price_items'])
await ensurePermission(pubPol.id, col, 'read', ['*'], { active: { _eq: true } });
await ensurePermission(pubPol.id, 'pricing_settings', 'read');
// Service (Backend + Produktion): Jobs/Orders lesen + Status schreiben
const svcRole = await ensureRole('Service', 'smart_toy');
const svcPol = await ensurePolicy('Service', { app: false });
await ensureAccess(svcRole.id, svcPol.id);
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'customers', 'products', 'formats', 'motifs', 'price_items'])
await ensurePermission(svcPol.id, col, 'read');
await ensurePermission(svcPol.id, 'orders', 'update', ['status', 'artifact_path', 'payment_status', 'payment_ref']);
await ensurePermission(svcPol.id, 'jobs', 'create');
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority']);
await ensurePermission(svcPol.id, 'status_history', 'create');
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
'statischen Token in .env von Backend/Produktion eintragen.');
}
// ── Anzeigenamen (Klarheit im Admin) ────────────────────────────────────────
async function applyLabels() {
console.log('› Anzeigenamen');
const labels = {
customers: { de: ['Kunde', 'Kunden'], en: ['Customer record', 'Customer records'] },
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
products: { de: ['Produkt', 'Produkte'], en: ['Product', 'Products'] },
motifs: { de: ['Motiv', 'Motive'], en: ['Motif', 'Motifs'] },
vouchers: { de: ['Gutschein', 'Gutscheine'], en: ['Voucher', 'Vouchers'] },
};
for (const [collection, l] of Object.entries(labels)) {
try {
await api('PATCH', `/collections/${collection}`, {
meta: { translations: [
{ language: 'de-DE', translation: l.de[1], singular: l.de[0], plural: l.de[1] },
{ language: 'en-US', translation: l.en[1], singular: l.en[0], plural: l.en[1] },
] },
});
} catch (e) { console.warn(` Label ${collection} übersprungen: ${e.message}`); }
}
}
// ── Main ────────────────────────────────────────────────────────────────────
(async () => {
await login();
console.log(`Bootstrap gegen ${DIRECTUS_URL}`);
await applySchema();
await applySeed();
await applyLabels();
if (SKIP_PERMISSIONS) console.log('› Rechte übersprungen (SKIP_PERMISSIONS=1)');
else await applyPermissions();
console.log('✓ Fertig.');
})().catch((e) => { console.error('\n✗ Abbruch:', e.message); process.exit(1); });

View File

@@ -0,0 +1,13 @@
{
"name": "skrift-directus-bootstrap",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Idempotentes Anlegen von Collections, Rollen, Rechten und Seed-Daten in Directus.",
"scripts": {
"bootstrap": "node bootstrap.mjs"
},
"engines": {
"node": ">=18"
}
}

View File

@@ -0,0 +1,85 @@
# Directus + Postgres — Daten-/Auth-/Admin-Schicht für Skrift.
#
# Hinter dem Nginx Proxy Manager (kein Host-Port; NPM proxyt intern per container_name).
# Im NPM als Proxy Host anlegen:
# Domain Names : admin.skrift.de (PUBLIC_URL in .env muss dazu passen)
# Scheme : http
# Forward Hostname : skrift-directus
# Forward Port : 8055
# SSL : Let's-Encrypt-Zertifikat aktivieren
#
# Start: cp .env.example .env → Werte setzen → docker compose up -d
services:
database:
image: postgres:16-alpine
container_name: skrift-directus-db
restart: unless-stopped
volumes:
- directus-db:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_DATABASE}
healthcheck:
test: ["CMD", "pg_isready", "-U", "${DB_USER}", "-d", "${DB_DATABASE}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
directus:
image: directus/directus:11
container_name: skrift-directus
restart: unless-stopped
depends_on:
database:
condition: service_healthy
volumes:
- directus-uploads:/directus/uploads
# Bind-Mount: Extensions kommen aus dem Repo-Ordner ./extensions
- ./extensions:/directus/extensions
environment:
SECRET: ${DIRECTUS_SECRET}
DB_CLIENT: pg
DB_HOST: database
DB_PORT: 5432
DB_DATABASE: ${DB_DATABASE}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
# Erster Admin – wird nur beim allerersten Start angelegt
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
PUBLIC_URL: ${PUBLIC_URL}
# CORS für das Frontend (Portal/Konfigurator)
CORS_ENABLED: "true"
CORS_ORIGIN: ${CORS_ORIGIN}
# E-Mail: für passwortlosen Login-Code + Benachrichtigungen (Flows)
EMAIL_FROM: ${EMAIL_FROM}
EMAIL_TRANSPORT: smtp
EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST}
EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT}
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER}
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
EMAIL_SMTP_SECURE: ${EMAIL_SMTP_SECURE}
expose:
- "8055"
networks:
- internal
- npm
volumes:
directus-db:
directus-uploads:
networks:
internal:
npm:
name: nginx-proxy-manager_default
external: true

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

View File

@@ -19,3 +19,8 @@ PORT=4000
# API Authentication (optional)
API_TOKEN=your-api-token-here
# Directus (Datenquelle für Aufträge)
# Token = statischer Token eines Nutzers in der Rolle "Service"
DIRECTUS_URL=https://admin.skrift.de
DIRECTUS_TOKEN=your-directus-service-token

View File

@@ -15,12 +15,16 @@ services:
- BATCH_SIZE=${BATCH_SIZE:-30}
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
- API_TOKEN=${API_TOKEN}
- DIRECTUS_URL=${DIRECTUS_URL}
- DIRECTUS_TOKEN=${DIRECTUS_TOKEN}
volumes:
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
- skrift-cache:/app/cache # Preview cache (temporary)
- /var/skrift-output:/app/output # Output files for N8N
networks:
- skrift-network
- npm
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
interval: 30s
@@ -35,3 +39,8 @@ volumes:
networks:
skrift-network:
driver: bridge
# Netz des Nginx Proxy Managers – damit NPM den Container per
# container_name (skrift-backend:4000) erreichen kann.
npm:
name: nginx-proxy-manager_default
external: true

View File

@@ -0,0 +1,158 @@
/**
* Generierung auf Basis eines Directus-Auftrags.
*
* Ablauf: Directus meldet "Auftrag X ist bezahlt" → dieser Endpunkt holt den
* Auftrag samt Empfängerzeilen aus Directus, baut daraus die Dokumentliste für
* die bestehende Pipeline (runGeneration) und schreibt Ergebnis + Status zurück.
*
* Antwortet sofort mit 202 – die Generierung läuft im Hintergrund weiter, weil
* sie je nach Menge deutlich länger dauern kann als ein HTTP-Timeout erlaubt.
*/
const { runGeneration } = require('./order-controller');
const directus = require('../../services/directus-service');
// Directus-Formatschlüssel → Backend-Formate
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
const ENVELOPE_FORMAT = { c6: 'c6', dinlang: 'din_lang' };
/** Baut den Adressblock für einen Umschlag aus einer Empfängerzeile. */
function addressText(entry) {
if (entry.free_text) return String(entry.free_text).trim();
const name = [entry.first_name, entry.last_name].filter(Boolean).join(' ');
const street = [entry.street, entry.house_no].filter(Boolean).join(' ');
const city = [entry.zip, entry.city].filter(Boolean).join(' ');
const isForeign = entry.country && !/^(de|deutschland|germany)$/i.test(String(entry.country).trim());
return [name, street, city, isForeign ? entry.country : null]
.filter(Boolean).join('\n');
}
/** Platzhalterwerte einer Zeile (strukturierte Felder + freie Platzhalter). */
function placeholdersOf(entry) {
return {
Anrede: entry.salutation || '',
Vorname: entry.first_name || '',
Nachname: entry.last_name || '',
Strasse: [entry.street, entry.house_no].filter(Boolean).join(' '),
PLZ: entry.zip || '',
Ort: entry.city || '',
Land: entry.country || '',
Briefnummer: entry.letter_number ?? '',
...(entry.placeholders && typeof entry.placeholders === 'object' ? entry.placeholders : {}),
};
}
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
function buildDocuments(order, entries) {
const font = order.font || 'tilda';
const letterFormat = LETTER_FORMAT[order.format?.key] || 'a4';
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
const documents = [];
entries.forEach((entry, i) => {
const placeholders = placeholdersOf(entry);
documents.push({
index: i,
type: 'letter',
text: order.text_template || '',
format: letterFormat,
font,
placeholders,
});
if (wantsEnvelope) {
documents.push({
index: i,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
format: envFormat,
font,
placeholders,
});
}
});
return { documents, letterFormat, envFormat, wantsEnvelope };
}
/**
* POST /api/order/from-directus
* Body: { orderId }
*/
async function generateFromDirectus(req, res, next) {
const { orderId } = req.body || {};
if (!orderId) {
return res.status(400).json({ error: 'Invalid request', message: 'orderId is required' });
}
let order;
try {
const loaded = await directus.fetchOrder(orderId);
order = loaded.order;
if (!loaded.entries.length) {
return res.status(400).json({
error: 'Invalid order',
message: `Auftrag ${order.order_number} hat keine Empfängerzeilen.`
});
}
// Sofort quittieren – der Rest läuft im Hintergrund.
res.status(202).json({
accepted: true,
orderId,
orderNumber: order.order_number,
documents: loaded.entries.length * (order.needs_envelope ? 2 : 1),
});
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
const result = await runGeneration({
orderNumber: order.order_number,
letters: documents,
metadata: {
letterFormat,
envelopeFormat: wantsEnvelope ? envFormat : null,
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
directusOrderId: orderId,
customerEmail: order.customer_email,
},
});
await directus.updateOrder(orderId, {
status: 'in_queue',
artifact_path: result.outputPath,
});
await directus.addStatusHistory(orderId, 'in_queue',
`${result.letters.length} Schriftstücke, ${result.envelopes.length} Umschläge generiert.`);
console.log(`[Directus] ${order.order_number} fertig → in_queue`);
} catch (err) {
console.error('[Directus] Generierung fehlgeschlagen:', err.message);
// Fehler in Directus sichtbar machen, statt still zu scheitern.
if (order?.id || orderId) {
try {
await directus.addStatusHistory(orderId, 'fehler', `Generierung fehlgeschlagen: ${err.message}`);
} catch (e) {
console.error('[Directus] Konnte Fehler nicht zurückschreiben:', e.message);
}
}
// Antwort wurde ggf. schon gesendet (202) – dann nur noch loggen.
if (!res.headersSent) {
if (err.status) return res.status(err.status).json({ error: 'Generation failed', message: err.message });
return next(err);
}
}
}
module.exports = { generateFromDirectus };

View File

@@ -138,23 +138,19 @@ async function finalizeOrder(req, res, next) {
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters } = req.body;
async function runGeneration({ orderNumber, letters, metadata }) {
{
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
const e = new Error('orderNumber is required');
e.status = 400;
throw e;
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Letters array is required and must not be empty'
});
const e = new Error('Letters array is required and must not be empty');
e.status = 400;
throw e;
}
console.log(`[Order] Generating order: ${orderNumber}`);
@@ -255,10 +251,10 @@ async function generateOrder(req, res, next) {
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
return res.status(500).json({
error: 'Scriptalization failed',
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
});
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
e.status = 500;
e.label = 'Scriptalization failed';
throw e;
}
}
@@ -290,10 +286,10 @@ async function generateOrder(req, res, next) {
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: 'SVG generation failed',
message: `Failed to generate SVG for letter ${i}: ${err.message}`
});
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500;
e.label = 'SVG generation failed';
throw e;
}
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
@@ -352,7 +348,7 @@ async function generateOrder(req, res, next) {
}
// Step 6: Create order metadata
const meta = req.body.metadata || {};
const meta = metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
@@ -373,8 +369,7 @@ async function generateOrder(req, res, next) {
console.log(`[Order] Order ${orderNumber} generated successfully`);
// Response
res.status(200).json({
return {
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
@@ -382,9 +377,26 @@ async function generateOrder(req, res, next) {
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
});
};
}
}
/**
* POST /api/order/generate
* HTTP-Wrapper um runGeneration – Verhalten unverändert.
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters, metadata } = req.body;
const result = await runGeneration({ orderNumber, letters, metadata });
res.status(200).json(result);
} catch (err) {
if (err.status) {
return res.status(err.status).json({
error: err.label || 'Invalid request',
message: err.message
});
}
console.error('[Order] Error generating order:', err);
next(err);
}
@@ -535,6 +547,7 @@ async function registerOrder(req, res, next) {
module.exports = {
finalizeOrder,
generateOrder,
runGeneration,
uploadMotif,
uploadOrderFile,
registerOrder,

View File

@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const multer = require('multer');
const orderController = require('../controllers/order-controller');
const directusController = require('../controllers/directus-controller');
const { authenticateApiToken } = require('../middleware/auth');
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
@@ -132,4 +133,15 @@ router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orde
*/
router.post('/register', authenticateApiToken, orderController.registerOrder);
/**
* POST /api/order/from-directus
* Generiert einen Auftrag, dessen Daten in Directus liegen.
*
* Request body: { orderId: string|number } (ID des Directus-Auftrags)
*
* Antwortet mit 202 (accepted); die Generierung läuft im Hintergrund und
* schreibt Status + artifact_path selbstständig nach Directus zurück.
*/
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
module.exports = router;

View File

@@ -44,6 +44,12 @@ module.exports = {
apiToken: process.env.API_TOKEN || null
},
// Directus ist die Datenquelle für Aufträge (Rolle "Service"-Token).
directus: {
url: process.env.DIRECTUS_URL || '',
token: process.env.DIRECTUS_TOKEN || ''
},
paypal: {
clientId: process.env.PAYPAL_CLIENT_ID || '',
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',

View File

@@ -0,0 +1,80 @@
/**
* Directus-Client für das Backend.
*
* Directus ist die Datenquelle für Aufträge; dieses Backend bleibt die reine
* Generierungs-Engine. Zugriff über einen Service-Token (Rolle "Service").
*/
const config = require('../config');
function requireConfig() {
if (!config.directus.url || !config.directus.token) {
const e = new Error('DIRECTUS_URL und DIRECTUS_TOKEN müssen gesetzt sein.');
e.status = 500;
throw e;
}
}
async function directusFetch(path, options = {}) {
requireConfig();
const res = await fetch(config.directus.url.replace(/\/$/, '') + path, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.directus.token}`,
...(options.headers || {}),
},
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg = data?.errors?.[0]?.message || text || res.statusText;
const e = new Error(`Directus ${options.method || 'GET'} ${path}: ${msg}`);
e.status = res.status === 404 ? 404 : 502;
throw e;
}
return data?.data ?? data;
}
/** Lädt einen Auftrag inkl. Empfängerzeilen, Produkt und Format. */
async function fetchOrder(orderId) {
const fields = [
'id', 'order_number', 'customer_email', 'font', 'status',
'needs_envelope', 'envelope_labeling', 'envelope_format',
'text_template', 'motif_mode', 'own_cards', 'entries_count',
'product.key', 'product.type', 'format.key',
].join(',');
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
if (!order) {
const e = new Error(`Auftrag ${orderId} nicht gefunden.`);
e.status = 404;
throw e;
}
const entries = await directusFetch(
`/items/order_entries?filter[order][_eq]=${encodeURIComponent(orderId)}` +
`&sort=letter_number&limit=-1&fields=*`
);
return { order, entries: entries || [] };
}
/** Schreibt Status und Artefakt-Pfad zurück. */
async function updateOrder(orderId, patch) {
return directusFetch(`/items/orders/${encodeURIComponent(orderId)}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
/** Protokolliert einen Statuswechsel in der Timeline. */
async function addStatusHistory(orderId, status, note) {
return directusFetch('/items/status_history', {
method: 'POST',
body: JSON.stringify({ order: orderId, status, note }),
});
}
module.exports = { fetchOrder, updateOrder, addStatusHistory };