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