/** * 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; } /** Prüft, ob der gesetzte Token wirklich gültig ist. */ async function tokenGueltig() { try { const res = await fetch(DIRECTUS_URL + '/users/me?fields=id', { headers: { Authorization: `Bearer ${token}` }, }); return res.ok; } catch { return false; } } async function login() { // Ein mitgegebener DIRECTUS_TOKEN (z. B. versehentlich aus der .env // übernommen) wird geprüft – sonst schlägt erst der nächste Aufruf mit einem // wenig hilfreichen 401 fehl. if (token) { if (await tokenGueltig()) return; console.warn(' DIRECTUS_TOKEN ist ungültig – versuche Login mit ADMIN_EMAIL/ADMIN_PASSWORD.'); token = ''; } if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { console.error('DIRECTUS_TOKEN ungültig bzw. nicht gesetzt und ADMIN_EMAIL/ADMIN_PASSWORD fehlen.'); 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' }), // Genau zwei feste Adressen je Konto (ohne Namen/Bezeichnung): Rechnung + Lieferung. // Werden automatisch an der passenden Stelle im Bestellvorgang eingesetzt. f('billing_address', 'json', { interface: 'input-code', note: 'Rechnungsadresse {street,zip,city,country}.' }), f('shipping_address', 'json', { interface: 'input-code', note: 'Lieferadresse {street,zip,city,country}.' }), // Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten. f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }), f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }), f('sevdesk_contact_id', 'string', { interface: 'input', hidden: true, note: 'Verknüpfter sevDesk-Kontakt (für Rechnungen).' }), // Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code // anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails. f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }), { 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', 'envelope', '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.' }), // Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel): // a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform // (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch). // Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2. f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }), f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }), // 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, note: 'Im Konfigurator (app.skrift.de) sichtbar.' }), f('show_manual', 'boolean', { interface: 'boolean', default: true, note: 'Im manuellen Druck (Admin) sichtbar.' }), ] }, { 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, a, e (a/e je Produkt). 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']), f('realistic', 'boolean', { interface: 'boolean', default: true, note: 'Natürliche Handschrift-Variation (Jitter/Neigung).' }), m2o('api_client', 'api_clients', { onDelete: 'SET NULL', note: 'Gesetzt bei Aufträgen über die Public-API.' }), dropdown('shipping_day', ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], { note: 'Vom Kunden gewünschter Versandtag (optional, v. a. Public-API).' }), f('font_scale_letter', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Brief (0.8–1.5, 1 = Standard).' }), f('font_scale_envelope', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Kuvert (0.8–1.5, 1 = Standard).' }), f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }), f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }), f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }), // Kontakt-/Akquise-Steuerung (siehe Collection „contacts"). f('keep_contacts', 'boolean', { interface: 'boolean', default: false, note: 'In Kontakte speichern: legt die Empfänger dieses Auftrags als Kontakte/Leads an (nur auf ausdrückliche Anforderung). Schützt zugleich die Empfängerdaten vor dem DSGVO-Strip.' }), f('contacts_created', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Kontakte für diesen Auftrag wurden bereits angelegt.' }), f('entries_stripped', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Empfängerdaten wurden nach Ablauf der Aufbewahrung anonymisiert.' }), dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }), 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 } }, { field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, 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']), f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }), f('envelope_sender', 'text', { interface: 'input-multiline', note: 'Absender fürs Kuvert (oben links, 5 Zeilen); manuelle Aufträge. Empfänger rutscht dann unten rechts.' }), f('envelope_sender_scale', 'float', { interface: 'input', default: 1, note: 'Relative Größe des Kuvert-Absenders (0.5–2, 1 = 100%).' }), 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('customer_offer', 'integer', { interface: 'input', note: 'ID des gewählten Kundenangebots (Aufschläge sind im Preis).' }), f('product_name_suffix', 'string', { interface: 'input', note: 'Namens-Zusatz aus dem Kundenangebot, z. B. „auf bedrucktem Papier".' }), 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) – erstes/primäres.' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, // Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste. f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }), // 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.' }), f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }), m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }), // Rechnungs-Positionsaufstellung (aus der Preisberechnung) – Basis für die sevDesk-Rechnung. f('price_lines', 'json', { interface: 'input-code', note: 'Positionsaufstellung [{label, amount, per_unit}] zum Auftragszeitpunkt.' }), // sevDesk-Rechnung (automatisch erzeugt). f('sevdesk_invoice_id', 'string', { interface: 'input', note: 'sevDesk-Rechnungs-ID (Entwurf/gebucht).' }), f('sevdesk_invoice_number', 'string', { interface: 'input', note: 'sevDesk-Rechnungsnummer.' }), { 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.' }), f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }), ] }, { 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: {} }, // Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem // format-Schlüssel und springt nie selbstständig auf ein anderes Format. // Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird. f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }), 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('default_format', 'string', { interface: 'input', note: 'Standard-Template für dieses Format (Format-Key: a4, a6_hoch, a6_quer, dinlang, c6). Leer = kein Standard.' }), 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 }), ] }, // Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das // Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich // eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend). { collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [ f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }), f('name', 'string', { interface: 'input', required: true }), dropdown('kind', ['brief', 'umschlag', 'postkarte'], { default: 'brief' }), f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }), f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }), f('margin_top_mm', 'float', { interface: 'input', default: 25 }), f('margin_left_mm', 'float', { interface: 'input', default: 20 }), f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat).' }), f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }), f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }), // Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine). m2o('print_template', 'format_templates'), f('active', 'boolean', { interface: 'boolean', default: true }), ] }, { 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.' }), // Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage // für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem // an der Maschine eingelegten Format entspricht. f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }), f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }), 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: {} }, ] }, // API-Zugänge für Kunden (Public-API). Keys werden nur als SHA-256-Hash gespeichert. { collection: 'api_clients', meta: { icon: 'vpn_key', note: 'API-Zugänge für Kunden (Public-API).' }, fields: [ f('name', 'string', { interface: 'input', required: true, note: 'Bezeichnung, z. B. Firmenname.' }), f('key_plain', 'string', { interface: 'input', note: 'Wird beim Anlegen automatisch erzeugt und EINMALIG angezeigt – jetzt kopieren! Verschwindet bei der nächsten Änderung. Nur der Hash bleibt gespeichert.' }), f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }), f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }), m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }), // Mehrere erlaubte Produkte (m2m). Die Order gibt per „product" an, welches genutzt wird. { field: 'products', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'], note: 'Erlaubte Produkte für diesen Zugang (Mehrfachauswahl).' }, schema: null }, // Einzelprodukt (optional, Fallback/alt) – wird nur genutzt, wenn keine m2m-Produkte gesetzt sind. m2o('product', 'products', { note: 'Einzelprodukt (Fallback). Für mehrere Produkte „products" nutzen.' }), f('active', 'boolean', { interface: 'boolean', default: true }), f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }), f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }), f('last_used', 'timestamp', { interface: 'datetime' }), // AGB/Vertrag – einmalige Zustimmung beim Onboarding festhalten (Nachweis). f('terms_version', 'string', { interface: 'input', note: 'Akzeptierte AGB-Version, z. B. „2026-01".' }), f('terms_accepted_at', 'timestamp', { interface: 'datetime', note: 'Zeitpunkt der AGB-/Vertragszustimmung.' }), f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }), ] }, // Junction API-Zugang ↔ Produkt (m2m für api_clients.products). { collection: 'api_client_products', meta: { icon: 'link', note: 'Junction API-Zugang ↔ Produkt.', hidden: true }, fields: [ m2o('api_client', 'api_clients', { onDelete: 'CASCADE' }), m2o('product', 'products', { onDelete: 'CASCADE' }), ] }, // 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' }, // Voreingestellte Platzierung relativ zum Marker im PDF (§§SIG1§§ …). // Wird im Signatur-Modul als Vorgabe geladen, dort manuell überschreibbar. f('marker_key', 'string', { interface: 'input', default: 'SIG1', note: 'Marker-Token im PDF (ohne §§), z. B. SIG1.' }), f('offset_x_mm', 'float', { interface: 'input', default: 0, note: 'Versatz X zum Marker (mm).' }), f('offset_y_mm', 'float', { interface: 'input', default: 0, note: 'Versatz Y zum Marker (mm).' }), f('width_mm', 'float', { interface: 'input', default: 40, note: 'Breite der Signatur (mm).' }), { 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: {} }, ] }, // Kundenspezifische Angebote: frei benannte Aufschläge (pro Stück) + Namens-Zusatz, // die der eingeloggte Kunde im Konfigurator auswählt. Kein Festpreis (Phase 2). // Notizen je zusammengefasster Charge (Schlüssel = Kunde|Produkt|Versandtag|Eingang). { collection: 'charge_notes', meta: { icon: 'sticky_note_2', note: 'Notizen je Charge (Produktions-Board).', hidden: true }, fields: [ f('charge_key', 'string', { interface: 'input', unique: true, required: true, note: 'Charge-Schlüssel (kunde|produkt|versandtag|eingang).' }), f('note', 'text', { interface: 'input-multiline' }), { field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} }, ] }, // Logs & Fehler vom Produktions-Agenten und von AutoRecover (Plotter-Fehler). { collection: 'agent_logs', meta: { icon: 'article', note: 'Logs & Fehlermeldungen vom Produktions-Agenten / AutoRecover.' }, fields: [ m2o('machine', 'machines', { onDelete: 'SET NULL', note: 'Maschine (optional).' }), dropdown('level', ['info', 'warn', 'error'], { default: 'info' }), f('source', 'string', { interface: 'input', note: 'Quelle: agent | plotter | recover | restart.' }), f('message', 'text', { interface: 'input-multiline' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, // Befehle an den Agenten (z. B. Programm-Neustart). Der Agent pollt „pending". { collection: 'agent_commands', meta: { icon: 'terminal', note: 'Befehle an den Agenten (Programm-Neustart). Der Agent arbeitet „pending" ab.' }, fields: [ m2o('machine', 'machines', { onDelete: 'CASCADE', note: 'Zielmaschine/-agent.' }), dropdown('type', ['restart'], { default: 'restart' }), f('target', 'string', { interface: 'input', note: 'Optionaler Programmname/-befehl (überschreibt Agent-Config).' }), dropdown('status', ['pending', 'done', 'error'], { default: 'pending' }), f('result', 'text', { interface: 'input-multiline', note: 'Ergebnis/Fehler vom Agenten.' }), { 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: 'customer_offers', meta: { icon: 'local_offer', note: 'Kundenangebote: Aufschläge/Name-Zusatz je Kunde+Produkt (Kunde wählt im Konfigurator).' }, fields: [ m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), m2o('product', 'products', { onDelete: 'CASCADE' }), f('label', 'string', { interface: 'input', note: 'Angezeigte Bezeichnung, z. B. „Auf bedrucktem Papier".' }), f('name_suffix', 'string', { interface: 'input', note: 'Zusatz an den Produktnamen, z. B. „auf bedrucktem Papier".' }), f('surcharges', 'json', { interface: 'list', note: 'Aufschläge pro Stück (netto): Bezeichnung + Betrag.', options: { fields: [ { field: 'label', type: 'string', name: 'Bezeichnung', meta: { interface: 'input', width: 'half', options: { placeholder: 'z. B. bedrucktes Papier' } } }, { field: 'amount', type: 'float', name: 'Betrag netto (€/Stück)', meta: { interface: 'input', width: 'half' } }, ] } }), f('allow_multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitigen Druck erlauben.' }), f('note', 'text', { interface: 'input-multiline', note: 'Hinweis an den Kunden (optional).' }), f('active', 'boolean', { interface: 'boolean', default: true }), { 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('bcc_team', 'boolean', { interface: 'boolean', default: false, note: 'Kopie an mich (Team-Adresse aus email_settings) als BCC senden.' }), 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('team_email', 'string', { interface: 'input', note: 'Interne Benachrichtigungen (neue Bestellung/Muster/Anfrage). Leer = keine Team-Mails.' }), 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.' }), ] }, // Frei anlegbare Mustertexte (beliebig viele) – erscheinen im „Manueller Druck"- // Dropdown. Ersetzt langfristig die feste Du/Sie-Vorgabe (die als Fallback bleibt). { collection: 'muster_texts', meta: { icon: 'science', note: 'Frei anlegbare Mustertexte für den manuellen Druck.' }, fields: [ f('label', 'string', { interface: 'input', required: true, note: 'Bezeichnung im Dropdown.' }), f('text', 'text', { interface: 'input-multiline', note: 'Mustertext. Platzhalter [[…]] erlaubt.' }), f('sort', 'integer', { interface: 'input' }), f('active', 'boolean', { interface: 'boolean', default: true }), ] }, // 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: {} }, ] }, // Kontakte/Leads – Empfänger gesendeter Schriftstücke. Werden bei Versand // automatisch angelegt (Hook) und nach 30 Tagen wieder gelöscht, außer der // Auftrag ist als „Kontakte behalten" (Akquise) markiert. Telefon/E-Mail sind // manuell zu pflegen (Empfänger liefern sie nicht mit). CSV-Import/-Export // über die normale Collection-Seite. { collection: 'contacts', meta: { icon: 'contacts', note: 'Kontakte/Leads – Empfänger gesendeter Schriftstücke.' }, fields: [ f('salutation', 'string', { interface: 'input' }), f('first_name', 'string', { interface: 'input' }), f('last_name', 'string', { interface: 'input' }), f('company', '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('email', 'string', { interface: 'input', note: 'Manuell nachtragen.' }), f('phone', 'string', { interface: 'input', note: 'Manuell nachtragen. Klick im Kontakte-Modul öffnet GroundWire.' }), f('notes', 'text', { interface: 'input-multiline', note: 'Anruf-/Lead-Notizen.' }), dropdown('lead_status', ['neu', 'kontaktiert', 'interessiert', 'kein_interesse', 'kunde'], { default: 'neu', note: 'Nur Kontakte mit „kein_interesse" werden nach 30 Tagen automatisch gelöscht; alle anderen bleiben.' }), m2o('source_order', 'orders', { onDelete: 'SET NULL', note: 'Auftrag, aus dem der Kontakt stammt.' }), m2o('source_entry', 'order_entries', { onDelete: 'SET NULL', note: 'Empfängerzeile, aus der der Kontakt stammt.' }), f('sent_at', 'timestamp', { interface: 'datetime', note: 'Versandzeitpunkt – Grundlage für die 30-Tage-Löschung.' }), { 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: {} }, ] }, // Standardaktionen im Kontakte-Modul (Rückruf-Wiedervorlage, Angebot senden). // „angebot" verschickt sofort die Eckdaten an die interne Adresse; „rueckruf" // löst am fälligen Tag früh eine Erinnerungs-Mail aus (Hook skrift-contacts). { collection: 'contact_actions', meta: { icon: 'task_alt', hidden: true, note: 'Aktionen/Wiedervorlagen je Kontakt (Rückruf, Angebot).' }, fields: [ m2o('contact', 'contacts', { onDelete: 'CASCADE' }), dropdown('type', ['rueckruf', 'angebot', 'mail'], { default: 'rueckruf' }), f('due_date', 'timestamp', { interface: 'datetime', note: 'Fälligkeit (nur Rückruf) – Basis der Erinnerungs-Mail.' }), f('note', 'text', { interface: 'input-multiline', note: 'Notiz zur Aktion (optional).' }), dropdown('status', ['offen', 'erledigt'], { default: 'offen' }), f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }), { 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' } }); // m2m api_clients.products ↔ products über api_client_products await api('PATCH', '/relations/api_client_products/api_client', { meta: { one_field: 'products', junction_field: 'product' } }); await api('PATCH', '/relations/api_client_products/product', { meta: { junction_field: 'api_client' } }); 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, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 }, // Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere // Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen). { key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, 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); // Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND // bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen // können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht). try { const us = productByKey.unterschriftenservice; if (us?.id) { const patch = {}; if (us.input_mode !== 'datei') patch.input_mode = 'datei'; if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel'; if (Object.keys(patch).length) { await api('PATCH', `/items/products/${us.id}`, patch); log('unterschriftenservice →', JSON.stringify(patch)); } } } catch { /* nicht kritisch */ } 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 }, // Postkarten-Layout (Text links, Adresse rechts) – NUR manueller Druck, nicht in der App. { product: productByKey.postkarten.id, key: 'a6_postkarte', name: 'A6 Postkarte (Text + Adresse)', active: false, show_manual: true }, { product: productByKey.postkarten.id, key: 'dinlang_postkarte', name: 'DIN lang Postkarte (Text + Adresse)', active: false, show_manual: 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: 'sammelversand_klein', label: 'Sammelversand (bis 5 Stück)', category: 'versand', price: 1.80, 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, a/e je Produkt. const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))'; if (!ps || !ps.multiplier_formula) { await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT }); log('seed multiplier-formel'); } else { // Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent // (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die // Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten. const m = String(ps.multiplier_formula).match( /^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/, ); if (m) { const aOld = Number(m[1]); const eOld = Number(m[2]); await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT }); const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1'); for (const p of (prods || [])) { const patch = {}; if (p.price_factor_a == null) patch.price_factor_a = aOld; if (p.price_factor_e == null) patch.price_factor_e = eOld; if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch); } log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`); } } // 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}}` }, // Separate, frei editierbare Mails für Unterschriftenservice- und Extern-Bestellungen. { key: 'unterschriften_eingang', label: 'Unterschriften: Eingang', subject: 'Ihr Unterschriften-Auftrag {{order_number}} ist eingegangen', append_signature: true, attach_agb: false, body: `

${G}

vielen Dank – Ihr Auftrag {{order_number}} für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.

` }, { key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false, body: `

${G}

vielen Dank – Ihre Bestellung {{order_number}} ist bei uns eingegangen und geht direkt in die Produktion.

` }, ]; for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t }); // Druck-Templates – Werte 1:1 aus der config.json des alten Job-Managers. // Je Format gibt es zwei Varianten (v1/v2); die zweite ist die zusätzliche // Platzierung (u. a. für individuelle Kuvert-Beschriftung). Werte werden per // Upsert gesetzt, damit falsche Altwerte beim Bootstrap korrigiert werden. const formatTemplates = [ { key: 'a4_single', name: 'A4 Brief', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6_v1', name: 'A6 Hochformat – Variante 1', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6_v2', name: 'A6 Hochformat – Variante 2', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 }, { key: 'a6l_v1', name: 'A6 Querformat – Variante 1', type: 'brief', width: 5.8, height: 4.1, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6l_v2', name: 'A6 Querformat – Variante 2', type: 'brief', width: 5.8, height: 4.1, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 }, // DIN Lang – v1 = Empfängeradresse, v2 = individuelle Beschriftung. { key: 'kuvert_a_v1', name: 'Kuvert DIN Lang – Variante 1', type: 'umschlag', width: 4.33, height: 8.27, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 }, { key: 'kuvert_a_v2', name: 'Kuvert DIN Lang – Variante 2', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 }, // C6 – v1 = Empfängeradresse, v2 = individuelle Beschriftung. { key: 'kuvert_b_v1', name: 'Kuvert C6 – Variante 1', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 }, { key: 'kuvert_b_v2', name: 'Kuvert C6 – Variante 2', type: 'umschlag', width: 4.33, height: 6.38, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 }, ]; // Nur bei LEERER Collection anlegen (Erstinstallation). Der Betreiber pflegt die // Templates danach selbst (löschen/umbenennen/eigene) – Bootstrap fügt sie NICHT // wieder hinzu. Vorhandene werden bewusst nicht überschrieben. const ftVorhanden = await api('GET', `/items/format_templates?limit=1&fields=id`); if (!ftVorhanden || !ftVorhanden.length) { for (const t of formatTemplates) { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); } } else { log('format_templates vorhanden → Seed übersprungen (vom Betreiber gepflegt)'); } // Rendering-Formate (zentral fürs Backend). Werte = bisherige Defaults. const renderFormats = [ { key: 'a4', name: 'A4', kind: 'brief', page_width_mm: 210, page_height_mm: 297, margin_top_mm: 25, margin_left_mm: 20, line_height_factor: 1.35, line_limit: 27, active: true }, { key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.3, line_limit: 14, active: true }, { key: 'a6l', name: 'A6 Querformat', kind: 'brief', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true }, { key: 'din_lang', name: 'DIN Lang Kuvert', kind: 'umschlag', page_width_mm: 220, page_height_mm: 110, margin_top_mm: 50, margin_left_mm: 10, line_height_factor: 1.2, active: true }, { key: 'c6', name: 'C6 Kuvert', kind: 'umschlag', page_width_mm: 162, page_height_mm: 114, margin_top_mm: 60, margin_left_mm: 10, line_height_factor: 1.2, active: true }, // Postkarten (nur manueller Druck): Text links, Absender+Empfänger rechts, // Briefmarkenfeld oben rechts frei. Ein SVG je Karte (ein Plotter-Durchlauf). { key: 'a6_postkarte', name: 'A6 Postkarte (Text + Adresse)', kind: 'postkarte', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.2, active: true }, { key: 'dinlang_postkarte', name: 'DIN lang Postkarte (Text + Adresse)', kind: 'postkarte', page_width_mm: 210, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.2, active: true }, ]; // Upsert (nicht nur anlegen), damit falsche Alt-Maße korrigiert werden – z. B. // Postkarten (a6p/a6l), die sonst nicht aufs Kartenformat passen. for (const rf of renderFormats) { const vorhanden = await api('GET', `/items/render_formats?filter[key][_eq]=${encodeURIComponent(rf.key)}&limit=1&fields=id`); if (vorhanden && vorhanden.length) await api('PATCH', `/items/render_formats/${vorhanden[0].id}`, rf); else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); } } // 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(), team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@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'); } // muster_texts – Starteinträge (Du/Sie) nur anlegen, wenn noch keine da sind. const mtx = await api('GET', '/items/muster_texts?limit=1'); if (!mtx || !mtx.length) { await api('POST', '/items/muster_texts', { label: 'Sie-Form', sort: 1, active: true, text: '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' }); await api('POST', '/items/muster_texts', { label: 'Du-Form', sort: 2, active: true, text: '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' }); log('seed muster_texts'); } } // ── 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', 'contacts', 'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', '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`); } /** * Legt eine Berechtigung an ODER gleicht eine bestehende an. * * Wichtig: Früher wurde ein vorhandener Eintrag unangetastet gelassen. Dadurch * blieben einmal angelegte, zu enge Feldlisten für immer bestehen – spätere * Erweiterungen (z. B. production_status für den Produktions-Agent) kamen nie * an und führten zu 403-Fehlern. Der Bootstrap ist die Quelle der Wahrheit, * deshalb werden Felder und Filter jetzt aktiv nachgezogen. */ 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&fields=id,fields,permissions`); if (existing && existing.length) { const cur = existing[0]; const gleich = JSON.stringify(cur.fields ?? null) === JSON.stringify(fields) && JSON.stringify(cur.permissions ?? {}) === JSON.stringify(permissions); if (!gleich) { await api('PATCH', `/permissions/${cur.id}`, { fields, permissions }); log('perm ~', `${collection}.${action}`); } 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', 'billing_address', 'shipping_address', 'alt_emails'], { 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', 'billing_address', 'shipping_address'], { 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'); // Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice). await ensurePermission(custPol.id, 'directus_files', 'create'); await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } }); // 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', 'render_formats']) 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'); // Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck // zusammen und lädt sie dafür über /assets). await ensurePermission(svcPol.id, 'directus_files', 'read'); 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', 'contacts']) 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', 'muster_texts', 'machines', 'format_templates', 'render_formats', '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}}'], ['contacts', 'source_order', '{{order_number}}'], ]; 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); });