diff --git a/.gitignore b/.gitignore index 6c8208e..a86bcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ Produktion/skrift-produktion/temp_signature/ build/ dist/ *.spec +# Ausnahme: Directus-Extensions werden ohne Build-Schritt gepflegt – +# ihr dist/ IST der Quellcode und muss ins Repo. +!Docker/directus/extensions/*/dist/ # ── OS / Editor ───────────────────────────────────────────── .DS_Store diff --git a/Docker/directus/.env.example b/Docker/directus/.env.example new file mode 100644 index 0000000..d99bc46 --- /dev/null +++ b/Docker/directus/.env.example @@ -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 diff --git a/Docker/directus/bootstrap/README.md b/Docker/directus/bootstrap/README.md new file mode 100644 index 0000000..91766d6 --- /dev/null +++ b/Docker/directus/bootstrap/README.md @@ -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= \ +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. diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs new file mode 100644 index 0000000..3618152 --- /dev/null +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -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= 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); }); diff --git a/Docker/directus/bootstrap/package.json b/Docker/directus/bootstrap/package.json new file mode 100644 index 0000000..ead33e1 --- /dev/null +++ b/Docker/directus/bootstrap/package.json @@ -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" + } +} diff --git a/Docker/directus/docker-compose.yml b/Docker/directus/docker-compose.yml new file mode 100644 index 0000000..f8e2a4b --- /dev/null +++ b/Docker/directus/docker-compose.yml @@ -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 diff --git a/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js new file mode 100644 index 0000000..056832d --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js @@ -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: ` +

Guten Tag,

+

Ihr Anmeldecode für Skrift lautet:

+

${code}

+

Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung + angefordert haben, können Sie diese E-Mail ignorieren.

+ `, + }); + } + + // ── 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 }; diff --git a/Docker/directus/extensions/directus-extension-skrift-auth/package.json b/Docker/directus/extensions/directus-extension-skrift-auth/package.json new file mode 100644 index 0000000..5db5a94 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-auth/package.json @@ -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" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js new file mode 100644 index 0000000..7e910ca --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -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 }; diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/package.json b/Docker/directus/extensions/directus-extension-skrift-orders/package.json new file mode 100644 index 0000000..4a7b983 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orders/package.json @@ -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" + } +} diff --git a/Docker/skrift-backend/.env.example b/Docker/skrift-backend/.env.example index 1aa28b3..eb94d34 100644 --- a/Docker/skrift-backend/.env.example +++ b/Docker/skrift-backend/.env.example @@ -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 diff --git a/Docker/skrift-backend/docker-compose.yml b/Docker/skrift-backend/docker-compose.yml index 6dec2fd..acfc85b 100644 --- a/Docker/skrift-backend/docker-compose.yml +++ b/Docker/skrift-backend/docker-compose.yml @@ -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 diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js new file mode 100644 index 0000000..f61ac94 --- /dev/null +++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js @@ -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 }; diff --git a/Docker/skrift-backend/src/api/controllers/order-controller.js b/Docker/skrift-backend/src/api/controllers/order-controller.js index 0fdffe8..b28b518 100644 --- a/Docker/skrift-backend/src/api/controllers/order-controller.js +++ b/Docker/skrift-backend/src/api/controllers/order-controller.js @@ -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, diff --git a/Docker/skrift-backend/src/api/routes/order-routes.js b/Docker/skrift-backend/src/api/routes/order-routes.js index 87af099..ea0d7f0 100644 --- a/Docker/skrift-backend/src/api/routes/order-routes.js +++ b/Docker/skrift-backend/src/api/routes/order-routes.js @@ -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; diff --git a/Docker/skrift-backend/src/config/index.js b/Docker/skrift-backend/src/config/index.js index 717554e..1d67b96 100644 --- a/Docker/skrift-backend/src/config/index.js +++ b/Docker/skrift-backend/src/config/index.js @@ -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 || '', diff --git a/Docker/skrift-backend/src/services/directus-service.js b/Docker/skrift-backend/src/services/directus-service.js new file mode 100644 index 0000000..984fecc --- /dev/null +++ b/Docker/skrift-backend/src/services/directus-service.js @@ -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 };