Directus als Daten-/Auth-Schicht + Backend-Anbindung

Directus (Postgres, hinter NPM) wird die zentrale Datenquelle für Kunden,
Aufträge, Produkte, Preise und Gutscheine; das skrift-backend bleibt reine
Generierungs-Engine.

- Docker/directus: docker-compose (Directus 11 + Postgres), Bootstrap-Skript
  legt Collections, Rollen/Policies/Rechte und Seed idempotent an.
- Extension skrift-auth: passwortloser Login per E-Mail-Code (Codes nur als
  Hash, Ablauf, Versuchslimit) sowie Kontoanlage aus dem Checkout.
- Extension skrift-orders: kontrollierter Bestell-Endpunkt für Gäste und
  eingeloggte Kunden; Preis wird immer serverseitig berechnet (editierbare
  Formel via sicherem Interpreter, Werte aus price_items).
- skrift-backend: neuer Endpunkt /api/order/from-directus erzeugt SVGs aus
  einem Directus-Auftrag und schreibt Status + artifact_path zurück. Dafür
  wurde die Kernlogik aus generateOrder als runGeneration herausgelöst
  (Verhalten unverändert); generateOrder ist jetzt ein HTTP-Wrapper.
- compose: NPM-Netz ergänzt, API_TOKEN/DIRECTUS_* werden durchgereicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-20 19:13:35 +02:00
parent 0341c4eaea
commit 2dbbc4611a
17 changed files with 1571 additions and 24 deletions

View File

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

View File

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

View File

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