Unterschriftenservice, Extern-Modus, Adressmodell, Alt-Mail, Team-Mails

skrift-app (Konfigurator):
- Datei-Produkte (Unterschriftenservice): Mehrfach-Upload (PDF/Word/Excel),
  komplett preisfrei (keine Preisleiste/Aufpreise/Kostenübersicht/Zahlung)
- Extern-/Etsy-Modus (?extern=1&versand&kuvert&beschriftung&menge): vom Betreiber
  vorkonfiguriert, Kunde füllt nur Empfänger/Text/Rechnungsdaten/Übersicht,
  keine Preise/Zahlung; Menge greift immer (auch >25 / ohne Empfänger); eigener
  Danke-Text
- Adressmodell: zwei feste Adressen (Rechnung/Lieferung) ohne Namen statt
  Dropdown; Auto-Vorbefüllung; Lieferadresse nur bei Sammelversand; abweichende
  Lieferadresse per Default aus; Pflichtfelder erzwungen
- Platzhalter-Tabelle immer im Text-Schritt (auch Sammelversand ohne Kuvert) +
  Excel; Tabellen-Eingabe nur bis 25, „Ab 26"-Hinweis entfernt
- Stammdaten: zwei Adressblöcke + Erfolgsmeldung

Directus/Backend:
- Signatur-Endpunkte /signature-preview + /signature-render (Proxy zum
  signature-service, Marker + Konto-Offsets), Modul-Reiter „Unterschriften"
  (aus Auftrag oder manuell), Tabelle über volle Breite
- Generierung läuft bei jeder Bestellung; Kuverts immer (Datei-Produkt: nur
  Kuvert, DIN Lang als Default); source_files am Auftrag
- Produktions-Board: „Dateien"-Button (hochgeladene Originale, keine SVGs)
- Alt-Mail-Login: Zweit-Mails melden im selben Konto an (skrift-auth)
- Team-Mails schlank + konfigurierbar (email_settings.team_email) für
  Bestellung/Muster/Anfrage; keine Bestellbestätigung bei Datei-Produkten,
  separate Vorlagen unterschriften_eingang/extern_eingang
- Rechnungsadresse wird ins Konto übernommen, wenn dort noch keine steht
- Kunden-Policy: directus_files create/read (Upload); Bootstrap: users
  billing_address/shipping_address/alt_emails, orders.source_files/source=extern,
  signatures marker/offsets, unterschriftenservice input_mode=datei/pricing_mode=formel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-26 14:52:09 +02:00
parent 6232cecdb1
commit 3cb93d078e
12 changed files with 778 additions and 258 deletions

View File

@@ -130,8 +130,16 @@ const USER_FIELDS = [
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }),
f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }),
f('phone', 'string', { interface: 'input' }),
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }),
// 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 },
@@ -241,7 +249,7 @@ const collections = [
m2o('format', 'formats'),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
dropdown('font', ['tilda', 'alva', 'ellie']),
dropdown('source', ['configurator', 'portal', 'operator'], { default: 'configurator' }),
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' }),
@@ -266,8 +274,10 @@ const collections = [
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).' },
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).' }),
@@ -435,6 +445,12 @@ const collections = [
{ 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: {} },
] },
@@ -472,6 +488,7 @@ const collections = [
{ 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}}).' }),
@@ -622,6 +639,21 @@ async function applySeed() {
];
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 },
@@ -733,6 +765,11 @@ async function applySeed() {
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 });
@@ -775,6 +812,7 @@ async function applySeed() {
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',
@@ -883,12 +921,12 @@ async function applyPermissions() {
await ensureAccess(custRole.id, custPol.id);
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
await ensurePermission(custPol.id, 'directus_users', 'read',
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'],
['id', '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'],
['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);
@@ -899,6 +937,9 @@ async function applyPermissions() {
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 });