- Neues Modul „Statistik": versendete Briefe je Kunde/Monat, getrennt Inland/ Ausland, Zeilen aufklappbar (Auftragsliste), mit Suche. Raus aus dem Board. - Produktion: Auto-Refresh pausiert, während ein Feld bearbeitet wird (Notizen gingen sonst beim 15-s-Reload/Statuswechsel verloren). „Neu generieren“/ „In manuellen Druck“ aus dem Empfänger-Dialog entfernt (liegen unter Weiteres). Unter Weiteres jetzt auch Kontakt/Kunde verknüpfen (per E-Mail) und mit × lösen – ohne Kundenmail. - Ausland-Erkennung (zielInland) mit Wortgrenzen (\b) + unbekannter Ländertext → Inland: „Schweizer Straße“, „Neusass“, „Polenz“ werden nicht mehr als Ausland geflaggt (orders + produktion). - Kontakte-Modul: Mehrfachauswahl → Status ändern / löschen. - contacts-Hook: Abwahl von „In Kontakte speichern“ löscht die Kontakte wieder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
13 KiB
JavaScript
218 lines
13 KiB
JavaScript
/**
|
||
* 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) => ({ '<': '<', '>': '>', '&': '&' }[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(/ /gi, ' ')
|
||
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/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`);
|
||
}
|
||
|
||
// Kontakte eines Auftrags wieder entfernen (Abwahl von „In Kontakte speichern").
|
||
async function deleteContactsForOrder(orderId) {
|
||
const schema = await getSchema();
|
||
const contacts = svc('contacts', schema);
|
||
const raus = await contacts.readByQuery({ filter: { source_order: { _eq: orderId } }, limit: -1, fields: ['id'] });
|
||
if (raus?.length) await contacts.deleteMany(raus.map((r) => r.id));
|
||
await svc('orders', schema).updateOne(orderId, { contacts_created: false });
|
||
logger.info(`[skrift-contacts] ${raus?.length || 0} Kontakt(e) aus Auftrag ${orderId} entfernt (Abwahl)`);
|
||
}
|
||
|
||
register.action('orders.items.update', async ({ keys, payload }) => {
|
||
if (payload?.keep_contacts === undefined) return; // nur wenn der Haken geändert wird
|
||
for (const id of keys || []) {
|
||
try {
|
||
if (payload.keep_contacts === true) await saveContactsForOrder(id);
|
||
else await deleteContactsForOrder(id);
|
||
} catch (e) { logger.warn(`[skrift-contacts] Kontakte ${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}`); }
|
||
});
|
||
};
|