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>
474 lines
26 KiB
JavaScript
474 lines
26 KiB
JavaScript
/**
|
||
* 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); });
|