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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ Produktion/skrift-produktion/temp_signature/
|
|||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
*.spec
|
*.spec
|
||||||
|
# Ausnahme: Directus-Extensions werden ohne Build-Schritt gepflegt –
|
||||||
|
# ihr dist/ IST der Quellcode und muss ins Repo.
|
||||||
|
!Docker/directus/extensions/*/dist/
|
||||||
|
|
||||||
# ── OS / Editor ─────────────────────────────────────────────
|
# ── OS / Editor ─────────────────────────────────────────────
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
33
Docker/directus/.env.example
Normal file
33
Docker/directus/.env.example
Normal 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
|
||||||
52
Docker/directus/bootstrap/README.md
Normal file
52
Docker/directus/bootstrap/README.md
Normal 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.
|
||||||
473
Docker/directus/bootstrap/bootstrap.mjs
Normal file
473
Docker/directus/bootstrap/bootstrap.mjs
Normal 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); });
|
||||||
13
Docker/directus/bootstrap/package.json
Normal file
13
Docker/directus/bootstrap/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Docker/directus/docker-compose.yml
Normal file
85
Docker/directus/docker-compose.yml
Normal 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
|
||||||
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
230
Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Passwortloser Login per E-Mail-Code
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Directus kennt ab Werk nur Passwort/SSO. Diese Endpoint-Extension ergänzt den
|
||||||
|
* Kunden-Login per Einmalcode:
|
||||||
|
*
|
||||||
|
* POST /skrift-auth/request-code { email } → Code per Mail
|
||||||
|
* POST /skrift-auth/verify-code { email, code } → Tokens
|
||||||
|
* POST /skrift-auth/register { email, ... } → Konto anlegen + Code
|
||||||
|
*
|
||||||
|
* Sicherheitsprinzipien:
|
||||||
|
* - `request-code` antwortet IMMER generisch (verrät nicht, ob es das Konto gibt).
|
||||||
|
* - Codes werden nur als Hash gespeichert (SHA-256 + SECRET als Pepper).
|
||||||
|
* - Ablauf nach 10 Minuten, max. 5 Fehlversuche, 60 s Wiederhol-Sperre.
|
||||||
|
*
|
||||||
|
* Token-Ausstellung: Der Code-Login setzt für den Nutzer ein frisches Zufalls-
|
||||||
|
* passwort und meldet ihn damit über Directus' offiziellen AuthenticationService
|
||||||
|
* an. So sind Session/Refresh-Handling garantiert korrekt (statt selbstgebauter
|
||||||
|
* JWTs) – das Passwort kennt niemand und rotiert bei jedem Login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const CODE_TTL_MIN = 10;
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const RESEND_COOLDOWN_SEC = 60;
|
||||||
|
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
|
||||||
|
|
||||||
|
const handler = (router, { services, getSchema, env, logger }) => {
|
||||||
|
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
||||||
|
|
||||||
|
const norm = (e) => String(e || '').trim().toLowerCase();
|
||||||
|
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
|
const hashCode = (code, email) =>
|
||||||
|
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
|
||||||
|
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||||
|
|
||||||
|
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
||||||
|
|
||||||
|
async function findUserByEmail(email, schema) {
|
||||||
|
const users = new UsersService({ schema, accountability: null });
|
||||||
|
const found = await users.readByQuery({
|
||||||
|
filter: { email: { _eq: email } },
|
||||||
|
fields: ['id', 'email', 'status'],
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return found?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCodeMail(email, code) {
|
||||||
|
const mail = new MailService({ schema: await getSchema(), accountability: null });
|
||||||
|
await mail.send({
|
||||||
|
to: email,
|
||||||
|
subject: `Ihr Anmeldecode: ${code}`,
|
||||||
|
html: `
|
||||||
|
<p>Guten Tag,</p>
|
||||||
|
<p>Ihr Anmeldecode für Skrift lautet:</p>
|
||||||
|
<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>
|
||||||
|
<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung
|
||||||
|
angefordert haben, können Sie diese E-Mail ignorieren.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Code anfordern ────────────────────────────────────────────────────────
|
||||||
|
router.post('/request-code', async (req, res) => {
|
||||||
|
const email = norm(req.body?.email);
|
||||||
|
// Antwort ist bewusst immer gleich – keine Auskunft über Kontoexistenz.
|
||||||
|
const generic = () => res.json({ ok: true });
|
||||||
|
|
||||||
|
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { schema } = await ctx();
|
||||||
|
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||||
|
|
||||||
|
// Wiederhol-Sperre
|
||||||
|
const recent = await codes.readByQuery({
|
||||||
|
filter: { email: { _eq: email } },
|
||||||
|
sort: ['-date_created'], limit: 1, fields: ['date_created'],
|
||||||
|
});
|
||||||
|
if (recent?.[0]?.date_created) {
|
||||||
|
const age = (Date.now() - new Date(recent[0].date_created).getTime()) / 1000;
|
||||||
|
if (age < RESEND_COOLDOWN_SEC) return generic();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserByEmail(email, schema);
|
||||||
|
if (!user || user.status !== 'active') return generic();
|
||||||
|
|
||||||
|
const code = genCode();
|
||||||
|
await codes.createOne({
|
||||||
|
email,
|
||||||
|
code_hash: hashCode(code, email),
|
||||||
|
expires_at: new Date(Date.now() + CODE_TTL_MIN * 60_000).toISOString(),
|
||||||
|
attempts: 0,
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
await sendCodeMail(email, code);
|
||||||
|
return generic();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-auth] request-code: ${err.message}`);
|
||||||
|
return generic();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Code prüfen + anmelden ────────────────────────────────────────────────
|
||||||
|
router.post('/verify-code', async (req, res) => {
|
||||||
|
const email = norm(req.body?.email);
|
||||||
|
const code = String(req.body?.code || '').trim();
|
||||||
|
if (!isEmail(email) || !/^\d{6}$/.test(code))
|
||||||
|
return res.status(400).json({ error: 'E-Mail oder Code ungültig.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { schema } = await ctx();
|
||||||
|
const codes = new ItemsService('login_codes', { schema, accountability: null });
|
||||||
|
|
||||||
|
const rows = await codes.readByQuery({
|
||||||
|
filter: { email: { _eq: email }, used: { _eq: false } },
|
||||||
|
sort: ['-date_created'], limit: 1,
|
||||||
|
fields: ['id', 'code_hash', 'expires_at', 'attempts'],
|
||||||
|
});
|
||||||
|
const row = rows?.[0];
|
||||||
|
if (!row) return res.status(401).json({ error: 'Code ungültig oder abgelaufen.' });
|
||||||
|
|
||||||
|
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||||
|
await codes.updateOne(row.id, { used: true });
|
||||||
|
return res.status(401).json({ error: 'Code abgelaufen. Bitte neu anfordern.' });
|
||||||
|
}
|
||||||
|
if ((row.attempts ?? 0) >= MAX_ATTEMPTS) {
|
||||||
|
await codes.updateOne(row.id, { used: true });
|
||||||
|
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte neuen Code anfordern.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = Buffer.from(row.code_hash, 'utf8');
|
||||||
|
const actual = Buffer.from(hashCode(code, email), 'utf8');
|
||||||
|
const ok = expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
||||||
|
if (!ok) {
|
||||||
|
await codes.updateOne(row.id, { attempts: (row.attempts ?? 0) + 1 });
|
||||||
|
return res.status(401).json({ error: 'Code ungültig.' });
|
||||||
|
}
|
||||||
|
await codes.updateOne(row.id, { used: true });
|
||||||
|
|
||||||
|
const user = await findUserByEmail(email, schema);
|
||||||
|
if (!user || user.status !== 'active')
|
||||||
|
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
|
||||||
|
|
||||||
|
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||||
|
const users = new UsersService({ schema, accountability: null });
|
||||||
|
const tempPassword = crypto.randomBytes(32).toString('base64url');
|
||||||
|
await users.updateOne(user.id, { password: tempPassword });
|
||||||
|
|
||||||
|
const auth = new AuthenticationService({ schema, accountability: null });
|
||||||
|
const result = await auth.login('default', { email, password: tempPassword });
|
||||||
|
|
||||||
|
// Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen.
|
||||||
|
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||||
|
const cust = await customers.readByQuery({
|
||||||
|
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'],
|
||||||
|
});
|
||||||
|
if (cust?.[0] && !cust[0].linked_user) {
|
||||||
|
await customers.updateOne(cust[0].id, { linked_user: user.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
access_token: result.accessToken ?? result.access_token,
|
||||||
|
refresh_token: result.refreshToken ?? result.refresh_token,
|
||||||
|
expires: result.expires,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-auth] verify-code: ${err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Konto anlegen (aus dem Checkout) ──────────────────────────────────────
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const email = norm(req.body?.email);
|
||||||
|
if (!isEmail(email)) return res.status(400).json({ error: 'Ungültige E-Mail-Adresse.' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { schema } = await ctx();
|
||||||
|
|
||||||
|
// Bereits vorhanden? Dann einfach Login-Code schicken (kein Fehler).
|
||||||
|
const existing = await findUserByEmail(email, schema);
|
||||||
|
if (existing) return res.json({ ok: true, existed: true });
|
||||||
|
|
||||||
|
const roles = new RolesService({ schema, accountability: null });
|
||||||
|
const role = (await roles.readByQuery({
|
||||||
|
filter: { name: { _eq: CUSTOMER_ROLE_NAME } }, limit: 1, fields: ['id'],
|
||||||
|
}))?.[0];
|
||||||
|
if (!role) {
|
||||||
|
logger.error(`[skrift-auth] Rolle "${CUSTOMER_ROLE_NAME}" nicht gefunden.`);
|
||||||
|
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = new UsersService({ schema, accountability: null });
|
||||||
|
const userId = await users.createOne({
|
||||||
|
email,
|
||||||
|
password: crypto.randomBytes(32).toString('base64url'),
|
||||||
|
role: role.id,
|
||||||
|
status: 'active',
|
||||||
|
first_name: req.body?.first_name ?? null,
|
||||||
|
last_name: req.body?.last_name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail).
|
||||||
|
const customers = new ItemsService('customers', { schema, accountability: null });
|
||||||
|
const cust = (await customers.readByQuery({
|
||||||
|
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||||
|
}))?.[0];
|
||||||
|
if (cust) await customers.updateOne(cust.id, { linked_user: userId });
|
||||||
|
else await customers.createOne({
|
||||||
|
email, linked_user: userId,
|
||||||
|
first_name: req.body?.first_name ?? null,
|
||||||
|
last_name: req.body?.last_name ?? null,
|
||||||
|
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
|
company: req.body?.company ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ ok: true, existed: false });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-auth] register: ${err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Explizite ID → Routen liegen garantiert unter /skrift-auth/*,
|
||||||
|
// unabhängig von Ordner- oder Paketnamen.
|
||||||
|
export default { id: 'skrift-auth', handler };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-auth",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Passwortloser Login per E-Mail-Code für Skrift-Kunden.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Bestell-Endpunkt
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt)
|
||||||
|
* POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben)
|
||||||
|
*
|
||||||
|
* Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer
|
||||||
|
* serverseitig aus products/price_items/pricing_settings neu berechnet.
|
||||||
|
*
|
||||||
|
* Preislogik:
|
||||||
|
* - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet
|
||||||
|
* von einem sicheren Mini-Interpreter (kein eval).
|
||||||
|
* - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im
|
||||||
|
* Code, Werte aus price_items.
|
||||||
|
* - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Sicherer Formel-Interpreter ─────────────────────────────────────────────
|
||||||
|
// Erlaubt: Zahlen, Variablen, + - * / ( ) und unäres Minus. Sonst nichts.
|
||||||
|
function evalFormula(expr, vars) {
|
||||||
|
const tokens = String(expr).match(/\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]/g);
|
||||||
|
if (!tokens) throw new Error('Formel ist leer oder ungültig.');
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
const peek = () => tokens[pos];
|
||||||
|
const next = () => tokens[pos++];
|
||||||
|
|
||||||
|
// Ausdruck := Term (('+'|'-') Term)*
|
||||||
|
function parseExpr() {
|
||||||
|
let left = parseTerm();
|
||||||
|
while (peek() === '+' || peek() === '-') {
|
||||||
|
const op = next();
|
||||||
|
const right = parseTerm();
|
||||||
|
left = op === '+' ? left + right : left - right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
// Term := Faktor (('*'|'/') Faktor)*
|
||||||
|
function parseTerm() {
|
||||||
|
let left = parseFactor();
|
||||||
|
while (peek() === '*' || peek() === '/') {
|
||||||
|
const op = next();
|
||||||
|
const right = parseFactor();
|
||||||
|
if (op === '/' && right === 0) throw new Error('Division durch 0 in der Preisformel.');
|
||||||
|
left = op === '*' ? left * right : left / right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
// Faktor := '-' Faktor | '(' Ausdruck ')' | Zahl | Variable
|
||||||
|
function parseFactor() {
|
||||||
|
const t = next();
|
||||||
|
if (t === undefined) throw new Error('Formel unvollständig.');
|
||||||
|
if (t === '-') return -parseFactor();
|
||||||
|
if (t === '(') {
|
||||||
|
const v = parseExpr();
|
||||||
|
if (next() !== ')') throw new Error('Fehlende schließende Klammer.');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (/^\d/.test(t)) return parseFloat(t);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
|
||||||
|
throw new Error(`Unbekannte Variable in der Preisformel: "${t}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseExpr();
|
||||||
|
if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.');
|
||||||
|
if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Betrag.');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
||||||
|
|
||||||
|
const handler = (router, { services, getSchema, logger }) => {
|
||||||
|
const { ItemsService } = services;
|
||||||
|
|
||||||
|
const norm = (e) => String(e || '').trim().toLowerCase();
|
||||||
|
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||||
|
|
||||||
|
/** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */
|
||||||
|
function makeSanitizer(whitelist) {
|
||||||
|
let re;
|
||||||
|
try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); }
|
||||||
|
catch { re = null; }
|
||||||
|
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPricingContext(schema) {
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
const [settingsRaw, priceItems] = await Promise.all([
|
||||||
|
svc('pricing_settings').readSingleton({}),
|
||||||
|
svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1,
|
||||||
|
fields: ['key', 'price', 'unit', 'category'] }),
|
||||||
|
]);
|
||||||
|
const settings = settingsRaw || {};
|
||||||
|
const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p]));
|
||||||
|
return { settings, priceItems: byKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den Preis vollständig serverseitig. */
|
||||||
|
async function calculatePrice(schema, input) {
|
||||||
|
const { settings, priceItems } = await loadPricingContext(schema);
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
|
||||||
|
const product = (await svc('products').readByQuery({
|
||||||
|
filter: { key: { _eq: input.product } }, limit: 1,
|
||||||
|
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'active'],
|
||||||
|
}))?.[0];
|
||||||
|
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||||||
|
if (product.pricing_mode === 'auf_anfrage')
|
||||||
|
throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`);
|
||||||
|
|
||||||
|
const menge = Math.max(0, parseInt(input.quantity, 10) || 0);
|
||||||
|
if (menge < 1) throw new Error('Menge muss mindestens 1 sein.');
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
const add = (label, amount) => { if (amount) lines.push({ label, amount: round2(amount) }); };
|
||||||
|
const priceOf = (key) => Number(priceItems[key]?.price) || 0;
|
||||||
|
const unitOf = (key) => priceItems[key]?.unit || 'einmalig';
|
||||||
|
const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key));
|
||||||
|
|
||||||
|
// 1) Grundpreis über die editierbare Formel
|
||||||
|
const formula = settings.formula || 'base_price * menge';
|
||||||
|
const grund = evalFormula(formula, { base_price: Number(product.base_price) || 0, menge });
|
||||||
|
add(`${product.name} (${menge} Stk.)`, grund);
|
||||||
|
|
||||||
|
// 2) Versand – Kuvert ist bei Einzelversand Pflicht
|
||||||
|
if (input.shipping_type === 'einzeln') {
|
||||||
|
const auslandCount = (input.entries || [])
|
||||||
|
.filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length;
|
||||||
|
const inlandCount = menge - auslandCount;
|
||||||
|
add('Porto Inland', priceOf('porto_inland') * inlandCount);
|
||||||
|
add('Porto Ausland', priceOf('porto_ausland') * auslandCount);
|
||||||
|
} else {
|
||||||
|
add('Sammelversand', priceOf('sammelversand'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Kuvert + Beschriftung
|
||||||
|
if (input.needs_envelope) {
|
||||||
|
add('Kuvert', applyUnit('kuvert'));
|
||||||
|
if (input.envelope_labeling && input.envelope_labeling !== 'keine')
|
||||||
|
add('Kuvert-Beschriftung', applyUnit('beschriftung'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
|
||||||
|
if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'));
|
||||||
|
for (const key of input.addons || []) {
|
||||||
|
const item = priceItems[key];
|
||||||
|
if (!item || item.category !== 'zusatzleistung') continue;
|
||||||
|
add(item.key, applyUnit(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let net = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
|
|
||||||
|
// 5) Gutschein
|
||||||
|
let voucher = null;
|
||||||
|
if (input.voucher_code) {
|
||||||
|
const v = (await svc('vouchers').readByQuery({
|
||||||
|
filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } },
|
||||||
|
limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'],
|
||||||
|
}))?.[0];
|
||||||
|
const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now();
|
||||||
|
const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses;
|
||||||
|
if (v && !expired && !exhausted) {
|
||||||
|
const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value);
|
||||||
|
const capped = Math.min(discount, net);
|
||||||
|
add(`Gutschein ${v.code}`, -capped);
|
||||||
|
net -= capped;
|
||||||
|
voucher = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
net = round2(Math.max(0, net));
|
||||||
|
const mwst = Number(settings.mwst_percent ?? 19);
|
||||||
|
const vat = round2(net * (mwst / 100));
|
||||||
|
const gross = round2(net + vat);
|
||||||
|
|
||||||
|
// 6) Zahlungsart: ab Limit nur noch Rechnung
|
||||||
|
const limit = Number(settings.paypal_limit_net ?? 200);
|
||||||
|
const paypalAllowed = net < limit;
|
||||||
|
|
||||||
|
return { product, lines, net_total: net, vat_amount: vat, gross_total: gross,
|
||||||
|
mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(body) {
|
||||||
|
const errors = [];
|
||||||
|
if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.');
|
||||||
|
if (!body?.product) errors.push('Produkt fehlt.');
|
||||||
|
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
||||||
|
if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.');
|
||||||
|
// Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller)
|
||||||
|
if (body?.shipping_type === 'einzeln' && !body?.needs_envelope)
|
||||||
|
errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.');
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nur Preis berechnen (Live-Anzeige) ───────────────────────────────────
|
||||||
|
router.post('/quote', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const body = req.body || {};
|
||||||
|
const quote = await calculatePrice(schema, {
|
||||||
|
...body, quantity: body.quantity ?? (body.entries || []).length,
|
||||||
|
});
|
||||||
|
return res.json({
|
||||||
|
lines: quote.lines, net_total: quote.net_total, vat_amount: quote.vat_amount,
|
||||||
|
gross_total: quote.gross_total, mwst_percent: quote.mwst_percent,
|
||||||
|
paypal_allowed: quote.paypal_allowed,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auftrag anlegen ──────────────────────────────────────────────────────
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const errors = validate(body);
|
||||||
|
if (errors.length) return res.status(400).json({ errors });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
const email = norm(body.email);
|
||||||
|
const entries = body.entries;
|
||||||
|
|
||||||
|
const { settings } = await loadPricingContext(schema);
|
||||||
|
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||||
|
|
||||||
|
const quote = await calculatePrice(schema, { ...body, quantity: entries.length });
|
||||||
|
|
||||||
|
// Zahlungsart erzwingen, wenn über dem PayPal-Limit
|
||||||
|
let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung';
|
||||||
|
if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung';
|
||||||
|
|
||||||
|
// Kunde finden oder anlegen – Zuordnung immer per E-Mail
|
||||||
|
const customers = svc('customers');
|
||||||
|
let customer = (await customers.readByQuery({
|
||||||
|
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||||
|
}))?.[0];
|
||||||
|
if (!customer) {
|
||||||
|
const id = await customers.createOne({
|
||||||
|
email,
|
||||||
|
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
|
first_name: clean(body.first_name) ?? null,
|
||||||
|
last_name: clean(body.last_name) ?? null,
|
||||||
|
company: clean(body.company) ?? null,
|
||||||
|
phone: clean(body.phone) ?? null,
|
||||||
|
});
|
||||||
|
customer = { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format auflösen (optional)
|
||||||
|
let formatId = null;
|
||||||
|
if (body.format) {
|
||||||
|
formatId = (await svc('formats').readByQuery({
|
||||||
|
filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'],
|
||||||
|
}))?.[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Umschlagformat automatisch: A4 → DIN Lang, A6 → C6
|
||||||
|
const envelopeFormat = body.needs_envelope
|
||||||
|
? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const orderNumber = `SK-${new Date().toISOString().slice(0, 10)}-${
|
||||||
|
String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||||
|
|
||||||
|
const orderId = await svc('orders').createOne({
|
||||||
|
order_number: orderNumber,
|
||||||
|
customer: customer.id,
|
||||||
|
customer_email: email,
|
||||||
|
product: quote.product.id,
|
||||||
|
format: formatId,
|
||||||
|
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
|
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
||||||
|
source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'),
|
||||||
|
status: 'wartet_auf_zahlung',
|
||||||
|
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||||
|
needs_envelope: !!body.needs_envelope,
|
||||||
|
envelope_labeling: body.envelope_labeling ?? null,
|
||||||
|
envelope_format: envelopeFormat,
|
||||||
|
motif_mode: body.motif_mode ?? null,
|
||||||
|
own_cards: body.motif_mode === 'eigene_karten',
|
||||||
|
text_template: clean(body.text_template) ?? null,
|
||||||
|
text_briefing: clean(body.text_briefing) ?? null,
|
||||||
|
entries_count: entries.length,
|
||||||
|
net_total: quote.net_total,
|
||||||
|
vat_amount: quote.vat_amount,
|
||||||
|
gross_total: quote.gross_total,
|
||||||
|
voucher: quote.voucher?.id ?? null,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
payment_status: 'offen',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empfängerzeilen – Briefnummer wird IMMER vom System vergeben
|
||||||
|
const entriesSvc = svc('order_entries');
|
||||||
|
let n = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
n += 1;
|
||||||
|
await entriesSvc.createOne({
|
||||||
|
order: orderId,
|
||||||
|
letter_number: n,
|
||||||
|
salutation: clean(e.salutation) ?? null,
|
||||||
|
first_name: clean(e.first_name) ?? null,
|
||||||
|
last_name: clean(e.last_name) ?? null,
|
||||||
|
street: clean(e.street) ?? null,
|
||||||
|
house_no: clean(e.house_no) ?? null,
|
||||||
|
zip: clean(e.zip) ?? null,
|
||||||
|
city: clean(e.city) ?? null,
|
||||||
|
country: clean(e.country) ?? null,
|
||||||
|
free_text: clean(e.free_text) ?? null,
|
||||||
|
placeholders: e.placeholders && typeof e.placeholders === 'object'
|
||||||
|
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusatzleistungen verknüpfen
|
||||||
|
if (Array.isArray(body.addons) && body.addons.length) {
|
||||||
|
const items = await svc('price_items').readByQuery({
|
||||||
|
filter: { key: { _in: body.addons }, category: { _eq: 'zusatzleistung' } },
|
||||||
|
limit: -1, fields: ['id'],
|
||||||
|
});
|
||||||
|
for (const it of items || []) await svc('order_addons').createOne({ order: orderId, price_item: it.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gutschein-Einlösung protokollieren
|
||||||
|
if (quote.voucher) {
|
||||||
|
await svc('voucher_redemptions').createOne({ voucher: quote.voucher.id, order: orderId });
|
||||||
|
await svc('vouchers').updateOne(quote.voucher.id, { used_count: (quote.voucher.used_count ?? 0) + 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await svc('status_history').createOne({
|
||||||
|
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
order_number: orderNumber,
|
||||||
|
net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
|
||||||
|
payment_method: paymentMethod, paypal_allowed: quote.paypal_allowed,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] ${err.stack || err.message}`);
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { id: 'skrift-orders', handler };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-orders",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Kontrollierter Bestell-Endpunkt (Gast + eingeloggt) mit serverseitiger Preisberechnung.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,3 +19,8 @@ PORT=4000
|
|||||||
|
|
||||||
# API Authentication (optional)
|
# API Authentication (optional)
|
||||||
API_TOKEN=your-api-token-here
|
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
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ services:
|
|||||||
- BATCH_SIZE=${BATCH_SIZE:-30}
|
- BATCH_SIZE=${BATCH_SIZE:-30}
|
||||||
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
|
- CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2}
|
||||||
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
|
- RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2}
|
||||||
|
- API_TOKEN=${API_TOKEN}
|
||||||
|
- DIRECTUS_URL=${DIRECTUS_URL}
|
||||||
|
- DIRECTUS_TOKEN=${DIRECTUS_TOKEN}
|
||||||
volumes:
|
volumes:
|
||||||
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
|
- ./fonts:/app/fonts:ro # SVG Fonts (read-only)
|
||||||
- skrift-cache:/app/cache # Preview cache (temporary)
|
- skrift-cache:/app/cache # Preview cache (temporary)
|
||||||
- /var/skrift-output:/app/output # Output files for N8N
|
- /var/skrift-output:/app/output # Output files for N8N
|
||||||
networks:
|
networks:
|
||||||
- skrift-network
|
- skrift-network
|
||||||
|
- npm
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -35,3 +39,8 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
skrift-network:
|
skrift-network:
|
||||||
driver: bridge
|
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
|
||||||
|
|||||||
158
Docker/skrift-backend/src/api/controllers/directus-controller.js
Normal file
158
Docker/skrift-backend/src/api/controllers/directus-controller.js
Normal 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 };
|
||||||
@@ -138,23 +138,19 @@ async function finalizeOrder(req, res, next) {
|
|||||||
* POST /api/order/generate
|
* POST /api/order/generate
|
||||||
* Generate order from scratch without using cache
|
* Generate order from scratch without using cache
|
||||||
*/
|
*/
|
||||||
async function generateOrder(req, res, next) {
|
async function runGeneration({ orderNumber, letters, metadata }) {
|
||||||
try {
|
{
|
||||||
const { orderNumber, letters } = req.body;
|
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!orderNumber) {
|
if (!orderNumber) {
|
||||||
return res.status(400).json({
|
const e = new Error('orderNumber is required');
|
||||||
error: 'Invalid request',
|
e.status = 400;
|
||||||
message: 'orderNumber is required'
|
throw e;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!letters || !Array.isArray(letters) || letters.length === 0) {
|
if (!letters || !Array.isArray(letters) || letters.length === 0) {
|
||||||
return res.status(400).json({
|
const e = new Error('Letters array is required and must not be empty');
|
||||||
error: 'Invalid request',
|
e.status = 400;
|
||||||
message: 'Letters array is required and must not be empty'
|
throw e;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Order] Generating order: ${orderNumber}`);
|
console.log(`[Order] Generating order: ${orderNumber}`);
|
||||||
@@ -255,10 +251,10 @@ async function generateOrder(req, res, next) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
|
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
|
||||||
return res.status(500).json({
|
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
|
||||||
error: 'Scriptalization failed',
|
e.status = 500;
|
||||||
message: `Failed to scriptalize texts with font ${font}: ${err.message}`
|
e.label = 'Scriptalization failed';
|
||||||
});
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,10 +286,10 @@ async function generateOrder(req, res, next) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
|
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
|
||||||
return res.status(500).json({
|
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
|
||||||
error: 'SVG generation failed',
|
e.status = 500;
|
||||||
message: `Failed to generate SVG for letter ${i}: ${err.message}`
|
e.label = 'SVG generation failed';
|
||||||
});
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
|
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
|
||||||
@@ -352,7 +348,7 @@ async function generateOrder(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Create order metadata
|
// Step 6: Create order metadata
|
||||||
const meta = req.body.metadata || {};
|
const meta = metadata || {};
|
||||||
const orderMetadata = {
|
const orderMetadata = {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
@@ -373,8 +369,7 @@ async function generateOrder(req, res, next) {
|
|||||||
|
|
||||||
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
||||||
|
|
||||||
// Response
|
return {
|
||||||
res.status(200).json({
|
|
||||||
orderNumber,
|
orderNumber,
|
||||||
outputPath: outputDir,
|
outputPath: outputDir,
|
||||||
letters: generatedLetters,
|
letters: generatedLetters,
|
||||||
@@ -382,9 +377,26 @@ async function generateOrder(req, res, next) {
|
|||||||
csvFiles,
|
csvFiles,
|
||||||
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
|
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
|
||||||
timestamp: orderMetadata.generatedAt
|
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) {
|
} 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);
|
console.error('[Order] Error generating order:', err);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -535,6 +547,7 @@ async function registerOrder(req, res, next) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
finalizeOrder,
|
finalizeOrder,
|
||||||
generateOrder,
|
generateOrder,
|
||||||
|
runGeneration,
|
||||||
uploadMotif,
|
uploadMotif,
|
||||||
uploadOrderFile,
|
uploadOrderFile,
|
||||||
registerOrder,
|
registerOrder,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const orderController = require('../controllers/order-controller');
|
const orderController = require('../controllers/order-controller');
|
||||||
|
const directusController = require('../controllers/directus-controller');
|
||||||
const { authenticateApiToken } = require('../middleware/auth');
|
const { authenticateApiToken } = require('../middleware/auth');
|
||||||
|
|
||||||
// Max. Uploadgröße für Auftragsdateien (Brief-PDF, Adress-/Platzhalter-Tabellen).
|
// 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);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ module.exports = {
|
|||||||
apiToken: process.env.API_TOKEN || null
|
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: {
|
paypal: {
|
||||||
clientId: process.env.PAYPAL_CLIENT_ID || '',
|
clientId: process.env.PAYPAL_CLIENT_ID || '',
|
||||||
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
clientSecret: process.env.PAYPAL_CLIENT_SECRET || '',
|
||||||
|
|||||||
80
Docker/skrift-backend/src/services/directus-service.js
Normal file
80
Docker/skrift-backend/src/services/directus-service.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user