Files
skrift-directus/bootstrap/bootstrap.mjs
s4luorth f8f219a3fa Bestandspflege: Material je Standort, Auto-Abbuchung beim Druck
Datenmodell (bootstrap):
- Collections standorte (seed Skrift/Signascript), inventory (Papier/Kuvert/
  Briefmarke je Standort, initiale Zeilen) und inventory_log.
- directus_users.produktionsstandort (m2o standorte; USER_FIELDS-Relation wird
  jetzt angelegt); orders.frankiert + porto_klasse; jobs.inventory_done.

Neue Hook-Extension skrift-inventory:
- Bucht beim Job-Status 'printed' automatisch ab (am Kunden-Standort, +10 %):
  Brief->Papier (phys. Format), Kuvert->Kuvert + Briefmarke (nur wenn frankiert;
  Inland Standard / Ausland International, bzw. porto_klasse). Idempotent.

produktion: neuer Tab 'Bestand' - je Standort, Unterschreitung rot
('nachbestellen'), manuelles Zubuchen + Mindestbestand/Bestand editierbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 08:34:27 +02:00

1518 lines
109 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' }),
// Genau zwei feste Adressen je Konto (ohne Namen/Bezeichnung): Rechnung + Lieferung.
// Werden automatisch an der passenden Stelle im Bestellvorgang eingesetzt.
f('billing_address', 'json', { interface: 'input-code', note: 'Rechnungsadresse {street,zip,city,country}.' }),
f('shipping_address', 'json', { interface: 'input-code', note: 'Lieferadresse {street,zip,city,country}.' }),
// Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten.
f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }),
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
// Produktionsstandort dieses Kunden (Bestandsabbuchung). Leer = Skrift (Standard).
m2o('produktionsstandort', 'standorte', { note: 'Wo die Aufträge dieses Kunden produziert werden. Leer = Skrift.' }),
f('sevdesk_contact_id', 'string', { interface: 'input', hidden: true, note: 'Verknüpfter sevDesk-Kontakt (für Rechnungen).' }),
// Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }),
{ field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null },
{ field: 'discounts', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Kundenspezifische Rabatte.' }, schema: null },
{ field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null },
];
// directus_users-Felder, die Skrift nicht braucht – werden ausgeblendet.
const HIDE_USER_FIELDS = ['location', 'title', 'description', 'tags', 'avatar', 'language',
'appearance', 'theme_light', 'theme_dark', 'theme_light_overrides', 'theme_dark_overrides',
'text_direction', 'email_notifications'];
// ── Collections ─────────────────────────────────────────────────────────────
const collections = [
{ collection: 'products', meta: { icon: 'inventory_2', note: 'Produkte inkl. Kontakt-Produkte.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('name', 'string', { interface: 'input', required: true }),
f('description', 'text', { interface: 'input-multiline' }),
dropdown('type', ['letter', 'postcard', 'envelope', 'sample', 'contact'], { required: true }),
dropdown('visibility', ['alle', 'privat', 'unternehmen'], { default: 'alle', note: 'Für wen im Konfigurator sichtbar.' }),
dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }),
f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }),
// Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt.
// Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen.
f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }),
// Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel):
// a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform
// (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch).
// Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2.
f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }),
f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }),
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
// unterschreibendes PDF beim Unterschriftenservice).
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
{ collection: 'formats', meta: { icon: 'aspect_ratio', note: 'Formate je Produkt (admin-erweiterbar).' }, fields: [
m2o('product', 'products', { onDelete: 'CASCADE' }),
f('key', 'string', { interface: 'input', required: true }),
f('name', 'string', { interface: 'input', required: true }),
f('active', 'boolean', { interface: 'boolean', default: true, note: 'Im Konfigurator (app.skrift.de) sichtbar.' }),
f('show_manual', 'boolean', { interface: 'boolean', default: true, note: 'Im manuellen Druck (Admin) sichtbar.' }),
f('show_check', 'boolean', { interface: 'boolean', default: false, note: 'Im Textmengencheck (app.skrift.de/textmenge) wählbar – auch ohne öffentliche Bestellung.' }),
// Für das Ausrichtungs-Dropdown im Textmengencheck: gleiche family + je Ausrichtung
// ein Eintrag (hoch/quer). Leere orientation = Format hat nur eine Ausrichtung.
f('family', 'string', { interface: 'input', note: 'Format-Familie fürs Dropdown (z. B. A4, A5, A6, DIN lang). Leer = eigener Eintrag.' }),
dropdown('orientation', ['hoch', 'quer'], { note: 'Ausrichtung innerhalb der Familie. Leer = keine Wahl (nur eine Ausrichtung).' }),
] },
{ collection: 'motifs', meta: { icon: 'image', note: 'Motiv-Katalog für Postkarten.' }, fields: [
f('name', 'string', { interface: 'input', required: true }),
m2o('image', 'directus_files'),
dropdown('orientation', ['quer', 'hochkant'], { default: 'quer', note: 'Passend zum Postkartenformat.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
{ collection: 'price_items', meta: { icon: 'sell', note: 'Alle Options-/Zusatzpreise – hier editierbar.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('label', 'string', { interface: 'input', required: true }),
dropdown('category', ['versand', 'kuvert', 'beschriftung', 'zusatzleistung', 'zuschlag'], { required: true }),
f('price', 'float', { interface: 'input', required: true }),
dropdown('unit', ['einmalig', 'pro_stueck', 'prozent'], { default: 'einmalig' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ collection: 'pricing_settings', meta: { icon: 'calculate', singleton: true, note: 'Formel + globale Werte.' }, fields: [
f('formula', 'text', { interface: 'input-multiline', note: 'Grundpreis-Formel. Variablen: base_price, menge.' }),
// Mengen-Multiplikator (Kleinmengen-Aufschlag), je Segment editierbar.
// Variablen: qty, norm_b, norm_c, mind_b, mind_c, base_price. Funktionen:
// sqrt/abs/min/max/round. Vergleiche + Ternär (cond ? a : b) erlaubt.
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price, a, e (a/e je Produkt). Funktionen: sqrt, pow, min, max, round …' }),
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
{ field: 'quantity_discounts', type: 'json',
meta: { interface: 'list', note: 'Mengenrabatt-Staffel (ab X Stück Y %). Höchste passende Stufe greift.',
options: { fields: [
{ field: 'min_qty', name: 'ab Menge', type: 'integer', meta: { interface: 'input', width: 'half' } },
{ field: 'percent', name: 'Rabatt %', type: 'float', meta: { interface: 'input', width: 'half' } },
] } },
schema: {} },
f('paypal_limit_net', 'float', { interface: 'input', default: 200, note: 'Ab diesem Netto-Wert nur Rechnung.' }),
f('char_whitelist', 'text', { interface: 'input-multiline', note: 'Erlaubte Zeichen (Regex-Klasse, 1:1 aus job-manager).' }),
f('currency', 'string', { interface: 'input', default: 'EUR' }),
// PayPal: NUR die Client-ID (öffentlich). Das Secret gehört nicht hierher –
// diese Collection ist für Gäste lesbar, und clientseitig wird es nicht gebraucht.
f('paypal_enabled', 'boolean', { interface: 'boolean', default: false }),
dropdown('paypal_mode', ['sandbox', 'live'], { default: 'sandbox' }),
f('paypal_client_id_sandbox', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Sandbox).' }),
f('paypal_client_id_live', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Live).' }),
] },
{ collection: 'vouchers', meta: { icon: 'confirmation_number', note: 'Gutscheine.' }, fields: [
f('code', 'string', { interface: 'input', unique: true, required: true }),
dropdown('type', ['prozent', 'fest'], { required: true }),
f('value', 'float', { interface: 'input', required: true }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('valid_until', 'timestamp', { interface: 'datetime' }),
f('max_uses', 'integer', { interface: 'input' }),
f('used_count', 'integer', { interface: 'input', default: 0 }),
] },
{ collection: 'orders', meta: { icon: 'receipt_long', note: 'Auftrag.' }, fields: [
f('order_number', 'string', { interface: 'input', unique: true, required: true }),
m2o('customer', 'directus_users', { note: 'Kunde = Login-Konto (Zuordnung per E-Mail).' }),
f('customer_email', 'string', { interface: 'input', required: true, note: 'Zuordnungsschlüssel.' }),
m2o('product', 'products'),
m2o('format', 'formats'),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
dropdown('font', ['tilda', 'alva', 'ellie']),
f('realistic', 'boolean', { interface: 'boolean', default: true, note: 'Natürliche Handschrift-Variation (Jitter/Neigung).' }),
m2o('api_client', 'api_clients', { onDelete: 'SET NULL', note: 'Gesetzt bei Aufträgen über die Public-API.' }),
dropdown('shipping_day', ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], { note: 'Vom Kunden gewünschter Versandtag (optional, v. a. Public-API).' }),
f('font_scale_letter', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Brief (0.8–1.5, 1 = Standard).' }),
f('font_scale_envelope', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Kuvert (0.8–1.5, 1 = Standard).' }),
f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }),
f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }),
f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }),
// Kontakt-/Akquise-Steuerung (siehe Collection „contacts").
f('keep_contacts', 'boolean', { interface: 'boolean', default: false, note: 'In Kontakte speichern: legt die Empfänger dieses Auftrags als Kontakte/Leads an (nur auf ausdrückliche Anforderung). Schützt zugleich die Empfängerdaten vor dem DSGVO-Strip.' }),
f('contacts_created', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Kontakte für diesen Auftrag wurden bereits angelegt.' }),
f('entries_stripped', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Empfängerdaten wurden nach Ablauf der Aufbewahrung anonymisiert.' }),
dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }),
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
{ default: 'entwurf' }),
// Produktionsstatus (fürs Kanban) – getrennt vom kaufmännischen Status.
dropdown('production_status', ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig', 'versendet', 'storniert'],
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
// Motiv-Freigabe (Postkarten mit Motiv-Upload): Kunde genehmigt den A6-Entwurf.
dropdown('approval_status', ['none', 'pending', 'approved', 'changes'],
{ default: 'none', note: 'Motiv-Freigabe: none=keine, pending=wartet auf Freigabe, approved=freigegeben, changes=Änderungswunsch.' }),
f('approval_token', 'string', { interface: 'input', hidden: true, note: 'Token für den Freigabe-Link (Einmal-Zugang zum Entwurf).' }),
f('approval_note', 'text', { interface: 'input-multiline', note: 'Änderungswunsch des Kunden zum Motiv-Entwurf.' }),
m2o('motif_layout', 'directus_files', { note: 'Generiertes A6-Drucklayout (Motiv mittig, randabfallend) zur Freigabe.' }),
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }),
f('envelope_sender', 'text', { interface: 'input-multiline', note: 'Absender fürs Kuvert (oben links, 5 Zeilen); manuelle Aufträge. Empfänger rutscht dann unten rechts.' }),
f('envelope_sender_scale', 'float', { interface: 'input', default: 1, note: 'Relative Größe des Kuvert-Absenders (0.5–2, 1 = 100%).' }),
dropdown('envelope_format', ['c6', 'dinlang', 'c4'], { note: 'Automatisch aus Format; c4 nur manueller Druck.' }),
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
m2o('motif', 'motifs'),
m2o('motif_upload', 'directus_files'),
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
f('customer_offer', 'integer', { interface: 'input', note: 'ID des gewählten Kundenangebots (Aufschläge sind im Preis).' }),
f('product_name_suffix', 'string', { interface: 'input', note: 'Namens-Zusatz aus dem Kundenangebot, z. B. „auf bedrucktem Papier".' }),
f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }),
// Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word).
{ field: 'source_file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte) – erstes/primäres.' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
// Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste.
f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }),
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
// Bestandsabbuchung Briefmarke: von uns frankiert? Leer = automatisch
// (Einzelversand = ja, Sammelversand = nein). Bei Sammelversand umstellbar.
f('frankiert', 'boolean', { interface: 'boolean', note: 'Von uns frankiert → Briefmarke wird abgebucht. Leer = automatisch (Einzel ja / Sammel nein).' }),
dropdown('porto_klasse', ['auto', 'standard', 'grossbrief', 'maxibrief'], { default: 'auto', note: 'Markentyp-Override. auto = Inland 0,95 € / Ausland 1,25 €.' }),
f('entries_count', 'integer', { interface: 'input', default: 0 }),
f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }),
f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }),
f('net_total', 'float', { interface: 'input' }),
f('vat_amount', 'float', { interface: 'input' }),
f('gross_total', 'float', { interface: 'input' }),
m2o('voucher', 'vouchers'),
dropdown('payment_method', ['paypal', 'rechnung']),
dropdown('payment_status', ['offen', 'bezahlt', 'storniert'], { default: 'offen' }),
f('payment_ref', 'string', { interface: 'input' }),
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }),
m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }),
// Rechnungs-Positionsaufstellung (aus der Preisberechnung) – Basis für die sevDesk-Rechnung.
f('price_lines', 'json', { interface: 'input-code', note: 'Positionsaufstellung [{label, amount, per_unit}] zum Auftragszeitpunkt.' }),
// sevDesk-Rechnung (automatisch erzeugt).
f('sevdesk_invoice_id', 'string', { interface: 'input', note: 'sevDesk-Rechnungs-ID (Entwurf/gebucht).' }),
f('sevdesk_invoice_number', 'string', { interface: 'input', note: 'sevDesk-Rechnungsnummer.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
// Anschrift, ganze Zeile) – wie im alten Job-Manager. Nur lesend/suchend.
{ field: 'entries_view', type: 'alias',
meta: { interface: 'skrift-order-entries', special: ['alias', 'no-data'],
note: 'Alle Empfänger durchsuchbar (Briefnummer + volle Anschrift).' },
schema: null },
// Empfängerzeilen direkt im Auftrag (Adresse bzw. alle Freitext-Zeilen
// bearbeitbar) – kein Wechsel auf eine eigene Seite nötig.
{ field: 'entries', type: 'alias',
meta: { interface: 'list-o2m', special: ['o2m'],
options: { template: '#{{letter_number}} · {{first_name}} {{last_name}} · {{zip}} {{city}}' },
note: 'Empfängerzeilen bearbeiten (Adresse korrigieren).' },
schema: null },
// m2m-Alias zu price_items über order_addons:
{ field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null },
// Präsentationsfeld: zeigt die generierten Dateien per Interface-Plugin
// (Direktzugriff auf den Ausgabe-Ordner, keine Kopie).
{ field: 'files_view', type: 'alias',
meta: { interface: 'skrift-order-files', special: ['alias', 'no-data'], note: 'Generierte Dateien (Direktzugriff).' },
schema: null },
// Präsentationsfeld: „Drucken"-Button (reiht Druck-Jobs für eine Maschine ein).
{ field: 'print_action', type: 'alias',
meta: { interface: 'skrift-print-button', special: ['alias', 'no-data'], note: 'In die Druck-Warteschlange einreihen.' },
schema: null },
] },
{ collection: 'order_entries', meta: { icon: 'table_rows', note: 'Empfängerzeilen (Tabelle/Excel).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('letter_number', 'integer', { interface: 'input', note: 'Briefnummer – systemvergeben, immer gesetzt.' }),
f('salutation', 'string', { interface: 'input' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('street', 'string', { interface: 'input' }),
f('house_no', 'string', { interface: 'input' }),
f('zip', 'string', { interface: 'input' }),
f('city', 'string', { interface: 'input' }),
f('country', 'string', { interface: 'input' }),
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }),
] },
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
m2o('price_item', 'price_items', { onDelete: 'CASCADE' }),
] },
// Plotter/Schreibmaschinen – im Admin pflegbar, Basis für die Warteschlange.
{ collection: 'machines', meta: { icon: 'print', note: 'Produktionsmaschinen (Plotter).' }, fields: [
f('name', 'string', { interface: 'input', required: true }),
f('base_url', 'string', { interface: 'input', required: true, note: 'z. B. http://192.168.2.32:90' }),
f('mid', 'string', { interface: 'input', note: 'Maschinen-ID an der Steuerung (optional).' }),
f('username', 'string', { interface: 'input', note: 'Login an der Steuerung (falls nötig).' }),
f('password', 'string', { interface: 'input', options: { masked: true }, note: 'Login-Passwort (falls nötig).' }),
f('poll_interval_seconds', 'integer', { interface: 'input', default: 5 }),
f('active', 'boolean', { interface: 'boolean', default: true }),
dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }),
{ field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} },
// Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem
// format-Schlüssel und springt nie selbstständig auf ein anderes Format.
// Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird.
f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }),
f('notes', 'text', { interface: 'input-multiline' }),
] },
// Testdruck: beliebiges Template + Daten schnell generieren und drucken.
{ collection: 'test_prints', meta: { icon: 'science', note: 'Testdrucke zum Verproben von Templates & Maschinen.' }, fields: [
f('label', 'string', { interface: 'input', note: 'Bezeichnung (optional).' }),
m2o('template', 'format_templates', { note: 'Druck-Template (Platzierung an der Maschine).' }),
dropdown('format', ['a4', 'a6_hoch', 'a6_quer'], { default: 'a4', note: 'Layout für die Textgenerierung.' }),
dropdown('font', ['tilda', 'alva', 'ellie'], { default: 'tilda' }),
m2o('machine', 'machines'),
f('text', 'text', { interface: 'input-multiline', note: 'Testtext (Platzhalter werden nicht ersetzt).' }),
dropdown('status', ['neu', 'generiert', 'fehler'], { default: 'neu' }),
f('output_ref', 'string', { interface: 'input', readonly: true, note: 'Ausgabe-Ordner des Testdrucks.' }),
{ field: 'run_action', type: 'alias',
meta: { interface: 'skrift-testprint-button', special: ['alias', 'no-data'], note: 'Generieren & in die Druck-Warteschlange legen.' },
schema: null },
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Druck-Templates (Maße/Position/Rotation in Zoll) – aus config.json.
{ collection: 'format_templates', meta: { icon: 'straighten', note: 'Druck-Templates (Zoll).' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('name', 'string', { interface: 'input', required: true }),
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief' }),
f('default_format', 'string', { interface: 'input', note: 'Standard-Template für dieses Format (Format-Key: a4, a6_hoch, a6_quer, dinlang, c6). Leer = kein Standard.' }),
f('search_default', 'boolean', { interface: 'boolean', default: false, note: 'Als Standard-Template für den Schnelldruck im Suche-Reiter verwenden (je ein Brief- und ein Kuvert-Template markieren). Kann dasselbe wie das normale Standard sein.' }),
f('width', 'float', { interface: 'input', note: 'Zoll' }),
f('height', 'float', { interface: 'input', note: 'Zoll' }),
f('xpos', 'float', { interface: 'input', default: 0 }),
f('ypos', 'float', { interface: 'input', default: 0 }),
f('scale', 'float', { interface: 'input', default: 1 }),
f('rotation', 'integer', { interface: 'input', default: 0 }),
] },
// Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das
// Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich
// eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend).
{ collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }),
f('name', 'string', { interface: 'input', required: true }),
dropdown('kind', ['brief', 'umschlag', 'postkarte'], { default: 'brief' }),
f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }),
f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }),
f('margin_top_mm', 'float', { interface: 'input', default: 25 }),
f('margin_left_mm', 'float', { interface: 'input', default: 20 }),
f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat). Greifrand rechts (z. B. A6 hoch).' }),
f('margin_bottom_mm', 'float', { interface: 'input', note: 'Optional. Unterer Greifrand (z. B. A6 quer) – hält untere Zeilen frei.' }),
f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }),
f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }),
// Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine).
m2o('print_template', 'format_templates'),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
m2o('machine', 'machines'),
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
// Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage
// für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem
// an der Maschine eingelegten Format entspricht.
f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }),
f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }),
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }),
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
// Vom Druck-Popup gesetzt; leer = Template-Werte verwenden.
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
f('spaltenweise', 'boolean', { interface: 'boolean', default: false, note: 'Rollenschonende Zeichenreihenfolge (spaltenweise, für A6 quer). Der Agent lädt das SVG dann umsortiert.' }),
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
// Guard: Bestand wurde für diesen Job bereits abgebucht (Idempotenz des Inventar-Hooks).
f('inventory_done', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Material bereits abgebucht (Bestands-Hook).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('status', 'string', { interface: 'input' }),
f('note', 'text', { interface: 'input-multiline' }),
m2o('changed_by', 'directus_users'),
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// API-Zugänge für Kunden (Public-API). Keys werden nur als SHA-256-Hash gespeichert.
{ collection: 'api_clients', meta: { icon: 'vpn_key', note: 'API-Zugänge für Kunden (Public-API).' }, fields: [
f('name', 'string', { interface: 'input', required: true, note: 'Bezeichnung, z. B. Firmenname.' }),
f('key_plain', 'string', { interface: 'input', note: 'Wird beim Anlegen automatisch erzeugt und EINMALIG angezeigt – jetzt kopieren! Verschwindet bei der nächsten Änderung. Nur der Hash bleibt gespeichert.' }),
f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }),
f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }),
m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }),
// Mehrere erlaubte Produkte (m2m). Die Order gibt per „product" an, welches genutzt wird.
{ field: 'products', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'], note: 'Erlaubte Produkte für diesen Zugang (Mehrfachauswahl).' }, schema: null },
// Einzelprodukt (optional, Fallback/alt) – wird nur genutzt, wenn keine m2m-Produkte gesetzt sind.
m2o('product', 'products', { note: 'Einzelprodukt (Fallback). Für mehrere Produkte „products" nutzen.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }),
f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }),
f('last_used', 'timestamp', { interface: 'datetime' }),
// AGB/Vertrag – einmalige Zustimmung beim Onboarding festhalten (Nachweis).
f('terms_version', 'string', { interface: 'input', note: 'Akzeptierte AGB-Version, z. B. „2026-01".' }),
f('terms_accepted_at', 'timestamp', { interface: 'datetime', note: 'Zeitpunkt der AGB-/Vertragszustimmung.' }),
f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }),
] },
// Junction API-Zugang ↔ Produkt (m2m für api_clients.products).
{ collection: 'api_client_products', meta: { icon: 'link', note: 'Junction API-Zugang ↔ Produkt.', hidden: true }, fields: [
m2o('api_client', 'api_clients', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
] },
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
f('label', 'string', { interface: 'input', note: 'z. B. „Standard", „mit Titel".' }),
// Datei-Upload (SVG) statt Textfeld.
{ field: 'file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
// Voreingestellte Platzierung relativ zum Marker im PDF (§§SIG1§§ …).
// Wird im Signatur-Modul als Vorgabe geladen, dort manuell überschreibbar.
f('marker_key', 'string', { interface: 'input', default: 'SIG1', note: 'Marker-Token im PDF (ohne §§), z. B. SIG1.' }),
f('offset_x_mm', 'float', { interface: 'input', default: 0, note: 'Versatz X zum Marker (mm).' }),
f('offset_y_mm', 'float', { interface: 'input', default: 0, note: 'Versatz Y zum Marker (mm).' }),
f('width_mm', 'float', { interface: 'input', default: 40, note: 'Breite der Signatur (mm).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Kundenspezifische Rabatte. Wird am Kundenkonto als o2m gezeigt.
{ collection: 'customer_discounts', meta: { icon: 'percent', note: 'Kundenspezifische Rabatte.' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
f('label', 'string', { interface: 'input', note: 'z. B. „Einführungsrabatt".' }),
dropdown('type', ['prozent', 'fest'], { default: 'prozent' }),
f('value', 'float', { interface: 'input', note: 'Prozent oder €-Betrag.' }),
dropdown('scope', ['alle', 'erste_n_stueck'], { default: 'alle', note: 'Gilt für alle oder nur die ersten N Stück.' }),
f('limit_stueck', 'integer', { interface: 'input', note: 'N bei „erste_n_stueck".' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('valid_until', 'timestamp', { interface: 'datetime', note: 'Optionales Ablaufdatum.' }),
] },
// Für einzelne Kunden freigeschaltete Produkte (Override der Sichtbarkeit).
{ collection: 'customer_products', meta: { icon: 'lock_open', note: 'Freigeschaltete Produkte je Kunde.', hidden: true }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Kundenspezifische Angebote: frei benannte Aufschläge (pro Stück) + Namens-Zusatz,
// die der eingeloggte Kunde im Konfigurator auswählt. Kein Festpreis (Phase 2).
// Notizen je zusammengefasster Charge (Schlüssel = Kunde|Produkt|Versandtag|Eingang).
{ collection: 'charge_notes', meta: { icon: 'sticky_note_2', note: 'Notizen je Charge (Produktions-Board).', hidden: true }, fields: [
f('charge_key', 'string', { interface: 'input', unique: true, required: true, note: 'Charge-Schlüssel (kunde|produkt|versandtag|eingang).' }),
f('note', 'text', { interface: 'input-multiline' }),
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
// Logs & Fehler vom Produktions-Agenten und von AutoRecover (Plotter-Fehler).
{ collection: 'agent_logs', meta: { icon: 'article', note: 'Logs & Fehlermeldungen vom Produktions-Agenten / AutoRecover.' }, fields: [
m2o('machine', 'machines', { onDelete: 'SET NULL', note: 'Maschine (optional).' }),
dropdown('level', ['info', 'warn', 'error'], { default: 'info' }),
f('source', 'string', { interface: 'input', note: 'Quelle: agent | plotter | recover | restart.' }),
f('message', 'text', { interface: 'input-multiline' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Befehle an den Agenten (z. B. Programm-Neustart). Der Agent pollt „pending".
{ collection: 'agent_commands', meta: { icon: 'terminal', note: 'Befehle an den Agenten (Programm-Neustart). Der Agent arbeitet „pending" ab.' }, fields: [
m2o('machine', 'machines', { onDelete: 'CASCADE', note: 'Zielmaschine/-agent.' }),
dropdown('type', ['restart'], { default: 'restart' }),
f('target', 'string', { interface: 'input', note: 'Optionaler Programmname/-befehl (überschreibt Agent-Config).' }),
dropdown('status', ['pending', 'done', 'error'], { default: 'pending' }),
f('result', 'text', { interface: 'input-multiline', note: 'Ergebnis/Fehler vom Agenten.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
{ collection: 'customer_offers', meta: { icon: 'local_offer', note: 'Kundenangebote: Aufschläge/Name-Zusatz je Kunde+Produkt (Kunde wählt im Konfigurator).' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
f('label', 'string', { interface: 'input', note: 'Angezeigte Bezeichnung, z. B. „Auf bedrucktem Papier".' }),
f('name_suffix', 'string', { interface: 'input', note: 'Zusatz an den Produktnamen, z. B. „auf bedrucktem Papier".' }),
f('surcharges', 'json', { interface: 'list', note: 'Aufschläge pro Stück (netto): Bezeichnung + Betrag.', options: { fields: [
{ field: 'label', type: 'string', name: 'Bezeichnung', meta: { interface: 'input', width: 'half', options: { placeholder: 'z. B. bedrucktes Papier' } } },
{ field: 'amount', type: 'float', name: 'Betrag netto (€/Stück)', meta: { interface: 'input', width: 'half' } },
] } }),
f('allow_multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitigen Druck erlauben.' }),
f('note', 'text', { interface: 'input-multiline', note: 'Hinweis an den Kunden (optional).' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis.
{ collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }),
f('label', 'string', { interface: 'input', note: 'Bezeichnung im Admin.' }),
f('subject', 'string', { interface: 'input', required: true, note: 'Betreff. Platzhalter: {{order_id}}, {{name}}, {{code}}, {{link}} …' }),
f('body', 'text', { interface: 'input-rich-text-html', note: 'Text (WYSIWYG/HTML). Platzhalter in {{...}}.' }),
f('append_signature', 'boolean', { interface: 'boolean', default: true, note: 'Signatur automatisch anhängen (bei Mails mit eigenem Footer aus).' }),
f('attach_agb', 'boolean', { interface: 'boolean', default: false, note: 'AGB-PDF an diese Mail anhängen.' }),
f('bcc_team', 'boolean', { interface: 'boolean', default: false, note: 'Kopie an mich (Team-Adresse aus email_settings) als BCC senden.' }),
f('enabled', 'boolean', { interface: 'boolean', default: true }),
] },
// Gemeinsame E-Mail-Einstellungen (Signatur, Portal-Link, AGB, Footer).
{ collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [
f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }),
f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }),
f('team_email', 'string', { interface: 'input', note: 'Interne Benachrichtigungen (neue Bestellung/Muster/Anfrage). Leer = keine Team-Mails.' }),
f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }),
f('portal_url', 'string', { interface: 'input', note: 'Basis-URL des Kundenportals (Platzhalter {{link}}).' }),
f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }),
{ field: 'agb_file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'AGB als PDF – wird an Mails mit „AGB anhängen" gehängt. Zum Ändern einfach neue Datei hochladen.' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
] },
// Standard-Mustertexte für den manuellen Muster-Druck (Du-/Sie-Form).
// Platzhalter [[abschnitt]] füllt der Bediener im Druck-Dialog kurz aus.
{ collection: 'muster_settings', meta: { icon: 'science', singleton: true, note: 'Standard-Mustertexte (Du/Sie) für den Muster-Druck.' }, fields: [
f('text_du', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Du-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
] },
// Frei anlegbare Mustertexte (beliebig viele) – erscheinen im „Manueller Druck"-
// Dropdown. Ersetzt langfristig die feste Du/Sie-Vorgabe (die als Fallback bleibt).
{ collection: 'muster_texts', meta: { icon: 'science', note: 'Frei anlegbare Mustertexte für den manuellen Druck.' }, fields: [
f('label', 'string', { interface: 'input', required: true, note: 'Bezeichnung im Dropdown.' }),
f('text', 'text', { interface: 'input-multiline', note: 'Mustertext. Platzhalter [[…]] erlaubt.' }),
f('sort', 'integer', { interface: 'input' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
// Angebots- und Musteranfragen aus der App.
{ collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [
dropdown('type', ['angebot', 'muster'], { default: 'angebot' }),
f('name', 'string', { interface: 'input', required: true }),
f('email', 'string', { interface: 'input', required: true }),
f('phone', 'string', { interface: 'input' }),
f('quantity', 'integer', { interface: 'input' }),
f('product', 'string', { interface: 'input', note: 'Bei Muster: gewünschtes Produkt.' }),
f('message', 'text', { interface: 'input-multiline' }),
f('address', 'json', { interface: 'input-code', note: 'Bei Muster: Lieferadresse.' }),
dropdown('status', ['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'], { default: 'neu' }),
m2o('customer', 'directus_users', { note: 'Falls die Anfrage von einem bestehenden Konto kommt.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Vorschau-Kontingent (serverseitig – der Client darf das nicht bestimmen).
{ collection: 'preview_usage', meta: { icon: 'speed', hidden: true, note: 'Vorschau-Verbrauch je Kennung und Tag.' }, fields: [
f('key', 'string', { interface: 'input', required: true, note: 'E-Mail oder anonyme Kennung.' }),
f('day', 'string', { interface: 'input', required: true }),
f('count', 'integer', { interface: 'input', default: 0 }),
] },
// Einmalcodes für den passwortlosen Login (nur die Extension greift darauf zu).
{ collection: 'login_codes', meta: { icon: 'key', hidden: true, note: 'Einmalcodes für den passwortlosen Login (nur Hashes).' }, fields: [
f('email', 'string', { interface: 'input', required: true }),
f('code_hash', 'string', { interface: 'input', required: true }),
f('expires_at', 'timestamp', { interface: 'datetime', required: true }),
f('attempts', 'integer', { interface: 'input', default: 0 }),
f('used', 'boolean', { interface: 'boolean', default: false }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Kontakte/Leads – Empfänger gesendeter Schriftstücke. Werden bei Versand
// automatisch angelegt (Hook) und nach 30 Tagen wieder gelöscht, außer der
// Auftrag ist als „Kontakte behalten" (Akquise) markiert. Telefon/E-Mail sind
// manuell zu pflegen (Empfänger liefern sie nicht mit). CSV-Import/-Export
// über die normale Collection-Seite.
{ collection: 'contacts', meta: { icon: 'contacts', note: 'Kontakte/Leads – Empfänger gesendeter Schriftstücke.' }, fields: [
f('salutation', 'string', { interface: 'input' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('company', 'string', { interface: 'input' }),
f('street', 'string', { interface: 'input' }),
f('house_no', 'string', { interface: 'input' }),
f('zip', 'string', { interface: 'input' }),
f('city', 'string', { interface: 'input' }),
f('country', 'string', { interface: 'input' }),
f('email', 'string', { interface: 'input', note: 'Manuell nachtragen.' }),
f('phone', 'string', { interface: 'input', note: 'Manuell nachtragen. Klick im Kontakte-Modul öffnet GroundWire.' }),
f('notes', 'text', { interface: 'input-multiline', note: 'Anruf-/Lead-Notizen.' }),
dropdown('lead_status', ['neu', 'kontaktiert', 'interessiert', 'kein_interesse', 'kunde'], { default: 'neu', note: 'Nur Kontakte mit „kein_interesse" werden nach 30 Tagen automatisch gelöscht; alle anderen bleiben.' }),
m2o('source_order', 'orders', { onDelete: 'SET NULL', note: 'Auftrag, aus dem der Kontakt stammt.' }),
m2o('source_entry', 'order_entries', { onDelete: 'SET NULL', note: 'Empfängerzeile, aus der der Kontakt stammt.' }),
f('sent_at', 'timestamp', { interface: 'datetime', note: 'Versandzeitpunkt – Grundlage für die 30-Tage-Löschung.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
// Standardaktionen im Kontakte-Modul (Rückruf-Wiedervorlage, Angebot senden).
// „angebot" verschickt sofort die Eckdaten an die interne Adresse; „rueckruf"
// löst am fälligen Tag früh eine Erinnerungs-Mail aus (Hook skrift-contacts).
{ collection: 'contact_actions', meta: { icon: 'task_alt', hidden: true, note: 'Aktionen/Wiedervorlagen je Kontakt (Rückruf, Angebot).' }, fields: [
m2o('contact', 'contacts', { onDelete: 'CASCADE' }),
dropdown('type', ['rueckruf', 'angebot', 'mail'], { default: 'rueckruf' }),
f('due_date', 'timestamp', { interface: 'datetime', note: 'Fälligkeit (nur Rückruf) – Basis der Erinnerungs-Mail.' }),
f('note', 'text', { interface: 'input-multiline', note: 'Notiz zur Aktion (optional).' }),
dropdown('status', ['offen', 'erledigt'], { default: 'offen' }),
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Alternativer Übermittlungsweg (Lettershop/FTP): je Empfänger ein Datensatz
// im festen Schema + fertige Druck-PDF ODER Schriftstücktext. Wird je Kunde
// täglich (03:00 Folgetag; Sa+So gemeinsam) zu einer xlsx + Sammel-Druck-PDF
// gebündelt und per FTP hochgeladen.
{ collection: 'batch_submissions', meta: { icon: 'inventory', note: 'Alternativer Übermittlungsweg: Empfängerzeile + Druck-PDF/Text; täglich je Kunde zu xlsx+PDF gebündelt (FTP).' }, fields: [
m2o('api_client', 'api_clients', { onDelete: 'CASCADE', note: 'Zugang, über den übermittelt wurde.' }),
m2o('customer', 'directus_users', { note: 'Kunde (Login-Konto) des Zugangs.' }),
f('firma', 'string', { interface: 'input' }),
f('vorname', 'string', { interface: 'input' }),
f('nachname', 'string', { interface: 'input' }),
f('zeile1', 'string', { interface: 'input' }),
f('zeile2', 'string', { interface: 'input' }),
f('zeile3', 'string', { interface: 'input' }),
f('plz', 'string', { interface: 'input' }),
f('stadt', 'string', { interface: 'input' }),
f('land', 'string', { interface: 'input' }),
f('text', 'text', { interface: 'input-multiline', note: 'Schriftstücktext (Handschrift), falls keine Druck-PDF.' }),
m2o('pdf_file', 'directus_files', { note: 'Fertige Druck-PDF (wird 1:1 übernommen), falls kein Text.' }),
dropdown('status', ['pending', 'uebernommen', 'bundled'], { default: 'pending', note: 'pending = offen; uebernommen = als Auftrag in die Produktion übernommen.' }),
f('versendet', 'boolean', { interface: 'boolean', default: false, note: 'Im Board manuell als versendet markiert.' }),
f('bundle_name', 'string', { interface: 'input', readonly: true, note: 'Name der Bündel-Datei / Referenz.' }),
f('bundled_at', 'timestamp', { interface: 'datetime', readonly: true }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// ── Bestandspflege (zwei Produktionsstandorte) ─────────────────────────────
{ collection: 'standorte', meta: { icon: 'factory', note: 'Produktionsstandorte (für Bestand + Kundenzuordnung).' }, fields: [
f('name', 'string', { interface: 'input', required: true, unique: true }),
f('aktiv', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
{ collection: 'inventory', meta: { icon: 'inventory', note: 'Materialbestand je Standort (Papier/Kuvert/Briefmarke, je Format/Typ).' }, fields: [
m2o('standort', 'standorte', { onDelete: 'CASCADE', note: 'Produktionsstandort.' }),
dropdown('kategorie', ['papier', 'kuvert', 'briefmarke'], { note: 'Materialart.' }),
f('format', 'string', { interface: 'input', note: 'Schlüssel zum Abgleich: Papier/Kuvert = Format-Key (a4,a5p,a6l,dinlang,c6,c4 …); Briefmarke = standard/international/grossbrief …' }),
f('bezeichnung', 'string', { interface: 'input', note: 'Lesbare Bezeichnung (z. B. „A4 Papier", „Standard 0,95 €").' }),
f('bestand', 'integer', { interface: 'input', default: 0, note: 'Aktueller Bestand.' }),
f('mindestbestand', 'integer', { interface: 'input', default: 0, note: 'Schwelle → bei Unterschreitung „nachbestellen".' }),
f('einheit', 'string', { interface: 'input', default: 'Stück' }),
f('notiz', 'string', { interface: 'input' }),
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
{ collection: 'inventory_log', meta: { icon: 'receipt_long', hidden: true, note: 'Bestandsbuchungen (Abgänge beim Druck, Auffüllungen, Korrekturen).' }, fields: [
m2o('inventory', 'inventory', { onDelete: 'CASCADE', note: 'Betroffene Bestandszeile.' }),
f('delta', 'integer', { interface: 'input', note: 'Veränderung (negativ = Abgang, positiv = Zugang).' }),
dropdown('grund', ['druck', 'auffuellung', 'korrektur'], { note: 'Buchungsgrund.' }),
m2o('order', 'orders', { onDelete: 'SET NULL', note: 'Auslösender Auftrag (bei Abgang).' }),
f('notiz', 'string', { interface: 'input' }),
{ 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) {
const { __related, __onDelete, ...clean } = fld;
if (!existingFields.has(`directus_users.${fld.field}`)) {
await api('POST', '/fields/directus_users', clean);
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: clean.meta }); } catch { /* egal */ }
}
// m2o-Beziehung (z. B. produktionsstandort → standorte) nachziehen.
if (__related) relationsToCreate.push({ collection: 'directus_users', field: fld.field, related: __related, onDelete: __onDelete });
}
// Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren
// immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls
// ein Feldname in dieser Directus-Version anders heißt.
for (const field of HIDE_USER_FIELDS) {
try {
await api('PATCH', `/fields/directus_users/${field}`, { meta: { hidden: true } });
log('feld versteckt', `directus_users.${field}`);
} catch (e) {
console.warn(` Feld directus_users.${field} nicht ausgeblendet: ${e.message}`);
}
}
console.log('› Beziehungen (m2o)');
for (const r of relationsToCreate) {
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
await api('POST', '/relations', {
collection: r.collection, field: r.field, related_collection: r.related,
schema: { on_delete: r.onDelete }, meta: {},
});
log('relation +', `${r.collection}.${r.field} → ${r.related}`);
}
// m2m-Verdrahtung orders.addons ↔ price_items über order_addons
console.log('› m2m orders.addons');
try {
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
// m2m api_clients.products ↔ products über api_client_products
await api('PATCH', '/relations/api_client_products/api_client', { meta: { one_field: 'products', junction_field: 'product' } });
await api('PATCH', '/relations/api_client_products/product', { meta: { junction_field: 'api_client' } });
log('m2m verdrahtet');
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
// o2m am Kundenkonto: Unterschriften + Rabatte direkt im User anzeigen.
console.log('› o2m-Verdrahtung');
try {
await api('PATCH', '/relations/signatures/customer', { meta: { one_field: 'signatures' } });
await api('PATCH', '/relations/customer_discounts/customer', { meta: { one_field: 'discounts' } });
await api('PATCH', '/relations/customer_products/customer', { meta: { one_field: 'unlocked_products' } });
await api('PATCH', '/relations/order_entries/order', { meta: { one_field: 'entries' } });
log('o2m verdrahtet');
} catch (e) { console.warn(' o2m-Verdrahtung übersprungen (bitte melden):', e.message); }
}
// ── Seed-Daten ──────────────────────────────────────────────────────────────
async function seedItem(collection, uniqueField, item) {
const existing = await api('GET', `/items/${collection}?filter[${uniqueField}][_eq]=${encodeURIComponent(item[uniqueField])}&limit=1`);
if (existing && existing.length) return existing[0];
const created = await api('POST', `/items/${collection}`, item);
log('seed +', `${collection}.${item[uniqueField]}`);
return created;
}
async function applySeed() {
console.log('› Seed');
const products = [
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 },
// Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere
// Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen).
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, active: true, sort: 2 },
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
// bestellbar ist; für andere bleibt es (type contact) ein Anfrage-Produkt.
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', visibility: 'unternehmen', pricing_mode: 'formel', base_price: 5.0, input_mode: 'datei', active: true, sort: 4 },
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', visibility: 'unternehmen', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
];
const productByKey = {};
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
// Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND
// bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen
// können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht).
try {
const us = productByKey.unterschriftenservice;
if (us?.id) {
const patch = {};
if (us.input_mode !== 'datei') patch.input_mode = 'datei';
if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel';
if (Object.keys(patch).length) {
await api('PATCH', `/items/products/${us.id}`, patch);
log('unterschriftenservice →', JSON.stringify(patch));
}
}
} catch { /* nicht kritisch */ }
const formats = [
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true, show_check: true, family: 'A4', orientation: null },
// A5 + DIN lang (komplett beschrieben, Volltext): NUR manueller Druck + Textmengencheck,
// NICHT im öffentlichen Konfigurator bestellbar. Render-Keys = Directus-render_formats-Keys.
// Gleiche family + orientation hoch/quer → Ausrichtungs-Dropdown im Textmengencheck.
{ product: productByKey.briefe.id, key: 'a5p', name: 'A5 Hochformat', active: false, show_manual: true, show_check: true, family: 'A5', orientation: 'hoch' },
{ product: productByKey.briefe.id, key: 'a5l', name: 'A5 Querformat', active: false, show_manual: true, show_check: true, family: 'A5', orientation: 'quer' },
{ product: productByKey.briefe.id, key: 'dinlang_karte', name: 'DIN lang (komplett beschrieben)', active: false, show_manual: true, show_check: true, family: 'DIN lang', orientation: null },
{ product: productByKey.postkarten.id, key: 'a6_hoch', name: 'A6 Hochformat', active: true, show_check: true, family: 'A6', orientation: 'hoch' },
{ product: productByKey.postkarten.id, key: 'a6_quer', name: 'A6 Querformat', active: true, show_check: true, family: 'A6', orientation: 'quer' },
// Postkarten-Layout (Text links, Adresse rechts) – NUR manueller Druck, nicht in der App.
{ product: productByKey.postkarten.id, key: 'a6_postkarte', name: 'A6 Postkarte (Text + Adresse)', active: false, show_manual: true },
{ product: productByKey.postkarten.id, key: 'dinlang_postkarte', name: 'DIN lang Postkarte (Text + Adresse)', active: false, show_manual: true },
];
// Upsert: neue Zeilen anlegen; bei bestehenden NUR die Check-/Ausrichtungs-Metadaten
// nachziehen (admin-gepflegte Felder name/active/product/show_manual bleiben unangetastet).
for (const fmt of formats) {
const vorhanden = await api('GET', `/items/formats?filter[key][_eq]=${encodeURIComponent(fmt.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) {
const meta = {};
for (const k of ['show_check', 'family', 'orientation']) if (fmt[k] !== undefined) meta[k] = fmt[k];
if (Object.keys(meta).length) await api('PATCH', `/items/formats/${vorhanden[0].id}`, meta);
} else {
await api('POST', '/items/formats', fmt); log('seed + formats.' + fmt.key);
}
}
// Preise: kundenseitige Werte aus dem Konfigurator; 0 = im Admin setzen.
const priceItems = [
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
{ key: 'sammelversand_klein', label: 'Sammelversand (bis 5 Stück)', category: 'versand', price: 1.80, unit: 'einmalig', active: true },
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
{ key: 'gestaltungsservice', label: 'Gestaltungsservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
{ key: 'motiv_upload', label: 'Motiv-Upload', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
];
for (const pi of priceItems) await seedItem('price_items', 'key', pi);
// pricing_settings (Singleton)
const ps = await api('GET', '/items/pricing_settings');
if (!ps || !ps.formula) {
await api('PATCH', '/items/pricing_settings', {
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
// Beispiel-Staffel (relativ hoch angesetzt) – in Directus anpassbar.
quantity_discounts: [
{ min_qty: 250, percent: 5 },
{ min_qty: 500, percent: 10 },
{ min_qty: 1000, percent: 15 },
],
});
log('seed singleton pricing_settings');
}
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
// erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt.
const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))';
if (!ps || !ps.multiplier_formula) {
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
log('seed multiplier-formel');
} else {
// Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent
// (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die
// Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten.
const m = String(ps.multiplier_formula).match(
/^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/,
);
if (m) {
const aOld = Number(m[1]); const eOld = Number(m[2]);
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1');
for (const p of (prods || [])) {
const patch = {};
if (p.price_factor_a == null) patch.price_factor_a = aOld;
if (p.price_factor_e == null) patch.price_factor_e = eOld;
if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch);
}
log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`);
}
}
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
const G = 'Guten Tag {{name}},'; // gemeinsame Anrede
// Reiche Bestellbestätigung (Design an das bisherige n8n-Template angelehnt,
// aber mit flachen {{platzhaltern}}, die der Mailer-Hook befüllt).
const orderCreatedBody = `
<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}}` },
// Separate, frei editierbare Mails für Unterschriftenservice- und Extern-Bestellungen.
{ key: 'unterschriften_eingang', label: 'Unterschriften: Eingang', subject: 'Ihr Unterschriften-Auftrag {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
// Motiv-Freigabe: Kunde genehmigt den A6-Entwurf über den Link (Einmal-Zugang).
{ key: 'motiv_freigabe', label: 'Motiv: Freigabe anfordern', subject: 'Bitte Ihren Postkarten-Entwurf freigeben – {{order_number}}', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank für Ihre Bestellung <strong>{{order_number}}</strong>! Wir haben aus Ihrem Motiv einen Druck-Entwurf (A6) erstellt.</p>`
+ `<p>Bitte prüfen und <strong>freigeben</strong> Sie den Entwurf – oder hinterlegen Sie einen Änderungswunsch:</p>`
+ `<p><a href="{{freigabe_link}}" style="display:inline-block;background:#1E50A5;color:#fff;text-decoration:none;padding:12px 22px;border-radius:8px;font-weight:600;">Entwurf ansehen &amp; freigeben</a></p>`
+ `<p style="font-size:13px;color:#666;">Erst nach Ihrer Freigabe geht die Karte in den Druck.</p>` },
];
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: 'a5_v1', name: 'A5 Hochformat', type: 'brief', default_format: 'a5p', width: 5.83, height: 8.27, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a5l_v1', name: 'A5 Querformat', type: 'brief', default_format: 'a5l', width: 8.27, height: 5.83, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'dl_v1', name: 'DIN lang (komplett beschrieben)', type: 'brief', default_format: 'dinlang_karte', width: 8.27, height: 4.13, xpos: -0.3, 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 },
// C4 quer (324x229 mm = 12.76x9.02 Zoll). Querformat, maschinenseitig quer eingelegt (rotation 0).
{ key: 'kuvert_c4_v1', name: 'Kuvert C4 quer', type: 'umschlag', default_format: 'c4', width: 12.76, height: 9.02, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
];
// Nur bei LEERER Collection anlegen (Erstinstallation). Der Betreiber pflegt die
// Templates danach selbst (löschen/umbenennen/eigene) – Bootstrap fügt sie NICHT
// wieder hinzu. Vorhandene werden bewusst nicht überschrieben.
const ftVorhanden = await api('GET', `/items/format_templates?limit=1&fields=id`);
if (!ftVorhanden || !ftVorhanden.length) {
for (const t of formatTemplates) {
await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key);
}
} else {
log('format_templates vorhanden → Seed übersprungen (vom Betreiber gepflegt)');
}
// Nachzügler: neu hinzugekommene Formate (A5, DIN lang, C4) auch auf bestehenden
// Installationen anlegen – aber NUR, wenn der Key fehlt (bestehende unangetastet).
// So ist u. a. der C4-Kuvertdruck ohne Handarbeit möglich.
for (const key of ['a5_v1', 'a5l_v1', 'dl_v1', 'kuvert_c4_v1']) {
const t = formatTemplates.find((x) => x.key === key);
if (!t) continue;
const da = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(key)}&limit=1&fields=id`);
if (!da || !da.length) { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + key + ' (nachgerüstet)'); }
}
// 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 },
// A6-Greifrand IMMER an der langen (148 mm) Seite: hoch → rechts, quer → unten.
{ key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.3, line_limit: 14, active: true },
// margin_right_mm bewusst null: löscht den früheren rechten Greifrand beim Upsert-PATCH (jetzt unten).
{ 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: null, margin_bottom_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true },
// A5 greift an der KURZEN (148 mm) Seite: hoch → unten, quer → rechts.
// null löscht den ggf. früher geseedeten Gegen-Rand beim Upsert-PATCH.
{ key: 'a5p', name: 'A5 Hochformat', kind: 'brief', page_width_mm: 148, page_height_mm: 210, margin_top_mm: 20, margin_left_mm: 15, margin_right_mm: null, margin_bottom_mm: 14, line_height_factor: 1.35, line_limit: 19, active: true },
{ key: 'a5l', name: 'A5 Querformat', kind: 'brief', page_width_mm: 210, page_height_mm: 148, margin_top_mm: 20, margin_left_mm: 15, margin_right_mm: 14, margin_bottom_mm: null, line_height_factor: 1.35, line_limit: 13, active: true },
// DIN lang komplett beschrieben (Volltext, KEIN Adress-Layout) – kind brief, Greifrand an der langen Seite → unten.
{ key: 'dinlang_karte', name: 'DIN lang (komplett beschrieben)', kind: 'brief', page_width_mm: 210, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 10, margin_bottom_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 },
// C4 quer (Querformat-Beschriftung, maschinenseitig quer eingelegt) – nur manueller Druck.
{ key: 'c4', name: 'C4 Kuvert (quer)', kind: 'umschlag', page_width_mm: 324, page_height_mm: 229, margin_top_mm: 70, margin_left_mm: 20, line_height_factor: 1.2, active: true },
// Postkarten (nur manueller Druck): Text links, Absender+Empfänger rechts,
// Briefmarkenfeld oben rechts frei. Ein SVG je Karte (ein Plotter-Durchlauf).
{ key: 'a6_postkarte', name: 'A6 Postkarte (Text + Adresse)', kind: 'postkarte', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.2, active: true },
{ key: 'dinlang_postkarte', name: 'DIN lang Postkarte (Text + Adresse)', kind: 'postkarte', page_width_mm: 210, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.2, active: true },
];
// Upsert (nicht nur anlegen), damit falsche Alt-Maße korrigiert werden – z. B.
// Postkarten (a6p/a6l), die sonst nicht aufs Kartenformat passen.
for (const rf of renderFormats) {
const vorhanden = await api('GET', `/items/render_formats?filter[key][_eq]=${encodeURIComponent(rf.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) await api('PATCH', `/items/render_formats/${vorhanden[0].id}`, rf);
else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); }
}
// ── Standorte + initialer Materialbestand ──────────────────────────────────
const standortId = {};
for (const s of [{ name: 'Skrift', sort: 1, aktiv: true }, { name: 'Signascript', sort: 2, aktiv: true }]) {
const da = await api('GET', `/items/standorte?filter[name][_eq]=${encodeURIComponent(s.name)}&limit=1&fields=id`);
if (da && da.length) standortId[s.name] = da[0].id;
else { const neu = await api('POST', '/items/standorte', s); standortId[s.name] = neu?.id; log('seed + standorte.' + s.name); }
}
// Materialzeilen je Standort (Bestand/Mindestbestand füllt der Betreiber im Board).
const papierNamen = { a4: 'A4', a5: 'A5', a6: 'A6', dinlang: 'DIN lang' };
const material = [
...Object.entries(papierNamen).map(([k, n]) => ({ kategorie: 'papier', format: k, bezeichnung: `${n} Papier`, einheit: 'Blatt' })),
...[['dinlang', 'DIN lang'], ['c6', 'C6'], ['c4', 'C4']].map(([k, n]) => ({ kategorie: 'kuvert', format: k, bezeichnung: `Kuvert ${n}`, einheit: 'Stück' })),
{ kategorie: 'briefmarke', format: 'standard', bezeichnung: 'Standard 0,95 €', einheit: 'Stück' },
{ kategorie: 'briefmarke', format: 'international', bezeichnung: 'International 1,25 €', einheit: 'Stück' },
];
for (const sid of Object.values(standortId)) {
if (!sid) continue;
for (const m of material) {
const da = await api('GET', `/items/inventory?filter[standort][_eq]=${sid}&filter[kategorie][_eq]=${m.kategorie}&filter[format][_eq]=${encodeURIComponent(m.format)}&limit=1&fields=id`);
if (da && da.length) continue;
await api('POST', '/items/inventory', { standort: sid, bestand: 0, mindestbestand: 0, ...m });
}
}
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
const es = await api('GET', '/items/email_settings');
if (!es || !es.signature) {
await api('PATCH', '/items/email_settings', {
sender_name: 'Skrift',
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de').trim(),
signature: '<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');
}
// muster_texts – Starteinträge (Du/Sie) nur anlegen, wenn noch keine da sind.
const mtx = await api('GET', '/items/muster_texts?limit=1');
if (!mtx || !mtx.length) {
await api('POST', '/items/muster_texts', { label: 'Sie-Form', sort: 1, active: true,
text: 'Sehr geehrte/r [[Anrede]] [[Nachname]],\n\nschön, dass Sie Skrift ausprobieren! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nMit freundlichen Grüßen\nIhr Skrift-Team' });
await api('POST', '/items/muster_texts', { label: 'Du-Form', sort: 2, active: true,
text: 'Hallo [[Vorname]],\n\nschön, dass Du Skrift ausprobierst! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nHerzliche Grüße\nDein Skrift-Team' });
log('seed muster_texts');
}
}
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history',
'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'contacts',
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints',
'standorte', 'inventory', 'inventory_log'];
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
const CUSTOMER_ROLE_NAME = 'Kunde';
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
async function renameRole(oldName, newName) {
const neu = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(newName)}&limit=1`);
if (neu && neu.length) return;
const alt = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(oldName)}&limit=1`);
if (alt && alt.length) {
await api('PATCH', `/roles/${alt[0].id}`, { name: newName });
log('role umbenannt', `${oldName} → ${newName}`);
}
}
async function ensureRole(name, icon) {
const roles = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
if (roles && roles.length) return roles[0];
const r = await api('POST', '/roles', { name, icon });
log('role +', name);
return r;
}
async function ensurePolicy(name, opts = {}) {
const ps = await api('GET', `/policies?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
if (ps && ps.length) return ps[0];
const p = await api('POST', '/policies', { name, app_access: !!opts.app, admin_access: !!opts.admin, enforce_tfa: false });
log('policy +', name);
return p;
}
async function ensureAccess(roleId, policyId) {
const filt = roleId
? `filter[role][_eq]=${roleId}&filter[policy][_eq]=${policyId}`
: `filter[role][_null]=true&filter[policy][_eq]=${policyId}`;
const a = await api('GET', `/access?${filt}&limit=1`);
if (a && a.length) return;
await api('POST', '/access', { role: roleId, policy: policyId });
log('access +', `${roleId || 'public'} ↔ policy`);
}
/**
* Legt eine Berechtigung an ODER gleicht eine bestehende an.
*
* Wichtig: Früher wurde ein vorhandener Eintrag unangetastet gelassen. Dadurch
* blieben einmal angelegte, zu enge Feldlisten für immer bestehen – spätere
* Erweiterungen (z. B. production_status für den Produktions-Agent) kamen nie
* an und führten zu 403-Fehlern. Der Bootstrap ist die Quelle der Wahrheit,
* deshalb werden Felder und Filter jetzt aktiv nachgezogen.
*/
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
const existing = await api('GET',
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1&fields=id,fields,permissions`);
if (existing && existing.length) {
const cur = existing[0];
const gleich = JSON.stringify(cur.fields ?? null) === JSON.stringify(fields)
&& JSON.stringify(cur.permissions ?? {}) === JSON.stringify(permissions);
if (!gleich) {
await api('PATCH', `/permissions/${cur.id}`, { fields, permissions });
log('perm ~', `${collection}.${action}`);
}
return;
}
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
}
async function applyPermissions() {
console.log('› Rollen, Policies, Rechte');
// Staff: volle Verwaltung
const staffRole = await ensureRole('Staff', 'badge');
const staffPol = await ensurePolicy('Staff', { app: true });
await ensureAccess(staffRole.id, staffPol.id);
for (const col of BIZ)
for (const act of ['create', 'read', 'update', 'delete'])
await ensurePermission(staffPol.id, col, act);
// Customer: nur Eigenes + Lesen der Kataloge
// Alt-Namen auf den finalen "Kunde" ziehen (idempotent).
await renameRole('Customer', CUSTOMER_ROLE_NAME);
await renameRole('Kunde (Login)', CUSTOMER_ROLE_NAME);
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
const custPol = await ensurePolicy('Customer', { app: false });
await ensureAccess(custRole.id, custPol.id);
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
await ensurePermission(custPol.id, 'directus_users', 'read',
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses', 'billing_address', 'shipping_address', 'alt_emails'],
{ id: { _eq: '$CURRENT_USER' } });
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
await ensurePermission(custPol.id, 'directus_users', 'update',
['first_name', 'last_name', 'company', 'phone', 'addresses', 'billing_address', 'shipping_address'],
{ id: { _eq: '$CURRENT_USER' } });
for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER);
for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
// Eigene Produkt-Freischaltungen lesen (bestimmt, welche Produkte der Kunde
// im Konfigurator konfigurieren darf).
await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } });
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
await ensurePermission(custPol.id, col, 'read');
// Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice).
await ensurePermission(custPol.id, 'directus_files', 'create');
await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } });
// Public (Gast): nur aktive Kataloge lesen
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
await ensureAccess(null, pubPol.id);
for (const col of ['products', 'motifs', 'price_items'])
await ensurePermission(pubPol.id, col, 'read', ['*'], { active: { _eq: true } });
// formats: aktiv (Konfigurator) ODER show_check (Textmengencheck). Sonst blieben die
// nicht öffentlich bestellbaren Check-Formate (A5, DIN lang) für Anonyme unsichtbar.
await ensurePermission(pubPol.id, 'formats', 'read', ['*'], { _or: [{ active: { _eq: true } }, { show_check: { _eq: true } }] });
await ensurePermission(pubPol.id, 'pricing_settings', 'read');
// Service (Backend + Produktion): Jobs/Orders lesen + Status schreiben
const svcRole = await ensureRole('Service', 'smart_toy');
const svcPol = await ensurePolicy('Service', { app: false });
await ensureAccess(svcRole.id, svcPol.id);
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
'motifs', 'price_items', 'machines', 'format_templates', 'render_formats'])
await ensurePermission(svcPol.id, col, 'read');
await ensurePermission(svcPol.id, 'orders', 'update',
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
await ensurePermission(svcPol.id, 'jobs', 'create');
// Agent meldet Druckfortschritt zurück.
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']);
// Agent meldet Maschinenzustand (Heartbeat).
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
await ensurePermission(svcPol.id, 'status_history', 'create');
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
// zusammen und lädt sie dafür über /assets).
await ensurePermission(svcPol.id, 'directus_files', 'read');
// Alternativer Übermittlungsweg: Backend liest die Datensätze zum Bündeln und
// markiert sie danach als gebündelt.
await ensurePermission(svcPol.id, 'batch_submissions', 'read');
await ensurePermission(svcPol.id, 'batch_submissions', 'update', ['status', 'bundle_name', 'bundled_at']);
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
'statischen Token in .env von Backend/Produktion eintragen.');
}
// ── Anzeigenamen (Klarheit im Admin) ────────────────────────────────────────
async function applyLabels() {
console.log('› Anzeigenamen');
const labels = {
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
products: { de: ['Produkt', 'Produkte'], en: ['Product', 'Products'] },
motifs: { de: ['Motiv', 'Motive'], en: ['Motif', 'Motifs'] },
vouchers: { de: ['Gutschein', 'Gutscheine'], en: ['Voucher', 'Vouchers'] },
};
for (const [collection, l] of Object.entries(labels)) {
try {
await api('PATCH', `/collections/${collection}`, {
meta: { translations: [
{ language: 'de-DE', translation: l.de[1], singular: l.de[0], plural: l.de[1] },
{ language: 'en-US', translation: l.en[1], singular: l.en[0], plural: l.en[1] },
] },
});
} catch (e) { console.warn(` Label ${collection} übersprungen: ${e.message}`); }
}
}
// ── Navigation gruppieren ────────────────────────────────────────────────────
// Hauptansicht bewusst schlank: nur Aufträge, Jobs, Gutscheine, Verlauf.
// Alles Konfigurations-artige (Produkte, Preise, Motive …) wandert in einen
// Ordner „Einstellungen"; reine Detail-/Technik-Collections werden versteckt.
async function organizeNav() {
console.log('› Navigation gruppieren');
const FOLDER = 'einstellungen';
// Ordner (Collection ohne Tabelle) anlegen, falls noch nicht vorhanden.
let folderDa = true;
try { await api('GET', `/collections/${FOLDER}`); } catch { folderDa = false; }
if (!folderDa) {
await api('POST', '/collections', {
collection: FOLDER, schema: null,
meta: { icon: 'settings', note: 'Konfiguration: Produkte, Preise, Motive, Gutschein-Einlösungen …',
translations: [
{ language: 'de-DE', translation: 'Einstellungen', singular: 'Einstellung', plural: 'Einstellungen' },
{ language: 'en-US', translation: 'Settings', singular: 'Setting', plural: 'Settings' },
] },
});
log('folder +', FOLDER);
}
const patch = async (col, meta) => {
try { await api('PATCH', `/collections/${col}`, { meta }); }
catch (e) { console.warn(` Nav ${col} übersprungen: ${e.message}`); }
};
// Hauptebene
for (const col of ['orders', 'jobs', 'vouchers', 'status_history', 'contacts'])
await patch(col, { group: null, hidden: false });
// Unter „Einstellungen"
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'])
await patch(col, { group: FOLDER, hidden: false });
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).
// Empfängerzeilen werden direkt im Auftrag gepflegt (o2m) → keine eigene Seite.
for (const col of ['order_entries', 'order_addons', 'voucher_redemptions', 'preview_usage',
'login_codes', 'signatures', 'customer_discounts', 'customer_products'])
await patch(col, { hidden: true });
}
// ── Anzeige-Vorlagen für Verknüpfungen ──────────────────────────────────────
// Ohne diese zeigt Directus bei m2o-Feldern nur die ID (z. B. Produkt „1").
// Mit „related-values" + Template erscheint der aussagekräftige Wert.
async function applyDisplays() {
console.log('› Anzeige-Vorlagen (m2o)');
const displays = [
['orders', 'product', '{{name}}'],
['orders', 'format', '{{name}}'],
['orders', 'motif', '{{name}}'],
['orders', 'customer', '{{first_name}} {{last_name}}'],
['orders', 'voucher', '{{code}}'],
['formats', 'product', '{{name}}'],
['jobs', 'order', '{{order_number}}'],
['jobs', 'machine', '{{name}}'],
['jobs', 'template', '{{name}}'],
['test_prints', 'template', '{{name}}'],
['test_prints', 'machine', '{{name}}'],
['status_history', 'order', '{{order_number}}'],
['order_entries', 'order', '{{order_number}}'],
['order_addons', 'order', '{{order_number}}'],
['order_addons', 'price_item', '{{label}}'],
['signatures', 'customer', '{{first_name}} {{last_name}}'],
['customer_discounts', 'customer', '{{first_name}} {{last_name}}'],
['customer_products', 'customer', '{{first_name}} {{last_name}}'],
['customer_products', 'product', '{{name}}'],
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
['voucher_redemptions', 'voucher', '{{code}}'],
['contacts', 'source_order', '{{order_number}}'],
];
for (const [collection, field, template] of displays) {
try {
await api('PATCH', `/fields/${collection}/${field}`, {
meta: { display: 'related-values', display_options: { template } },
});
} catch (e) { console.warn(` Display ${collection}.${field} übersprungen: ${e.message}`); }
}
}
// ── Altlasten entfernen ─────────────────────────────────────────────────────
// Ersetzt durch: eine gemeinsame `multiplier_formula` + `products.norm_qty`.
async function removeDeprecatedFields() {
console.log('› Alte Felder entfernen');
const weg = [
['pricing_settings', 'multiplier_formula_business'],
['pricing_settings', 'multiplier_formula_privat'],
['pricing_settings', 'norm_qty_business'],
['pricing_settings', 'norm_qty_privat'],
['pricing_settings', 'min_qty_business'],
['pricing_settings', 'min_qty_privat'],
];
for (const [col, fld] of weg) {
try { await api('DELETE', `/fields/${col}/${fld}`); log('field -', `${col}.${fld}`); }
catch { /* bereits entfernt */ }
}
}
// ── Projektname ─────────────────────────────────────────────────────────────
// Directus nutzt den Projektnamen als ABSENDERNAMEN aller Mails. Steht hier
// „Directus", kommen die Mails auch so beim Kunden an.
async function applyProjectName() {
console.log('› Projektname');
try {
await api('PATCH', '/settings', { project_name: 'Skrift' });
log('settings', 'project_name = Skrift');
} catch (e) { console.warn(` Projektname übersprungen: ${e.message}`); }
}
// ── Auftrags-Übersicht (Produktion) ─────────────────────────────────────────
// Sammel-Anzeigevorlage + globaler Tabellen-Preset: auf einen Blick sieht man
// je Auftrag Nummer, Empfängeranzahl, Kuvert-Bedarf, Versandart und Bezahlt.
async function applyOrdersLayout() {
console.log('› Auftrags-Übersicht (Liste)');
try {
await api('PATCH', '/collections/orders', {
meta: { display_template: '{{order_number}} · {{entries_count}}× · {{shipping_type}} · {{payment_status}}' },
});
} catch (e) { console.warn(` Sammel-Template orders übersprungen: ${e.message}`); }
const spalten = ['order_number', 'customer', 'entries_count', 'needs_envelope',
'shipping_type', 'payment_status', 'production_status', 'date_created'];
try {
const vorhanden = await api('GET',
'/presets?filter[collection][_eq]=orders&filter[role][_null]=true&filter[user][_null]=true&filter[bucket][_null]=true&limit=1');
const preset = {
collection: 'orders', bucket: null, role: null, user: null,
layout: 'tabular',
layout_query: { tabular: { fields: spalten, sort: ['-date_created'], page: 1 } },
layout_options: { tabular: { widths: {} } },
};
if (vorhanden && vorhanden[0]) await api('PATCH', `/presets/${vorhanden[0].id}`, preset);
else { await api('POST', '/presets', preset); log('preset +', 'orders (Übersicht)'); }
} catch (e) { console.warn(` Listen-Preset orders übersprungen: ${e.message}`); }
}
// ── Insights-Dashboard „Übersicht" ──────────────────────────────────────────
// Zählt neue Aufträge, Anfragen und Musterbestellungen auf einen Blick.
async function applyDashboard() {
console.log('› Dashboard (Insights)');
try {
let dash = (await api('GET', `/dashboards?filter[name][_eq]=${encodeURIComponent('Übersicht')}&limit=1`))?.[0];
if (!dash) {
dash = await api('POST', '/dashboards', {
name: 'Übersicht', icon: 'dashboard', note: 'Neue Aufträge, Anfragen & Muster auf einen Blick.',
});
log('dashboard +', 'Übersicht');
}
const vorhanden = new Set(((await api('GET', `/panels?filter[dashboard][_eq]=${dash.id}&limit=-1&fields=name`)) || []).map((p) => p.name));
const metric = (name, icon, collection, filter, x) => ({
dashboard: dash.id, name, icon, type: 'metric', show_header: true,
position_x: x, position_y: 1, width: 6, height: 6,
options: { collection, field: 'id', function: 'count', filter },
});
const panels = [
metric('Neue Aufträge', 'receipt_long', 'orders', { production_status: { _eq: 'eingegangen' } }, 1),
metric('Neue Anfragen', 'request_quote', 'offer_requests', { _and: [{ type: { _eq: 'angebot' } }, { status: { _eq: 'neu' } }] }, 7),
metric('Neue Muster', 'science', 'offer_requests', { _and: [{ type: { _eq: 'muster' } }, { status: { _eq: 'neu' } }] }, 13),
];
for (const p of panels) {
if (vorhanden.has(p.name)) continue;
try { await api('POST', '/panels', p); log('panel +', p.name); }
catch (e) { console.warn(` Panel ${p.name} übersprungen: ${e.message}`); }
}
} catch (e) { console.warn(' Dashboard übersprungen (bitte melden):', e.message); }
}
// ── Main ────────────────────────────────────────────────────────────────────
(async () => {
await login();
console.log(`Bootstrap gegen ${DIRECTUS_URL}`);
await applySchema();
await applySeed();
await applyLabels();
await organizeNav();
await applyDisplays();
await removeDeprecatedFields();
await applyProjectName();
await applyOrdersLayout();
await applyDashboard();
if (SKIP_PERMISSIONS) console.log('› Rechte übersprungen (SKIP_PERMISSIONS=1)');
else await applyPermissions();
console.log('✓ Fertig.');
})().catch((e) => { console.error('\n✗ Abbruch:', e.message); process.exit(1); });