Files
skrift-programme/Docker/directus/bootstrap/bootstrap.mjs
Lucas Orth 445ebdec5a Bootstrap: bestehende Berechtigungen angleichen statt überspringen
ensurePermission brach ab, sobald für eine Collection bereits ein
Berechtigungseintrag existierte. Dadurch blieben einmal angelegte, zu enge
Feldlisten dauerhaft bestehen und spätere Erweiterungen kamen nie an --
der Produktions-Agent lief deshalb in 403-Fehler beim Zurückschreiben von
jobs.status und orders.production_status.

Vorhandene Einträge werden jetzt per PATCH auf die im Bootstrap definierten
Felder und Filter nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:44:58 +02:00

1042 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Directus Bootstrap
* ---------------------------------------------------------------------------
* Legt Collections, Felder, Beziehungen, Rollen/Policies/Rechte und Seed-Daten
* idempotent an (mehrfach ausführbar – Vorhandenes wird übersprungen).
*
* Ausführen (Node >= 18, kein npm install nötig):
* DIRECTUS_URL=https://admin.skrift.de \
* DIRECTUS_TOKEN=<admin-static-token> node bootstrap.mjs
* ODER mit Login statt Token:
* DIRECTUS_URL=... ADMIN_EMAIL=... ADMIN_PASSWORD=... node bootstrap.mjs
*
* Flags (optional):
* SKIP_PERMISSIONS=1 nur Schema + Seed, ohne Rollen/Rechte
*
* Zielt auf Directus 11 (Policy-basiertes Rechtemodell). Da nicht gegen eine
* Live-Instanz getestet: bei Fehlern die Konsolenausgabe schicken, dann fixe ich
* die betroffene Stelle gezielt.
*/
const DIRECTUS_URL = (process.env.DIRECTUS_URL || '').replace(/\/$/, '');
let token = process.env.DIRECTUS_TOKEN || '';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const SKIP_PERMISSIONS = process.env.SKIP_PERMISSIONS === '1';
if (!DIRECTUS_URL) { console.error('DIRECTUS_URL fehlt.'); process.exit(1); }
// ── Erlaubter Zeichensatz: 1:1 aus skrift-job-manager (job-app.js) ──────────
// Basis-Klasse (ohne \n\r\t – die fügt das Frontend für mehrzeilige Felder an).
const CHAR_WHITELIST =
'\\x20-\\x5F\\x61-\\x7B\\x7D' +
'ÄÖÜäöüß' + // ÄÖÜäöüß
'¡£§«»¿' + // ¡£§«»¿
'‐–—‘’“”€'; // ‐ – — ' ' " " €
// ── HTTP-Helfer ─────────────────────────────────────────────────────────────
async function api(method, path, body) {
const res = await fetch(DIRECTUS_URL + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { /* non-json */ }
if (!res.ok) {
const msg = data?.errors?.[0]?.message || text || res.statusText;
const err = new Error(`${method} ${path} → ${res.status}: ${msg}`);
err.status = res.status;
throw err;
}
return data?.data ?? data;
}
async function login() {
if (token) return;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error('Weder DIRECTUS_TOKEN noch ADMIN_EMAIL/ADMIN_PASSWORD gesetzt.');
process.exit(1);
}
const res = await fetch(DIRECTUS_URL + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (!res.ok) { console.error('Login fehlgeschlagen:', await res.text()); process.exit(1); }
token = (await res.json()).data.access_token;
}
const log = (...a) => console.log(' ', ...a);
// ── Feld-DSL ────────────────────────────────────────────────────────────────
const ID = {
field: 'id', type: 'integer',
meta: { hidden: true, interface: 'input', readonly: true },
schema: { is_primary_key: true, has_auto_increment: true },
};
const f = (field, type, extra = {}) => ({
field, type,
meta: { interface: extra.interface, options: extra.options, note: extra.note,
required: extra.required || false, special: extra.special },
schema: { is_nullable: extra.nullable !== false, is_unique: extra.unique || false,
default_value: extra.default },
});
const dropdown = (field, choices, extra = {}) =>
f(field, 'string', { interface: 'select-dropdown',
options: { choices: choices.map((v) => ({ text: v, value: v })) }, ...extra });
const m2o = (field, related, extra = {}) =>
({ field, type: related === 'directus_users' || related === 'directus_files' ? 'uuid' : 'integer',
meta: { interface: 'select-dropdown-m2o', note: extra.note, special: null },
schema: { is_nullable: extra.nullable !== false }, __related: related, __onDelete: extra.onDelete || 'SET NULL' });
// 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 = `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#333;">
<tr><td style="padding:20px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="background:#fff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#1a365d;padding:30px 40px;text-align:center;"><h1 style="margin:0;color:#fff;font-size:26px;">Bestellbestätigung</h1></td></tr>
<tr><td style="padding:36px 40px 8px;"><p style="margin:0 0 16px;font-size:18px;">Guten Tag {{name}},</p><p style="margin:0 0 16px;">vielen Dank für Ihre Bestellung bei Skrift. Wir haben sie erhalten und bearbeiten sie schnellstmöglich.</p></td></tr>
<tr><td style="padding:0 40px 24px;"><table role="presentation" width="100%" style="background:#f0f7ff;border-radius:8px;border-left:4px solid #1a365d;"><tr><td style="padding:18px;"><p style="margin:0;font-size:14px;color:#666;">Ihre Bestellnummer:</p><p style="margin:4px 0 0;font-size:22px;font-weight:700;color:#1a365d;">{{order_id}}</p></td></tr></table></td></tr>
<tr><td style="padding:8px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Ihre Bestellung</h2>
<table role="presentation" width="100%">
<tr><td width="40%" style="padding:6px 0;color:#666;">Produkt:</td><td style="padding:6px 0;font-weight:600;">{{product}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">Menge:</td><td style="padding:6px 0;font-weight:600;">{{menge}} Stück</td></tr>
<tr><td style="padding:6px 0;color:#666;">Format:</td><td style="padding:6px 0;font-weight:600;">{{format}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">Versand:</td><td style="padding:6px 0;font-weight:600;">{{versand}}</td></tr>
</table>{{kuvert_block}}</td></tr>
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Preisübersicht</h2>
<table role="presentation" width="100%">
<tr><td style="padding:6px 0;color:#666;">Zwischensumme (netto):</td><td style="padding:6px 0;text-align:right;">{{netto}} {{waehrung}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">MwSt. ({{mwst_satz}} %):</td><td style="padding:6px 0;text-align:right;">{{mwst_betrag}} {{waehrung}}</td></tr>
<tr><td colspan="2" style="padding:8px 0;"><hr style="border:none;border-top:2px solid #1a365d;margin:0;"></td></tr>
<tr><td style="padding:6px 0;font-size:17px;font-weight:700;color:#1a365d;">Gesamt (brutto):</td><td style="padding:6px 0;text-align:right;font-size:17px;font-weight:700;color:#1a365d;">{{brutto}} {{waehrung}}</td></tr>
</table></td></tr>
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Rechnungsadresse</h2><p style="margin:0;line-height:1.6;">{{rechnungsadresse}}</p></td></tr>
{{lieferadresse_block}}
<tr><td style="padding:16px 40px;"><h2 style="margin:0 0 10px;font-size:18px;color:#1a365d;">Wie geht es weiter?</h2><ol style="margin:0;padding-left:20px;line-height:1.7;"><li>Wir prüfen Ihre Bestellung und bereiten die Produktion vor.</li><li>Nach Fertigstellung erhalten Sie eine Versandbestätigung.</li></ol></td></tr>
<tr><td style="background:#f5f5f5;padding:24px 40px;text-align:center;border-top:1px solid #e0e0e0;"><p style="margin:0 0 8px;font-size:14px;color:#666;">Mit freundlichen Grüßen<br><strong>Ihr Skrift-Team</strong></p><p style="margin:12px 0 0;font-size:12px;color:#999;">{{footer}}</p></td></tr>
</table></td></tr></table>`.trim();
const templates = [
{ key: 'login_code', label: 'Anmeldecode', subject: 'Ihr Anmeldecode: {{code}}', append_signature: true, attach_agb: false,
body: `<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
`<p style="font-size:28px;font-weight:700;letter-spacing:4px">{{code}}</p>` +
`<p>Der Code ist 10 Minuten gültig. Falls Sie keine Anmeldung angefordert haben, ignorieren Sie diese E-Mail.</p>` },
{ 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: `<p>${G}</p><p>Ihre Zahlung zu Auftrag <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten die Produktion.</p>` },
{ key: 'in_produktion', label: 'Status: in Produktion', subject: '{{order_id}} ist in Produktion', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist jetzt in Produktion.</p>` },
{ key: 'versendet', label: 'Status: versendet', subject: '{{order_id}} wurde versendet', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihre Schriftstücke zu Auftrag <strong>{{order_id}}</strong> wurden versendet bzw. an Sie übergeben. Vielen Dank für Ihr Vertrauen!</p>` },
{ key: 'abgeschlossen', label: 'Status: abgeschlossen', subject: '{{order_id}} abgeschlossen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
{ key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{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: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
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`);
}
/**
* 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'],
{ 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); });