/** * 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' }); // Kunden sind Directus-Login-Nutzer (directus_users). Kein eigener „customers"- // Datensatz mehr – das Profil hängt direkt am Konto (Felder siehe USER_FIELDS). // Unterfelder der Adressliste – ohne diese zeigt die „list"-Oberfläche ein // leeres Formular („no visible fields"). const ADDRESS_SUBFIELDS = [ { field: 'label', name: 'Bezeichnung', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'z. B. Rechnung, Lieferung' } } }, { field: 'street', name: 'Straße & Hausnummer', type: 'string', meta: { interface: 'input', width: 'full' } }, { field: 'zip', name: 'PLZ', type: 'string', meta: { interface: 'input', width: 'half' } }, { field: 'city', name: 'Ort', type: 'string', meta: { interface: 'input', width: 'half' } }, { field: 'country', name: 'Land', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'Deutschland' } } }, ]; // Nur die Felder, die Skrift wirklich braucht. Alias-Felder (signatures, // discounts) zeigen die zugehörigen o2m-Collections direkt am Kundenkonto. const USER_FIELDS = [ dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }), f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }), f('phone', 'string', { interface: 'input' }), f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }), f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }), { field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null }, { field: 'discounts', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Kundenspezifische Rabatte.' }, schema: null }, { field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null }, ]; // directus_users-Felder, die Skrift nicht braucht – werden ausgeblendet. const HIDE_USER_FIELDS = ['location', 'title', 'description', 'tags', 'avatar', 'language', 'appearance', 'theme_light', 'theme_dark', 'theme_light_overrides', 'theme_dark_overrides', 'text_direction', 'email_notifications']; // ── Collections ───────────────────────────────────────────────────────────── const collections = [ { 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('visibility', ['alle', 'privat', 'unternehmen'], { default: 'alle', note: 'Für wen im Konfigurator sichtbar.' }), dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }), f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }), // Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt. // Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen. f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }), // Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu // unterschreibendes PDF beim Unterschriftenservice). dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }), 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'), dropdown('orientation', ['quer', 'hochkant'], { default: 'quer', note: 'Passend zum Postkartenformat.' }), 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: 'Grundpreis-Formel. Variablen: base_price, menge.' }), // Mengen-Multiplikator (Kleinmengen-Aufschlag), je Segment editierbar. // Variablen: qty, norm_b, norm_c, mind_b, mind_c, base_price. Funktionen: // sqrt/abs/min/max/round. Vergleiche + Ternär (cond ? a : b) erlaubt. // Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b)) // Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die // Normalpreis-Menge des jeweiligen Produkts (products.norm_qty). f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }), f('mwst_percent', 'float', { interface: 'input', default: 19 }), // Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den // Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar. { field: 'quantity_discounts', type: 'json', meta: { interface: 'list', note: 'Mengenrabatt-Staffel (ab X Stück Y %). Höchste passende Stufe greift.', options: { fields: [ { field: 'min_qty', name: 'ab Menge', type: 'integer', meta: { interface: 'input', width: 'half' } }, { field: 'percent', name: 'Rabatt %', type: 'float', meta: { interface: 'input', width: 'half' } }, ] } }, schema: {} }, 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' }), // PayPal: NUR die Client-ID (öffentlich). Das Secret gehört nicht hierher – // diese Collection ist für Gäste lesbar, und clientseitig wird es nicht gebraucht. f('paypal_enabled', 'boolean', { interface: 'boolean', default: false }), dropdown('paypal_mode', ['sandbox', 'live'], { default: 'sandbox' }), f('paypal_client_id_sandbox', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Sandbox).' }), f('paypal_client_id_live', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Live).' }), ] }, { 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', 'directus_users', { note: 'Kunde = Login-Konto (Zuordnung per E-Mail).' }), 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' }), // Produktionsstatus (fürs Kanban) – getrennt vom kaufmännischen Status. dropdown('production_status', ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig', 'versendet', 'storniert'], { default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }), f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }), f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }), // Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen). { field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } }, 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('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }), // Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word). { field: 'source_file', type: 'uuid', meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte).' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, // Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand). f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }), f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }), f('entries_count', 'integer', { interface: 'input', default: 0 }), f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }), f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }), 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: {} }, // Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle // Anschrift, ganze Zeile) – wie im alten Job-Manager. Nur lesend/suchend. { field: 'entries_view', type: 'alias', meta: { interface: 'skrift-order-entries', special: ['alias', 'no-data'], note: 'Alle Empfänger durchsuchbar (Briefnummer + volle Anschrift).' }, schema: null }, // Empfängerzeilen direkt im Auftrag (Adresse bzw. alle Freitext-Zeilen // bearbeitbar) – kein Wechsel auf eine eigene Seite nötig. { field: 'entries', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], options: { template: '#{{letter_number}} · {{first_name}} {{last_name}} · {{zip}} {{city}}' }, note: 'Empfängerzeilen bearbeiten (Adresse korrigieren).' }, schema: null }, // m2m-Alias zu price_items über order_addons: { field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null }, // Präsentationsfeld: zeigt die generierten Dateien per Interface-Plugin // (Direktzugriff auf den Ausgabe-Ordner, keine Kopie). { field: 'files_view', type: 'alias', meta: { interface: 'skrift-order-files', special: ['alias', 'no-data'], note: 'Generierte Dateien (Direktzugriff).' }, schema: null }, // Präsentationsfeld: „Drucken"-Button (reiht Druck-Jobs für eine Maschine ein). { field: 'print_action', type: 'alias', meta: { interface: 'skrift-print-button', special: ['alias', 'no-data'], note: 'In die Druck-Warteschlange einreihen.' }, 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' }), ] }, // Plotter/Schreibmaschinen – im Admin pflegbar, Basis für die Warteschlange. { collection: 'machines', meta: { icon: 'print', note: 'Produktionsmaschinen (Plotter).' }, fields: [ f('name', 'string', { interface: 'input', required: true }), f('base_url', 'string', { interface: 'input', required: true, note: 'z. B. http://192.168.2.32:90' }), f('mid', 'string', { interface: 'input', note: 'Maschinen-ID an der Steuerung (optional).' }), f('username', 'string', { interface: 'input', note: 'Login an der Steuerung (falls nötig).' }), f('password', 'string', { interface: 'input', options: { masked: true }, note: 'Login-Passwort (falls nötig).' }), f('poll_interval_seconds', 'integer', { interface: 'input', default: 5 }), f('active', 'boolean', { interface: 'boolean', default: true }), dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }), { field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} }, f('notes', 'text', { interface: 'input-multiline' }), ] }, // Testdruck: beliebiges Template + Daten schnell generieren und drucken. { collection: 'test_prints', meta: { icon: 'science', note: 'Testdrucke zum Verproben von Templates & Maschinen.' }, fields: [ f('label', 'string', { interface: 'input', note: 'Bezeichnung (optional).' }), m2o('template', 'format_templates', { note: 'Druck-Template (Platzierung an der Maschine).' }), dropdown('format', ['a4', 'a6_hoch', 'a6_quer'], { default: 'a4', note: 'Layout für die Textgenerierung.' }), dropdown('font', ['tilda', 'alva', 'ellie'], { default: 'tilda' }), m2o('machine', 'machines'), f('text', 'text', { interface: 'input-multiline', note: 'Testtext (Platzhalter werden nicht ersetzt).' }), dropdown('status', ['neu', 'generiert', 'fehler'], { default: 'neu' }), f('output_ref', 'string', { interface: 'input', readonly: true, note: 'Ausgabe-Ordner des Testdrucks.' }), { field: 'run_action', type: 'alias', meta: { interface: 'skrift-testprint-button', special: ['alias', 'no-data'], note: 'Generieren & in die Druck-Warteschlange legen.' }, schema: null }, { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, // Druck-Templates (Maße/Position/Rotation in Zoll) – aus config.json. { collection: 'format_templates', meta: { icon: 'straighten', note: 'Druck-Templates (Zoll).' }, fields: [ f('key', 'string', { interface: 'input', unique: true, required: true }), f('name', 'string', { interface: 'input', required: true }), dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief' }), f('width', 'float', { interface: 'input', note: 'Zoll' }), f('height', 'float', { interface: 'input', note: 'Zoll' }), f('xpos', 'float', { interface: 'input', default: 0 }), f('ypos', 'float', { interface: 'input', default: 0 }), f('scale', 'float', { interface: 'input', default: 1 }), f('rotation', 'integer', { interface: 'input', default: 0 }), ] }, { collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [ m2o('order', 'orders', { onDelete: 'CASCADE' }), f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }), m2o('machine', 'machines'), dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }), m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }), dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }), f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }), // Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot). // Vom Druck-Popup gesetzt; leer = Template-Werte verwenden. f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }), // Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle. f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }), f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }), { 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: {} }, ] }, { 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: {} }, ] }, // Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt. { collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [ m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), f('label', 'string', { interface: 'input', note: 'z. B. „Standard", „mit Titel".' }), // Datei-Upload (SVG) statt Textfeld. { field: 'file', type: 'uuid', meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, // Kundenspezifische Rabatte. Wird am Kundenkonto als o2m gezeigt. { collection: 'customer_discounts', meta: { icon: 'percent', note: 'Kundenspezifische Rabatte.' }, fields: [ m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), f('label', 'string', { interface: 'input', note: 'z. B. „Einführungsrabatt".' }), dropdown('type', ['prozent', 'fest'], { default: 'prozent' }), f('value', 'float', { interface: 'input', note: 'Prozent oder €-Betrag.' }), dropdown('scope', ['alle', 'erste_n_stueck'], { default: 'alle', note: 'Gilt für alle oder nur die ersten N Stück.' }), f('limit_stueck', 'integer', { interface: 'input', note: 'N bei „erste_n_stueck".' }), f('active', 'boolean', { interface: 'boolean', default: true }), f('valid_until', 'timestamp', { interface: 'datetime', note: 'Optionales Ablaufdatum.' }), ] }, // Für einzelne Kunden freigeschaltete Produkte (Override der Sichtbarkeit). { collection: 'customer_products', meta: { icon: 'lock_open', note: 'Freigeschaltete Produkte je Kunde.', hidden: true }, fields: [ m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), m2o('product', 'products', { onDelete: 'CASCADE' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, // E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis. { collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [ f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }), f('label', 'string', { interface: 'input', note: 'Bezeichnung im Admin.' }), f('subject', 'string', { interface: 'input', required: true, note: 'Betreff. Platzhalter: {{order_id}}, {{name}}, {{code}}, {{link}} …' }), f('body', 'text', { interface: 'input-rich-text-html', note: 'Text (WYSIWYG/HTML). Platzhalter in {{...}}.' }), f('append_signature', 'boolean', { interface: 'boolean', default: true, note: 'Signatur automatisch anhängen (bei Mails mit eigenem Footer aus).' }), f('attach_agb', 'boolean', { interface: 'boolean', default: false, note: 'AGB-PDF an diese Mail anhängen.' }), f('enabled', 'boolean', { interface: 'boolean', default: true }), ] }, // Gemeinsame E-Mail-Einstellungen (Signatur, Portal-Link, AGB, Footer). { collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [ f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }), f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }), f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }), f('portal_url', 'string', { interface: 'input', note: 'Basis-URL des Kundenportals (Platzhalter {{link}}).' }), f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }), { field: 'agb_file', type: 'uuid', meta: { interface: 'file', special: ['file'], note: 'AGB als PDF – wird an Mails mit „AGB anhängen" gehängt. Zum Ändern einfach neue Datei hochladen.' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, ] }, // Standard-Mustertexte für den manuellen Muster-Druck (Du-/Sie-Form). // Platzhalter [[abschnitt]] füllt der Bediener im Druck-Dialog kurz aus. { collection: 'muster_settings', meta: { icon: 'science', singleton: true, note: 'Standard-Mustertexte (Du/Sie) für den Muster-Druck.' }, fields: [ f('text_du', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Du-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }), f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }), ] }, // Angebots- und Musteranfragen aus der App. { collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [ dropdown('type', ['angebot', 'muster'], { default: 'angebot' }), f('name', 'string', { interface: 'input', required: true }), f('email', 'string', { interface: 'input', required: true }), f('phone', 'string', { interface: 'input' }), f('quantity', 'integer', { interface: 'input' }), f('product', 'string', { interface: 'input', note: 'Bei Muster: gewünschtes Produkt.' }), f('message', 'text', { interface: 'input-multiline' }), f('address', 'json', { interface: 'input-code', note: 'Bei Muster: Lieferadresse.' }), dropdown('status', ['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'], { default: 'neu' }), m2o('customer', 'directus_users', { note: 'Falls die Anfrage von einem bestehenden Konto kommt.' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, // Vorschau-Kontingent (serverseitig – der Client darf das nicht bestimmen). { collection: 'preview_usage', meta: { icon: 'speed', hidden: true, note: 'Vorschau-Verbrauch je Kennung und Tag.' }, fields: [ f('key', 'string', { interface: 'input', required: true, note: 'E-Mail oder anonyme Kennung.' }), f('day', 'string', { interface: 'input', required: true }), f('count', 'integer', { interface: 'input', default: 0 }), ] }, // 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 }); } } // Profilfelder direkt am Login-Konto (Kunde = directus_users). console.log('› Kundenfelder an directus_users'); for (const fld of USER_FIELDS) { if (!existingFields.has(`directus_users.${fld.field}`)) { await api('POST', '/fields/directus_users', fld); log('field +', `directus_users.${fld.field}`); } else { // Vorhandenes Feld: Oberfläche/Optionen aktualisieren (z. B. Adressliste). try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: fld.meta }); } catch { /* egal */ } } } // Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren // immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls // ein Feldname in dieser Directus-Version anders heißt. for (const field of HIDE_USER_FIELDS) { try { await api('PATCH', `/fields/directus_users/${field}`, { meta: { hidden: true } }); log('feld versteckt', `directus_users.${field}`); } catch (e) { console.warn(` Feld directus_users.${field} nicht ausgeblendet: ${e.message}`); } } 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); } // o2m am Kundenkonto: Unterschriften + Rabatte direkt im User anzeigen. console.log('› o2m-Verdrahtung'); try { await api('PATCH', '/relations/signatures/customer', { meta: { one_field: 'signatures' } }); await api('PATCH', '/relations/customer_discounts/customer', { meta: { one_field: 'discounts' } }); await api('PATCH', '/relations/customer_products/customer', { meta: { one_field: 'unlocked_products' } }); await api('PATCH', '/relations/order_entries/order', { meta: { one_field: 'entries' } }); log('o2m verdrahtet'); } catch (e) { console.warn(' o2m-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', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, active: true, sort: 1 }, { key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 }, { key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 }, // Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch. // pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden // bestellbar ist; für andere bleibt es (type contact) ein Anfrage-Produkt. { key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', visibility: 'unternehmen', pricing_mode: 'formel', base_price: 5.0, input_mode: 'datei', active: true, sort: 4 }, { key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', visibility: 'unternehmen', 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, // Beispiel-Staffel (relativ hoch angesetzt) – in Directus anpassbar. quantity_discounts: [ { min_qty: 250, percent: 5 }, { min_qty: 500, percent: 10 }, { min_qty: 1000, percent: 15 }, ], }); log('seed singleton pricing_settings'); } // Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben // erhalten). `norm` kommt je Produkt aus products.norm_qty. if (!ps || !ps.multiplier_formula) { await api('PATCH', '/items/pricing_settings', { multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))', }); log('seed multiplier-formel'); } // E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten). const G = 'Guten Tag {{name}},'; // gemeinsame Anrede // Reiche Bestellbestätigung (Design an das bisherige n8n-Template angelehnt, // aber mit flachen {{platzhaltern}}, die der Mailer-Hook befüllt). const orderCreatedBody = `
{{lieferadresse_block}}

Bestellbestätigung

Guten Tag {{name}},

vielen Dank für Ihre Bestellung bei Skrift. Wir haben sie erhalten und bearbeiten sie schnellstmöglich.

Ihre Bestellnummer:

{{order_id}}

Ihre Bestellung

Produkt:{{product}}
Menge:{{menge}} Stück
Format:{{format}}
Versand:{{versand}}
{{kuvert_block}}

Preisübersicht

Zwischensumme (netto):{{netto}} {{waehrung}}
MwSt. ({{mwst_satz}} %):{{mwst_betrag}} {{waehrung}}

Gesamt (brutto):{{brutto}} {{waehrung}}

Rechnungsadresse

{{rechnungsadresse}}

Wie geht es weiter?

  1. Wir prüfen Ihre Bestellung und bereiten die Produktion vor.
  2. Nach Fertigstellung erhalten Sie eine Versandbestätigung.

Mit freundlichen Grüßen
Ihr Skrift-Team

{{footer}}

`.trim(); const templates = [ { key: 'login_code', label: 'Anmeldecode', subject: 'Ihr Anmeldecode: {{code}}', append_signature: true, attach_agb: false, body: `

Guten Tag,

Ihr Anmeldecode für Skrift lautet:

` + `

{{code}}

` + `

Der Code ist 10 Minuten gültig. Falls Sie keine Anmeldung angefordert haben, ignorieren Sie diese E-Mail.

` }, { key: 'order_created', label: 'Bestellbestätigung', subject: 'Ihre Bestellung {{order_id}} ist eingegangen', append_signature: false, attach_agb: true, body: orderCreatedBody }, { key: 'bezahlt', label: 'Status: bezahlt', subject: 'Zahlung erhalten – {{order_id}}', append_signature: true, attach_agb: false, body: `

${G}

Ihre Zahlung zu Auftrag {{order_id}} ist bei uns eingegangen. Wir starten die Produktion.

` }, { key: 'in_produktion', label: 'Status: in Produktion', subject: '{{order_id}} ist in Produktion', append_signature: true, attach_agb: false, body: `

${G}

Ihr Auftrag {{order_id}} ist jetzt in Produktion.

` }, { key: 'versendet', label: 'Status: versendet', subject: '{{order_id}} wurde versendet', append_signature: true, attach_agb: false, body: `

${G}

Ihre Schriftstücke zu Auftrag {{order_id}} wurden versendet bzw. an Sie übergeben. Vielen Dank für Ihr Vertrauen!

` }, { key: 'abgeschlossen', label: 'Status: abgeschlossen', subject: '{{order_id}} abgeschlossen', append_signature: true, attach_agb: false, body: `

${G}

Ihr Auftrag {{order_id}} ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.

` }, { key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false, body: `

${G}

Ihr Auftrag {{order_id}} wurde storniert. Bei Fragen sind wir gerne für Sie da.

{{reason}}` }, ]; for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t }); // Druck-Templates (aus der bisherigen config.json der Produktions-App). const formatTemplates = [ { key: 'a4_single', name: 'A4 Brief – Einzeln', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6_v1', name: 'A6 Brief – Hochformat', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6_v2', name: 'A6 Brief – Querformat', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 }, { key: 'kuvert_a_v1', name: 'Kuvert DIN Lang', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 }, { key: 'kuvert_b_v1', name: 'Kuvert C6', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 }, ]; for (const t of formatTemplates) await seedItem('format_templates', 'key', t); // email_settings (Singleton) – Signatur nur setzen, wenn leer. const es = await api('GET', '/items/email_settings'); if (!es || !es.signature) { await api('PATCH', '/items/email_settings', { sender_name: 'Skrift', sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(), signature: '

Mit freundlichen Grüßen
Ihr Skrift-Team

', portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(), company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de', }); log('seed singleton email_settings'); } // muster_settings (Singleton) – Standardtexte nur setzen, wenn leer. const ms = await api('GET', '/items/muster_settings'); if (!ms || (!ms.text_du && !ms.text_sie)) { await api('PATCH', '/items/muster_settings', { text_du: 'Hallo [[Vorname]],\n\nschön, dass Du Skrift ausprobierst! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nHerzliche Grüße\nDein Skrift-Team', text_sie: 'Sehr geehrte/r [[Anrede]] [[Nachname]],\n\nschön, dass Sie Skrift ausprobieren! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nMit freundlichen Grüßen\nIhr Skrift-Team', }); log('seed singleton muster_settings'); } } // ── Rollen / Policies / Rechte (Directus 11) ──────────────────────────────── const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history', 'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints']; // Kunde = Login-Konto: Aufträge hängen direkt am Nutzer. const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } }; const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } }; const CUSTOMER_ROLE_NAME = 'Kunde'; /** 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-Namen auf den finalen "Kunde" ziehen (idempotent). await renameRole('Customer', CUSTOMER_ROLE_NAME); await renameRole('Kunde (Login)', CUSTOMER_ROLE_NAME); const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person'); const custPol = await ensurePolicy('Customer', { app: false }); await ensureAccess(custRole.id, custPol.id); // Kunde = eigenes Login-Konto: eigenes Profil lesen … await ensurePermission(custPol.id, 'directus_users', 'read', ['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'], { id: { _eq: '$CURRENT_USER' } }); // … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die // Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde. await ensurePermission(custPol.id, 'directus_users', 'update', ['first_name', 'last_name', 'company', 'phone', 'addresses'], { id: { _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); // Eigene Produkt-Freischaltungen lesen (bestimmt, welche Produkte der Kunde // im Konfigurator konfigurieren darf). await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } }); 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', 'products', 'formats', 'motifs', 'price_items', 'machines', 'format_templates']) await ensurePermission(svcPol.id, col, 'read'); await ensurePermission(svcPol.id, 'orders', 'update', ['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']); await ensurePermission(svcPol.id, 'jobs', 'create'); // Agent meldet Druckfortschritt zurück. await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']); // Agent meldet Maschinenzustand (Heartbeat). await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']); 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 = { 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}`); } } } // ── Navigation gruppieren ──────────────────────────────────────────────────── // Hauptansicht bewusst schlank: nur Aufträge, Jobs, Gutscheine, Verlauf. // Alles Konfigurations-artige (Produkte, Preise, Motive …) wandert in einen // Ordner „Einstellungen"; reine Detail-/Technik-Collections werden versteckt. async function organizeNav() { console.log('› Navigation gruppieren'); const FOLDER = 'einstellungen'; // Ordner (Collection ohne Tabelle) anlegen, falls noch nicht vorhanden. let folderDa = true; try { await api('GET', `/collections/${FOLDER}`); } catch { folderDa = false; } if (!folderDa) { await api('POST', '/collections', { collection: FOLDER, schema: null, meta: { icon: 'settings', note: 'Konfiguration: Produkte, Preise, Motive, Gutschein-Einlösungen …', translations: [ { language: 'de-DE', translation: 'Einstellungen', singular: 'Einstellung', plural: 'Einstellungen' }, { language: 'en-US', translation: 'Settings', singular: 'Setting', plural: 'Settings' }, ] }, }); log('folder +', FOLDER); } const patch = async (col, meta) => { try { await api('PATCH', `/collections/${col}`, { meta }); } catch (e) { console.warn(` Nav ${col} übersprungen: ${e.message}`); } }; // Hauptebene for (const col of ['orders', 'jobs', 'vouchers', 'status_history']) await patch(col, { group: null, hidden: false }); // Unter „Einstellungen" for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests', 'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints']) await patch(col, { group: FOLDER, hidden: false }); // Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und // Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt). // Empfängerzeilen werden direkt im Auftrag gepflegt (o2m) → keine eigene Seite. for (const col of ['order_entries', 'order_addons', 'voucher_redemptions', 'preview_usage', 'login_codes', 'signatures', 'customer_discounts', 'customer_products']) await patch(col, { hidden: true }); } // ── Anzeige-Vorlagen für Verknüpfungen ────────────────────────────────────── // Ohne diese zeigt Directus bei m2o-Feldern nur die ID (z. B. Produkt „1"). // Mit „related-values" + Template erscheint der aussagekräftige Wert. async function applyDisplays() { console.log('› Anzeige-Vorlagen (m2o)'); const displays = [ ['orders', 'product', '{{name}}'], ['orders', 'format', '{{name}}'], ['orders', 'motif', '{{name}}'], ['orders', 'customer', '{{first_name}} {{last_name}}'], ['orders', 'voucher', '{{code}}'], ['formats', 'product', '{{name}}'], ['jobs', 'order', '{{order_number}}'], ['jobs', 'machine', '{{name}}'], ['jobs', 'template', '{{name}}'], ['test_prints', 'template', '{{name}}'], ['test_prints', 'machine', '{{name}}'], ['status_history', 'order', '{{order_number}}'], ['order_entries', 'order', '{{order_number}}'], ['order_addons', 'order', '{{order_number}}'], ['order_addons', 'price_item', '{{label}}'], ['signatures', 'customer', '{{first_name}} {{last_name}}'], ['customer_discounts', 'customer', '{{first_name}} {{last_name}}'], ['customer_products', 'customer', '{{first_name}} {{last_name}}'], ['customer_products', 'product', '{{name}}'], ['offer_requests', 'customer', '{{first_name}} {{last_name}}'], ['voucher_redemptions', 'voucher', '{{code}}'], ]; for (const [collection, field, template] of displays) { try { await api('PATCH', `/fields/${collection}/${field}`, { meta: { display: 'related-values', display_options: { template } }, }); } catch (e) { console.warn(` Display ${collection}.${field} übersprungen: ${e.message}`); } } } // ── Altlasten entfernen ───────────────────────────────────────────────────── // Ersetzt durch: eine gemeinsame `multiplier_formula` + `products.norm_qty`. async function removeDeprecatedFields() { console.log('› Alte Felder entfernen'); const weg = [ ['pricing_settings', 'multiplier_formula_business'], ['pricing_settings', 'multiplier_formula_privat'], ['pricing_settings', 'norm_qty_business'], ['pricing_settings', 'norm_qty_privat'], ['pricing_settings', 'min_qty_business'], ['pricing_settings', 'min_qty_privat'], ]; for (const [col, fld] of weg) { try { await api('DELETE', `/fields/${col}/${fld}`); log('field -', `${col}.${fld}`); } catch { /* bereits entfernt */ } } } // ── Projektname ───────────────────────────────────────────────────────────── // Directus nutzt den Projektnamen als ABSENDERNAMEN aller Mails. Steht hier // „Directus", kommen die Mails auch so beim Kunden an. async function applyProjectName() { console.log('› Projektname'); try { await api('PATCH', '/settings', { project_name: 'Skrift' }); log('settings', 'project_name = Skrift'); } catch (e) { console.warn(` Projektname übersprungen: ${e.message}`); } } // ── Auftrags-Übersicht (Produktion) ───────────────────────────────────────── // Sammel-Anzeigevorlage + globaler Tabellen-Preset: auf einen Blick sieht man // je Auftrag Nummer, Empfängeranzahl, Kuvert-Bedarf, Versandart und Bezahlt. async function applyOrdersLayout() { console.log('› Auftrags-Übersicht (Liste)'); try { await api('PATCH', '/collections/orders', { meta: { display_template: '{{order_number}} · {{entries_count}}× · {{shipping_type}} · {{payment_status}}' }, }); } catch (e) { console.warn(` Sammel-Template orders übersprungen: ${e.message}`); } const spalten = ['order_number', 'customer', 'entries_count', 'needs_envelope', 'shipping_type', 'payment_status', 'production_status', 'date_created']; try { const vorhanden = await api('GET', '/presets?filter[collection][_eq]=orders&filter[role][_null]=true&filter[user][_null]=true&filter[bucket][_null]=true&limit=1'); const preset = { collection: 'orders', bucket: null, role: null, user: null, layout: 'tabular', layout_query: { tabular: { fields: spalten, sort: ['-date_created'], page: 1 } }, layout_options: { tabular: { widths: {} } }, }; if (vorhanden && vorhanden[0]) await api('PATCH', `/presets/${vorhanden[0].id}`, preset); else { await api('POST', '/presets', preset); log('preset +', 'orders (Übersicht)'); } } catch (e) { console.warn(` Listen-Preset orders übersprungen: ${e.message}`); } } // ── Insights-Dashboard „Übersicht" ────────────────────────────────────────── // Zählt neue Aufträge, Anfragen und Musterbestellungen auf einen Blick. async function applyDashboard() { console.log('› Dashboard (Insights)'); try { let dash = (await api('GET', `/dashboards?filter[name][_eq]=${encodeURIComponent('Übersicht')}&limit=1`))?.[0]; if (!dash) { dash = await api('POST', '/dashboards', { name: 'Übersicht', icon: 'dashboard', note: 'Neue Aufträge, Anfragen & Muster auf einen Blick.', }); log('dashboard +', 'Übersicht'); } const vorhanden = new Set(((await api('GET', `/panels?filter[dashboard][_eq]=${dash.id}&limit=-1&fields=name`)) || []).map((p) => p.name)); const metric = (name, icon, collection, filter, x) => ({ dashboard: dash.id, name, icon, type: 'metric', show_header: true, position_x: x, position_y: 1, width: 6, height: 6, options: { collection, field: 'id', function: 'count', filter }, }); const panels = [ metric('Neue Aufträge', 'receipt_long', 'orders', { production_status: { _eq: 'eingegangen' } }, 1), metric('Neue Anfragen', 'request_quote', 'offer_requests', { _and: [{ type: { _eq: 'angebot' } }, { status: { _eq: 'neu' } }] }, 7), metric('Neue Muster', 'science', 'offer_requests', { _and: [{ type: { _eq: 'muster' } }, { status: { _eq: 'neu' } }] }, 13), ]; for (const p of panels) { if (vorhanden.has(p.name)) continue; try { await api('POST', '/panels', p); log('panel +', p.name); } catch (e) { console.warn(` Panel ${p.name} übersprungen: ${e.message}`); } } } catch (e) { console.warn(' Dashboard übersprungen (bitte melden):', e.message); } } // ── Main ──────────────────────────────────────────────────────────────────── (async () => { await login(); console.log(`Bootstrap gegen ${DIRECTUS_URL}`); await applySchema(); await applySeed(); await applyLabels(); await organizeNav(); await applyDisplays(); await removeDeprecatedFields(); await applyProjectName(); await applyOrdersLayout(); await applyDashboard(); 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); });