Files
skrift-programme/Docker/directus/bootstrap/bootstrap.mjs
Lucas Orth f77ddb6a4c Formate zentral aus Directus + Kuvert-/Agent-/Login-Fixes
Formate zentralisiert (Phase 1)
- Neue Collection render_formats (Seitengröße, Ränder, Zeilenhöhe/-limit +
  Plotter-Template) als einzige Quelle. Backend liest sie gecacht (Start +
  stündlich, synchroner Zugriff → keine Latenz) mit eingebauten Defaults als
  Fallback; manueller Trigger via POST /api/formats/refresh.
- Ermöglicht frei definierbare Formate ohne Codeänderung.

Kuvert-Templates
- Werte 1:1 aus dem alten Job-Manager übernommen (falsche/vertauschte Maße
  korrigiert, per Upsert) und je Format die zweite Variante ergänzt
  (individuelle Beschriftung). Druck-Popup schlägt bei freiem Text Variante 2 vor.

Produktions-Agent
- Datei-Endpunkt liefert {files} ohne data-Hülle → eigener get_raw statt des
  data-auspackenden get; Dokumentauswahl positionsbasiert (robust gegen 0-/1-
  basierte Dateinamen).

Bootstrap
- Ungültiger DIRECTUS_TOKEN wird erkannt und fällt auf ADMIN_EMAIL/PASSWORD
  zurück, statt mit einem 401 abzubrechen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:05:09 +02:00

1101 lines
70 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;
}
/** 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' }),
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 }),
] },
// 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'], { 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.' }),
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 – 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 },
];
for (const t of formatTemplates) {
const vorhanden = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(t.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) await api('PATCH', `/items/format_templates/${vorhanden[0].id}`, t);
else { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); }
}
// 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 },
];
for (const rf of renderFormats) await seedItem('render_formats', 'key', rf);
// 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', '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'],
{ 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', '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');
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', '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}}'],
];
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); });