Tabs (Eingabe + Rendering): - Tab-Taste fügt echten Tabulator ein (App-Konfigurator + Directus manueller Druck); \t in beiden Zeichen-Whitelists erlaubt. - Renderer: Zeilen mit führenden Tabs werden nicht mehr nach Wortbreite umgebrochen; Tab-Spruenge an den rechten Rand geclampt; bei Ueberlauf rechtsbuendig (Datum ganz rechts, ohne Absturz nach links). Tab-loser Text bleibt bit-identisch. Mailer: - loadSettings bricht nicht mehr ab, wenn team_email fehlt (Team- Empfaenger separat gelesen). - Eingebaute Fallback-Vorlagen fuer order_created + Status-Mails, damit Bestaetigung/Status auch ohne DB-Vorlage zugestellt werden. Weitere ausstehende Arbeit: - Abweichender Text je Schriftstueck (#5) in Generierung/Konfigurator. - Produktions-Board: Format-Sammelgruppen (Bulk) + Bootstrap-Feld. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1205 lines
79 KiB
JavaScript
1205 lines
79 KiB
JavaScript
/**
|
||
* 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).' }),
|
||
// 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', '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 }),
|
||
] },
|
||
|
||
{ 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']),
|
||
dropdown('source', ['configurator', 'portal', 'operator', 'extern'], { default: 'configurator', note: '„extern" = Bestellung über eine andere Plattform (z. B. Etsy), ohne Preis/Zahlung.' }),
|
||
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
|
||
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
|
||
{ default: 'entwurf' }),
|
||
// Produktionsstatus (fürs Kanban) – getrennt vom kaufmännischen Status.
|
||
dropdown('production_status', ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig', 'versendet', 'storniert'],
|
||
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
|
||
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
|
||
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
|
||
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
|
||
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
|
||
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
|
||
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
|
||
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
|
||
f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }),
|
||
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
|
||
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
|
||
m2o('motif', 'motifs'),
|
||
m2o('motif_upload', 'directus_files'),
|
||
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
|
||
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
|
||
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
|
||
f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }),
|
||
// Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word).
|
||
{ field: 'source_file', type: 'uuid',
|
||
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte) – erstes/primäres.' },
|
||
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||
// Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste.
|
||
f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }),
|
||
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
|
||
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
|
||
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
|
||
f('entries_count', 'integer', { interface: 'input', default: 0 }),
|
||
f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }),
|
||
f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }),
|
||
f('net_total', 'float', { interface: 'input' }),
|
||
f('vat_amount', 'float', { interface: 'input' }),
|
||
f('gross_total', 'float', { interface: 'input' }),
|
||
m2o('voucher', 'vouchers'),
|
||
dropdown('payment_method', ['paypal', 'rechnung']),
|
||
dropdown('payment_status', ['offen', 'bezahlt', 'storniert'], { default: 'offen' }),
|
||
f('payment_ref', 'string', { interface: 'input' }),
|
||
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
|
||
{ 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('width', 'float', { interface: 'input', note: 'Zoll' }),
|
||
f('height', 'float', { interface: 'input', note: 'Zoll' }),
|
||
f('xpos', 'float', { interface: 'input', default: 0 }),
|
||
f('ypos', 'float', { interface: 'input', default: 0 }),
|
||
f('scale', 'float', { interface: 'input', default: 1 }),
|
||
f('rotation', 'integer', { interface: 'input', default: 0 }),
|
||
] },
|
||
|
||
// Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das
|
||
// Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich
|
||
// eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend).
|
||
{ collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [
|
||
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }),
|
||
f('name', 'string', { interface: 'input', required: true }),
|
||
dropdown('kind', ['brief', 'umschlag'], { default: 'brief' }),
|
||
f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }),
|
||
f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }),
|
||
f('margin_top_mm', 'float', { interface: 'input', default: 25 }),
|
||
f('margin_left_mm', 'float', { interface: 'input', default: 20 }),
|
||
f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat).' }),
|
||
f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }),
|
||
f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }),
|
||
// Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine).
|
||
m2o('print_template', 'format_templates'),
|
||
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||
] },
|
||
|
||
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
|
||
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
||
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
|
||
m2o('machine', 'machines'),
|
||
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
|
||
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
|
||
// Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage
|
||
// für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem
|
||
// an der Maschine eingelegten Format entspricht.
|
||
f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }),
|
||
f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }),
|
||
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
|
||
f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }),
|
||
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
|
||
// Vom Druck-Popup gesetzt; leer = Template-Werte verwenden.
|
||
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
|
||
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
|
||
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
|
||
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
|
||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||
] },
|
||
|
||
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
|
||
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
||
f('status', 'string', { interface: 'input' }),
|
||
f('note', 'text', { interface: 'input-multiline' }),
|
||
m2o('changed_by', 'directus_users'),
|
||
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||
] },
|
||
|
||
// 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: {} },
|
||
] },
|
||
|
||
// E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis.
|
||
{ collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [
|
||
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }),
|
||
f('label', 'string', { interface: 'input', note: 'Bezeichnung im Admin.' }),
|
||
f('subject', 'string', { interface: 'input', required: true, note: 'Betreff. Platzhalter: {{order_id}}, {{name}}, {{code}}, {{link}} …' }),
|
||
f('body', 'text', { interface: 'input-rich-text-html', note: 'Text (WYSIWYG/HTML). Platzhalter in {{...}}.' }),
|
||
f('append_signature', 'boolean', { interface: 'boolean', default: true, note: 'Signatur automatisch anhängen (bei Mails mit eigenem Footer aus).' }),
|
||
f('attach_agb', 'boolean', { interface: 'boolean', default: false, note: 'AGB-PDF an diese Mail anhängen.' }),
|
||
f('enabled', 'boolean', { interface: 'boolean', default: true }),
|
||
] },
|
||
|
||
// Gemeinsame E-Mail-Einstellungen (Signatur, Portal-Link, AGB, Footer).
|
||
{ collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [
|
||
f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }),
|
||
f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }),
|
||
f('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: {} },
|
||
] },
|
||
];
|
||
|
||
// ── Schema anlegen ──────────────────────────────────────────────────────────
|
||
async function applySchema() {
|
||
console.log('› Collections & Felder');
|
||
const existingCols = new Set((await api('GET', '/collections')).map((c) => c.collection));
|
||
const allFields = await api('GET', '/fields');
|
||
const existingFields = new Set(allFields.map((x) => `${x.collection}.${x.field}`));
|
||
const existingRels = new Set((await api('GET', '/relations')).map((r) => `${r.collection}.${r.field}`));
|
||
const relationsToCreate = [];
|
||
|
||
for (const c of collections) {
|
||
if (!existingCols.has(c.collection)) {
|
||
await api('POST', '/collections', { collection: c.collection, meta: c.meta, schema: {}, fields: [ID] });
|
||
log('collection +', c.collection);
|
||
} else { log('collection ok', c.collection); }
|
||
|
||
for (const fld of c.fields) {
|
||
const { __related, __onDelete, ...clean } = fld;
|
||
if (!existingFields.has(`${c.collection}.${fld.field}`)) {
|
||
await api('POST', `/fields/${c.collection}`, clean);
|
||
log('field +', `${c.collection}.${fld.field}`);
|
||
}
|
||
if (__related) relationsToCreate.push({ collection: c.collection, field: fld.field, related: __related, onDelete: __onDelete });
|
||
}
|
||
}
|
||
|
||
// Profilfelder direkt am Login-Konto (Kunde = directus_users).
|
||
console.log('› Kundenfelder an directus_users');
|
||
for (const fld of USER_FIELDS) {
|
||
if (!existingFields.has(`directus_users.${fld.field}`)) {
|
||
await api('POST', '/fields/directus_users', fld);
|
||
log('field +', `directus_users.${fld.field}`);
|
||
} else {
|
||
// Vorhandenes Feld: Oberfläche/Optionen aktualisieren (z. B. Adressliste).
|
||
try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: fld.meta }); } catch { /* egal */ }
|
||
}
|
||
}
|
||
// Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren
|
||
// immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls
|
||
// ein Feldname in dieser Directus-Version anders heißt.
|
||
for (const field of HIDE_USER_FIELDS) {
|
||
try {
|
||
await api('PATCH', `/fields/directus_users/${field}`, { meta: { hidden: true } });
|
||
log('feld versteckt', `directus_users.${field}`);
|
||
} catch (e) {
|
||
console.warn(` Feld directus_users.${field} nicht ausgeblendet: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
console.log('› Beziehungen (m2o)');
|
||
for (const r of relationsToCreate) {
|
||
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
|
||
await api('POST', '/relations', {
|
||
collection: r.collection, field: r.field, related_collection: r.related,
|
||
schema: { on_delete: r.onDelete }, meta: {},
|
||
});
|
||
log('relation +', `${r.collection}.${r.field} → ${r.related}`);
|
||
}
|
||
|
||
// m2m-Verdrahtung orders.addons ↔ price_items über order_addons
|
||
console.log('› m2m orders.addons');
|
||
try {
|
||
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
|
||
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
|
||
log('m2m verdrahtet');
|
||
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
||
|
||
// o2m am Kundenkonto: Unterschriften + Rabatte direkt im User anzeigen.
|
||
console.log('› o2m-Verdrahtung');
|
||
try {
|
||
await api('PATCH', '/relations/signatures/customer', { meta: { one_field: 'signatures' } });
|
||
await api('PATCH', '/relations/customer_discounts/customer', { meta: { one_field: 'discounts' } });
|
||
await api('PATCH', '/relations/customer_products/customer', { meta: { one_field: 'unlocked_products' } });
|
||
await api('PATCH', '/relations/order_entries/order', { meta: { one_field: 'entries' } });
|
||
log('o2m verdrahtet');
|
||
} catch (e) { console.warn(' o2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
||
}
|
||
|
||
// ── Seed-Daten ──────────────────────────────────────────────────────────────
|
||
async function seedItem(collection, uniqueField, item) {
|
||
const existing = await api('GET', `/items/${collection}?filter[${uniqueField}][_eq]=${encodeURIComponent(item[uniqueField])}&limit=1`);
|
||
if (existing && existing.length) return existing[0];
|
||
const created = await api('POST', `/items/${collection}`, item);
|
||
log('seed +', `${collection}.${item[uniqueField]}`);
|
||
return created;
|
||
}
|
||
|
||
async function applySeed() {
|
||
console.log('› Seed');
|
||
const products = [
|
||
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 },
|
||
// Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere
|
||
// Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen).
|
||
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, active: true, sort: 2 },
|
||
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
||
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
|
||
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
|
||
// bestellbar ist; für andere bleibt es (type contact) ein Anfrage-Produkt.
|
||
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', visibility: 'unternehmen', pricing_mode: 'formel', base_price: 5.0, input_mode: 'datei', active: true, sort: 4 },
|
||
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', visibility: 'unternehmen', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
|
||
];
|
||
const productByKey = {};
|
||
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
|
||
// Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND
|
||
// bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen
|
||
// können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht).
|
||
try {
|
||
const us = productByKey.unterschriftenservice;
|
||
if (us?.id) {
|
||
const patch = {};
|
||
if (us.input_mode !== 'datei') patch.input_mode = 'datei';
|
||
if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel';
|
||
if (Object.keys(patch).length) {
|
||
await api('PATCH', `/items/products/${us.id}`, patch);
|
||
log('unterschriftenservice →', JSON.stringify(patch));
|
||
}
|
||
}
|
||
} catch { /* nicht kritisch */ }
|
||
|
||
const formats = [
|
||
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true },
|
||
{ product: productByKey.postkarten.id, key: 'a6_hoch', name: 'A6 Hochformat', active: true },
|
||
{ product: productByKey.postkarten.id, key: 'a6_quer', name: 'A6 Querformat', active: true },
|
||
];
|
||
for (const fmt of formats) await seedItem('formats', 'key', fmt);
|
||
|
||
// Preise: kundenseitige Werte aus dem Konfigurator; 0 = im Admin setzen.
|
||
const priceItems = [
|
||
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
|
||
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
|
||
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
|
||
{ key: 'sammelversand_klein', label: 'Sammelversand (bis 5 Stück)', category: 'versand', price: 1.80, unit: 'einmalig', active: true },
|
||
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
|
||
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
|
||
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
|
||
{ key: 'gestaltungsservice', label: 'Gestaltungsservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
|
||
{ key: 'motiv_upload', label: 'Motiv-Upload', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
|
||
];
|
||
for (const pi of priceItems) await seedItem('price_items', 'key', pi);
|
||
|
||
// pricing_settings (Singleton)
|
||
const ps = await api('GET', '/items/pricing_settings');
|
||
if (!ps || !ps.formula) {
|
||
await api('PATCH', '/items/pricing_settings', {
|
||
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
|
||
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
|
||
// Beispiel-Staffel (relativ hoch angesetzt) – in Directus anpassbar.
|
||
quantity_discounts: [
|
||
{ min_qty: 250, percent: 5 },
|
||
{ min_qty: 500, percent: 10 },
|
||
{ min_qty: 1000, percent: 15 },
|
||
],
|
||
});
|
||
log('seed singleton pricing_settings');
|
||
}
|
||
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
|
||
// erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt.
|
||
const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))';
|
||
if (!ps || !ps.multiplier_formula) {
|
||
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||
log('seed multiplier-formel');
|
||
} else {
|
||
// Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent
|
||
// (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die
|
||
// Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten.
|
||
const m = String(ps.multiplier_formula).match(
|
||
/^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/,
|
||
);
|
||
if (m) {
|
||
const aOld = Number(m[1]); const eOld = Number(m[2]);
|
||
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||
const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1');
|
||
for (const p of (prods || [])) {
|
||
const patch = {};
|
||
if (p.price_factor_a == null) patch.price_factor_a = aOld;
|
||
if (p.price_factor_e == null) patch.price_factor_e = eOld;
|
||
if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch);
|
||
}
|
||
log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`);
|
||
}
|
||
}
|
||
|
||
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
|
||
const G = 'Guten Tag {{name}},'; // gemeinsame Anrede
|
||
// Reiche Bestellbestätigung (Design an das bisherige n8n-Template angelehnt,
|
||
// aber mit flachen {{platzhaltern}}, die der Mailer-Hook befüllt).
|
||
const orderCreatedBody = `
|
||
<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>` },
|
||
];
|
||
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
||
|
||
// Druck-Templates – Werte 1:1 aus der config.json des alten Job-Managers.
|
||
// Je Format gibt es zwei Varianten (v1/v2); die zweite ist die zusätzliche
|
||
// Platzierung (u. a. für individuelle Kuvert-Beschriftung). Werte werden per
|
||
// Upsert gesetzt, damit falsche Altwerte beim Bootstrap korrigiert werden.
|
||
const formatTemplates = [
|
||
{ key: 'a4_single', name: 'A4 Brief', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||
{ key: 'a6_v1', name: 'A6 Hochformat – Variante 1', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||
{ key: 'a6_v2', name: 'A6 Hochformat – Variante 2', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
||
{ key: 'a6l_v1', name: 'A6 Querformat – Variante 1', type: 'brief', width: 5.8, height: 4.1, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||
{ key: 'a6l_v2', name: 'A6 Querformat – Variante 2', type: 'brief', width: 5.8, height: 4.1, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
||
// DIN Lang – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
|
||
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang – Variante 1', type: 'umschlag', width: 4.33, height: 8.27, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||
{ key: 'kuvert_a_v2', name: 'Kuvert DIN Lang – Variante 2', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||
// C6 – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
|
||
{ key: 'kuvert_b_v1', name: 'Kuvert C6 – Variante 1', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||
{ key: 'kuvert_b_v2', name: 'Kuvert C6 – Variante 2', type: 'umschlag', width: 4.33, height: 6.38, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||
];
|
||
for (const t of formatTemplates) {
|
||
const vorhanden = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(t.key)}&limit=1&fields=id`);
|
||
if (vorhanden && vorhanden.length) await api('PATCH', `/items/format_templates/${vorhanden[0].id}`, t);
|
||
else { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); }
|
||
}
|
||
|
||
// Rendering-Formate (zentral fürs Backend). Werte = bisherige Defaults.
|
||
const renderFormats = [
|
||
{ key: 'a4', name: 'A4', kind: 'brief', page_width_mm: 210, page_height_mm: 297, margin_top_mm: 25, margin_left_mm: 20, line_height_factor: 1.35, line_limit: 27, active: true },
|
||
{ key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.3, line_limit: 14, active: true },
|
||
{ key: 'a6l', name: 'A6 Querformat', kind: 'brief', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true },
|
||
{ key: 'din_lang', name: 'DIN Lang Kuvert', kind: 'umschlag', page_width_mm: 220, page_height_mm: 110, margin_top_mm: 50, margin_left_mm: 10, line_height_factor: 1.2, active: true },
|
||
{ key: 'c6', name: 'C6 Kuvert', kind: 'umschlag', page_width_mm: 162, page_height_mm: 114, margin_top_mm: 60, margin_left_mm: 10, line_height_factor: 1.2, active: true },
|
||
];
|
||
// Upsert (nicht nur anlegen), damit falsche Alt-Maße korrigiert werden – z. B.
|
||
// Postkarten (a6p/a6l), die sonst nicht aufs Kartenformat passen.
|
||
for (const rf of renderFormats) {
|
||
const vorhanden = await api('GET', `/items/render_formats?filter[key][_eq]=${encodeURIComponent(rf.key)}&limit=1&fields=id`);
|
||
if (vorhanden && vorhanden.length) await api('PATCH', `/items/render_formats/${vorhanden[0].id}`, rf);
|
||
else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); }
|
||
}
|
||
|
||
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
|
||
const es = await api('GET', '/items/email_settings');
|
||
if (!es || !es.signature) {
|
||
await api('PATCH', '/items/email_settings', {
|
||
sender_name: 'Skrift',
|
||
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
|
||
team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de').trim(),
|
||
signature: '<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',
|
||
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'];
|
||
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
|
||
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
|
||
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
|
||
|
||
const CUSTOMER_ROLE_NAME = 'Kunde';
|
||
|
||
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
|
||
async function renameRole(oldName, newName) {
|
||
const neu = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(newName)}&limit=1`);
|
||
if (neu && neu.length) return;
|
||
const alt = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(oldName)}&limit=1`);
|
||
if (alt && alt.length) {
|
||
await api('PATCH', `/roles/${alt[0].id}`, { name: newName });
|
||
log('role umbenannt', `${oldName} → ${newName}`);
|
||
}
|
||
}
|
||
|
||
async function ensureRole(name, icon) {
|
||
const roles = await api('GET', `/roles?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
|
||
if (roles && roles.length) return roles[0];
|
||
const r = await api('POST', '/roles', { name, icon });
|
||
log('role +', name);
|
||
return r;
|
||
}
|
||
async function ensurePolicy(name, opts = {}) {
|
||
const ps = await api('GET', `/policies?filter[name][_eq]=${encodeURIComponent(name)}&limit=1`);
|
||
if (ps && ps.length) return ps[0];
|
||
const p = await api('POST', '/policies', { name, app_access: !!opts.app, admin_access: !!opts.admin, enforce_tfa: false });
|
||
log('policy +', name);
|
||
return p;
|
||
}
|
||
async function ensureAccess(roleId, policyId) {
|
||
const filt = roleId
|
||
? `filter[role][_eq]=${roleId}&filter[policy][_eq]=${policyId}`
|
||
: `filter[role][_null]=true&filter[policy][_eq]=${policyId}`;
|
||
const a = await api('GET', `/access?${filt}&limit=1`);
|
||
if (a && a.length) return;
|
||
await api('POST', '/access', { role: roleId, policy: policyId });
|
||
log('access +', `${roleId || 'public'} ↔ policy`);
|
||
}
|
||
/**
|
||
* Legt eine Berechtigung an ODER gleicht eine bestehende an.
|
||
*
|
||
* Wichtig: Früher wurde ein vorhandener Eintrag unangetastet gelassen. Dadurch
|
||
* blieben einmal angelegte, zu enge Feldlisten für immer bestehen – spätere
|
||
* Erweiterungen (z. B. production_status für den Produktions-Agent) kamen nie
|
||
* an und führten zu 403-Fehlern. Der Bootstrap ist die Quelle der Wahrheit,
|
||
* deshalb werden Felder und Filter jetzt aktiv nachgezogen.
|
||
*/
|
||
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
|
||
const existing = await api('GET',
|
||
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1&fields=id,fields,permissions`);
|
||
if (existing && existing.length) {
|
||
const cur = existing[0];
|
||
const gleich = JSON.stringify(cur.fields ?? null) === JSON.stringify(fields)
|
||
&& JSON.stringify(cur.permissions ?? {}) === JSON.stringify(permissions);
|
||
if (!gleich) {
|
||
await api('PATCH', `/permissions/${cur.id}`, { fields, permissions });
|
||
log('perm ~', `${collection}.${action}`);
|
||
}
|
||
return;
|
||
}
|
||
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
|
||
}
|
||
|
||
async function applyPermissions() {
|
||
console.log('› Rollen, Policies, Rechte');
|
||
|
||
// Staff: volle Verwaltung
|
||
const staffRole = await ensureRole('Staff', 'badge');
|
||
const staffPol = await ensurePolicy('Staff', { app: true });
|
||
await ensureAccess(staffRole.id, staffPol.id);
|
||
for (const col of BIZ)
|
||
for (const act of ['create', 'read', 'update', 'delete'])
|
||
await ensurePermission(staffPol.id, col, act);
|
||
|
||
// Customer: nur Eigenes + Lesen der Kataloge
|
||
// Alt-Namen auf den finalen "Kunde" ziehen (idempotent).
|
||
await renameRole('Customer', CUSTOMER_ROLE_NAME);
|
||
await renameRole('Kunde (Login)', CUSTOMER_ROLE_NAME);
|
||
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
|
||
const custPol = await ensurePolicy('Customer', { app: false });
|
||
await ensureAccess(custRole.id, custPol.id);
|
||
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
|
||
await ensurePermission(custPol.id, 'directus_users', 'read',
|
||
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses', 'billing_address', 'shipping_address', 'alt_emails'],
|
||
{ id: { _eq: '$CURRENT_USER' } });
|
||
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
|
||
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
|
||
await ensurePermission(custPol.id, 'directus_users', 'update',
|
||
['first_name', 'last_name', 'company', 'phone', 'addresses', 'billing_address', 'shipping_address'],
|
||
{ id: { _eq: '$CURRENT_USER' } });
|
||
for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER);
|
||
for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER);
|
||
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
|
||
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
|
||
// Eigene Produkt-Freischaltungen lesen (bestimmt, welche Produkte der Kunde
|
||
// im Konfigurator konfigurieren darf).
|
||
await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } });
|
||
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
|
||
await ensurePermission(custPol.id, col, 'read');
|
||
// Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice).
|
||
await ensurePermission(custPol.id, 'directus_files', 'create');
|
||
await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } });
|
||
|
||
// Public (Gast): nur aktive Kataloge lesen
|
||
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
|
||
await ensureAccess(null, pubPol.id);
|
||
for (const col of ['products', 'formats', 'motifs', 'price_items'])
|
||
await ensurePermission(pubPol.id, col, 'read', ['*'], { active: { _eq: true } });
|
||
await ensurePermission(pubPol.id, 'pricing_settings', 'read');
|
||
|
||
// Service (Backend + Produktion): Jobs/Orders lesen + Status schreiben
|
||
const svcRole = await ensureRole('Service', 'smart_toy');
|
||
const svcPol = await ensurePolicy('Service', { app: false });
|
||
await ensureAccess(svcRole.id, svcPol.id);
|
||
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
|
||
'motifs', 'price_items', 'machines', 'format_templates', 'render_formats'])
|
||
await ensurePermission(svcPol.id, col, 'read');
|
||
await ensurePermission(svcPol.id, 'orders', 'update',
|
||
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
|
||
await ensurePermission(svcPol.id, 'jobs', 'create');
|
||
// Agent meldet Druckfortschritt zurück.
|
||
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']);
|
||
// Agent meldet Maschinenzustand (Heartbeat).
|
||
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
|
||
await ensurePermission(svcPol.id, 'status_history', 'create');
|
||
|
||
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
||
'statischen Token in .env von Backend/Produktion eintragen.');
|
||
}
|
||
|
||
// ── Anzeigenamen (Klarheit im Admin) ────────────────────────────────────────
|
||
async function applyLabels() {
|
||
console.log('› Anzeigenamen');
|
||
const labels = {
|
||
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
|
||
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
|
||
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
|
||
products: { de: ['Produkt', 'Produkte'], en: ['Product', 'Products'] },
|
||
motifs: { de: ['Motiv', 'Motive'], en: ['Motif', 'Motifs'] },
|
||
vouchers: { de: ['Gutschein', 'Gutscheine'], en: ['Voucher', 'Vouchers'] },
|
||
};
|
||
for (const [collection, l] of Object.entries(labels)) {
|
||
try {
|
||
await api('PATCH', `/collections/${collection}`, {
|
||
meta: { translations: [
|
||
{ language: 'de-DE', translation: l.de[1], singular: l.de[0], plural: l.de[1] },
|
||
{ language: 'en-US', translation: l.en[1], singular: l.en[0], plural: l.en[1] },
|
||
] },
|
||
});
|
||
} catch (e) { console.warn(` Label ${collection} übersprungen: ${e.message}`); }
|
||
}
|
||
}
|
||
|
||
// ── Navigation gruppieren ────────────────────────────────────────────────────
|
||
// Hauptansicht bewusst schlank: nur Aufträge, Jobs, Gutscheine, Verlauf.
|
||
// Alles Konfigurations-artige (Produkte, Preise, Motive …) wandert in einen
|
||
// Ordner „Einstellungen"; reine Detail-/Technik-Collections werden versteckt.
|
||
async function organizeNav() {
|
||
console.log('› Navigation gruppieren');
|
||
const FOLDER = 'einstellungen';
|
||
|
||
// Ordner (Collection ohne Tabelle) anlegen, falls noch nicht vorhanden.
|
||
let folderDa = true;
|
||
try { await api('GET', `/collections/${FOLDER}`); } catch { folderDa = false; }
|
||
if (!folderDa) {
|
||
await api('POST', '/collections', {
|
||
collection: FOLDER, schema: null,
|
||
meta: { icon: 'settings', note: 'Konfiguration: Produkte, Preise, Motive, Gutschein-Einlösungen …',
|
||
translations: [
|
||
{ language: 'de-DE', translation: 'Einstellungen', singular: 'Einstellung', plural: 'Einstellungen' },
|
||
{ language: 'en-US', translation: 'Settings', singular: 'Setting', plural: 'Settings' },
|
||
] },
|
||
});
|
||
log('folder +', FOLDER);
|
||
}
|
||
|
||
const patch = async (col, meta) => {
|
||
try { await api('PATCH', `/collections/${col}`, { meta }); }
|
||
catch (e) { console.warn(` Nav ${col} übersprungen: ${e.message}`); }
|
||
};
|
||
|
||
// Hauptebene
|
||
for (const col of ['orders', 'jobs', 'vouchers', 'status_history'])
|
||
await patch(col, { group: null, hidden: false });
|
||
// Unter „Einstellungen"
|
||
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
|
||
'email_templates', 'email_settings', 'muster_settings', '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}}'],
|
||
];
|
||
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); });
|