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:
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user