Files
skrift-directus/extensions/directus-extension-skrift-contacts/dist/index.js
2026-08-24 12:13:58 +02:00

206 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
* ---------------------------------------------------------------------------
* Rund um die Collections `contacts` und `contact_actions`:
*
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
*
* 2) STANDARDAKTIONEN (contact_actions) —
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
*
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
*
* Env:
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
*
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
*/
const RETENTION_DAYS = 30;
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
function contactHtml(c, extraNote) {
const anschrift = [
c.company ? `<strong>${esc(c.company)}</strong>` : '',
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
esc([c.street, c.house_no].filter(Boolean).join(' ')),
esc([c.zip, c.city].filter(Boolean).join(' ')),
c.country ? esc(c.country) : '',
].filter(Boolean).join('<br>');
const zeilen = [];
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
zeilen.push('<p style="margin:0 0 12px;">'
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
return zeilen.join('');
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService, MailService } = services;
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
async function loadSettings(schema) {
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
catch { return {}; }
}
async function sendTeamMail(schema, subject, html) {
const settings = await loadSettings(schema);
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
const msg = { to, subject, html, text: htmlZuText(html) };
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
await new MailService({ schema, accountability: null }).send(msg);
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
}
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
function contactFromEntry(order, e) {
const c = {
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
country: e.country || null, lead_status: 'neu',
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
};
if (!c.first_name && !c.last_name && e.free_text) {
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
if (lines.length) c.last_name = lines[0];
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
}
return c;
}
async function saveContactsForOrder(orderId) {
const schema = await getSchema();
const orders = svc('orders', schema);
const order = await orders.readOne(orderId, {
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
});
if (!order) return;
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
const entries = await svc('order_entries', schema).readByQuery({
filter: { order: { _eq: orderId } }, limit: -1,
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
});
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
await orders.updateOne(orderId, { contacts_created: true });
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
}
register.action('orders.items.update', async ({ keys, payload }) => {
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
for (const id of keys || []) {
try { await saveContactsForOrder(id); }
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
}
});
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
register.action('contact_actions.items.create', async ({ key, payload }) => {
const typ = payload?.type;
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
try {
const schema = await getSchema();
const a = await svc('contact_actions', schema).readOne(key, {
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
'contact.email', 'contact.phone', 'contact.notes'],
});
if (!a?.contact) return;
const name = contactName(a.contact);
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
await sendTeamMail(schema, betreff, html);
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
});
register.schedule(REMINDER_CRON, async () => {
try {
const schema = await getSchema();
const nowIso = new Date().toISOString();
const actions = svc('contact_actions', schema);
const due = await actions.readByQuery({
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
limit: -1,
});
for (const a of due || []) {
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
const name = contactName(a.contact);
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
}
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
});
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
register.schedule(PURGE_CRON, async () => {
try {
const schema = await getSchema();
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
const contacts = svc('contacts', schema);
const raus = await contacts.readByQuery({
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
fields: ['id'], limit: -1,
});
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
const orders = svc('orders', schema);
const alte = await orders.readByQuery({
filter: { _and: [
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
] },
fields: ['id', 'order_number'], limit: -1,
});
for (const o of alte || []) {
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
if (!STRIP_ENTRIES) {
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
continue;
}
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
await orders.updateOne(o.id, { entries_stripped: true });
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
}
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
});
};