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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user