Directus - Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/ Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange) und Empfängerliste mit Vorschau von Schriftstück und Kuvert. - Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche. - Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang. - Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert), Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard. - Absender aller Mails ist Skrift (Projektname + email_settings); die Angebotsanfrage nennt jetzt das angefragte Produkt. Preislogik - Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt. - Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen. - Preis pro Stück ohne einmalige Kosten (unit_net_total). - Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt. - Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm. - Briefnummern 1-basiert: Dateiname entspricht der Briefnummer. app.skrift.de - Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als eigene Seite, Kontaktprodukte direkt im Anfrageformular. - Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator. - Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet. - Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar, Standardmenge 100; Checkout-Checkboxen im Kartenstil. Produktion - skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter und automatischer Übergang in die Kuvertierung. WordPress - Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt, stündlicher Abgleich der Preisdaten in WordPress und ein sicherer Formel-Interpreter, damit Formeländerungen automatisch greifen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
7.5 KiB
JavaScript
159 lines
7.5 KiB
JavaScript
/**
|
||
* Skrift – Mail-Hook
|
||
* ---------------------------------------------------------------------------
|
||
* Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`)
|
||
* mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang.
|
||
*
|
||
* orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung)
|
||
* orders.items.update → Vorlage mit dem Schlüssel des neuen Status
|
||
* (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv
|
||
*
|
||
* Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er).
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const STATUS_LABEL = {
|
||
wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt',
|
||
material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen',
|
||
in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt',
|
||
versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert',
|
||
};
|
||
const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
||
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||
|
||
const render = (str, vars) =>
|
||
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
||
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||
|
||
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
||
function adresseHtml(a) {
|
||
if (!a || typeof a !== 'object') return '';
|
||
const zeilen = [];
|
||
if (a.company) zeilen.push(`<strong>${esc(a.company)}</strong>`);
|
||
zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' ')));
|
||
if (a.street) zeilen.push(esc(a.street));
|
||
zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' ')));
|
||
if (a.country) zeilen.push(esc(a.country));
|
||
let html = zeilen.filter(Boolean).join('<br>');
|
||
if (a.email) html += `<br><br>E-Mail: ${esc(a.email)}`;
|
||
if (a.phone) html += `<br>Telefon: ${esc(a.phone)}`;
|
||
return html;
|
||
}
|
||
|
||
export default (register, { services, getSchema, logger }) => {
|
||
const { ItemsService, MailService } = services;
|
||
|
||
async function loadTemplate(key, schema) {
|
||
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||
filter: { key: { _eq: key }, enabled: { _eq: true } },
|
||
limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'],
|
||
});
|
||
return rows?.[0] || null;
|
||
}
|
||
|
||
async function loadSettings(schema) {
|
||
try {
|
||
return await new ItemsService('email_settings', { schema, accountability: null })
|
||
.readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] });
|
||
} catch { return {}; }
|
||
}
|
||
|
||
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
||
async function agbAnhang(schema, settings) {
|
||
if (!settings?.agb_file) return null;
|
||
try {
|
||
const file = await new ItemsService('directus_files', { schema, accountability: null })
|
||
.readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] });
|
||
if (!file?.filename_disk) return null;
|
||
const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk));
|
||
return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' };
|
||
} catch (e) {
|
||
logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function sendForOrder(orderId, key, extraVars = {}) {
|
||
const schema = await getSchema();
|
||
const tpl = await loadTemplate(key, schema);
|
||
if (!tpl) return; // keine (aktive) Vorlage → keine Mail
|
||
|
||
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
||
fields: [
|
||
'order_number', 'customer_email', 'entries_count', 'shipping_type',
|
||
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
||
'customer.first_name', 'customer.last_name', 'customer.company',
|
||
'product.name', 'format.name',
|
||
],
|
||
});
|
||
if (!order?.customer_email) return;
|
||
|
||
const settings = await loadSettings(schema);
|
||
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
||
|| order.customer?.company || '';
|
||
|
||
const net = Number(order.net_total) || 0;
|
||
const vat = Number(order.vat_amount) || 0;
|
||
const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19;
|
||
|
||
const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine')
|
||
? `<table role="presentation" width="100%" style="background:#fafafa;border-radius:6px;margin-top:12px;"><tr><td style="padding:12px 15px;">
|
||
<p style="margin:0 0 6px;font-weight:600;color:#1a365d;">Umschlag</p>
|
||
<p style="margin:0;font-size:14px;color:#666;">Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}</p>
|
||
</td></tr></table>`
|
||
: '';
|
||
|
||
const liefer = adresseHtml(order.shipping_address);
|
||
const lieferBlock = liefer
|
||
? `<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Lieferadresse</h2><p style="margin:0;line-height:1.6;">${liefer}</p></td></tr>`
|
||
: '';
|
||
|
||
const vars = {
|
||
order_id: order.order_number, name, link: settings.portal_url || '',
|
||
product: esc(order.product?.name || ''), menge: order.entries_count ?? '',
|
||
format: esc(order.format?.name || ''),
|
||
versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
|
||
kuvert_block: kuvertBlock,
|
||
netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR',
|
||
rechnungsadresse: adresseHtml(order.billing),
|
||
lieferadresse_block: lieferBlock,
|
||
footer: settings.company_footer || '',
|
||
...extraVars,
|
||
};
|
||
|
||
let html = render(tpl.body, vars);
|
||
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
||
|
||
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html };
|
||
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
||
if (settings.sender_email) {
|
||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||
}
|
||
if (tpl.attach_agb) {
|
||
const anhang = await agbAnhang(schema, settings);
|
||
if (anhang) nachricht.attachments = [anhang];
|
||
}
|
||
await new MailService({ schema, accountability: null }).send(nachricht);
|
||
logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`);
|
||
}
|
||
|
||
register.action('orders.items.create', async ({ key, payload }) => {
|
||
if (payload?.status === 'entwurf') return;
|
||
try { await sendForOrder(key, 'order_created'); }
|
||
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
||
});
|
||
|
||
register.action('orders.items.update', async ({ keys, payload }) => {
|
||
if (!payload || payload.status === undefined) return;
|
||
const status = payload.status;
|
||
for (const id of keys || []) {
|
||
try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); }
|
||
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
||
}
|
||
});
|
||
};
|