feat(akquise): Mail an Berater bei neuem Projekt

Wird in der Akquise ein Projekt mit Berater angelegt (oder ein Berater
beim Bearbeiten hinzugefuegt), bekommt er eine Mail ueber Graph sendMail
aus crm@perinco.de; Reply-To ist der eintragende Benutzer. Je Berater
abschaltbar unter Einstellungen -> Akquise (Spalte notify, Vorgabe an).
Versand laeuft nach der Antwort und kippt das Speichern nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-21 09:38:13 +02:00
co-authored by Claude Opus 5.5
parent 907b010f3c
commit eaef454442
15 changed files with 402 additions and 24 deletions
+152
View File
@@ -0,0 +1,152 @@
import { pool } from './db.js';
import { graphRequest, mailboxPath } from './mail/graph.js';
import { mailConfigured } from './mail/config.js';
import { splitConsultants } from './acquisitionRules.js';
/*
* Mail an Berater, sobald sie in der Akquise einem Projekt zugeordnet werden.
*
* Wer eine Mail bekommt, steht in crm_acquisition_options: Adresse (`email`)
* und Schalter (`notify`), beides gepflegt unter Einstellungen -> Akquise.
* Ohne Adresse oder mit ausgeschaltetem Schalter geht nichts heraus.
*
* Versendet wird ueber Microsoft Graph aus dem CRM-Postfach (MS_MAILBOX) - die
* App hat dafuer die Anwendungsberechtigung Mail.Send. Antworten gehen per
* Reply-To an den Benutzer, der das Projekt eingetragen hat.
*
* Der Versand laeuft nach der Antwort an den Browser und darf das Speichern nie
* scheitern lassen: ein Fehler landet im Log, der Eintrag ist trotzdem da.
*/
const PUBLIC_URL = (process.env.CRM_PUBLIC_URL || 'https://crm.perinco.de').replace(/\/+$/, '');
/** Beschreibung in der Mail kuerzen - der volle Text steht im CRM. */
const MAX_DESCRIPTION_CHARS = 1500;
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** "2026-10-21" -> "21.10.2026" */
function formatDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value ?? ''));
return match ? `${match[3]}.${match[2]}.${match[1]}` : null;
}
function formatEuro(value) {
if (value === null || value === undefined) return null;
return `${Number(value).toLocaleString('de-DE', { maximumFractionDigits: 2 })} €`;
}
/**
* Namen, die in `after` stehen, aber nicht in `before` - ohne Ruecksicht auf
* Gross- und Kleinschreibung. Beim Anlegen ist `before` leer.
*/
export function addedConsultants(before, after) {
const known = new Set(splitConsultants(before).map((name) => name.toLowerCase()));
return splitConsultants(after).filter((name) => !known.has(name.toLowerCase()));
}
/** Betreff und HTML-Text der Mail an einen Berater. */
export function buildMessage(acquisition, consultantName) {
const period = [formatDate(acquisition.start_date), formatDate(acquisition.end_date)]
.filter(Boolean)
.join(' bis ');
const rows = [
['Projekt', acquisition.title],
['Endkunde', acquisition.company],
['Anbieter', acquisition.provider],
['Kanal', acquisition.channel],
['Tagessatz', formatEuro(acquisition.daily_rate)],
['Projektpreis', formatEuro(acquisition.fixed_price)],
['Zeitraum', period || null],
['Laufzeit', acquisition.duration_days ? `${acquisition.duration_days} Tage` : null],
['Eingetragen von', acquisition.created_by_name],
].filter(([, value]) => value !== null && value !== undefined && value !== '');
const table = rows
.map(
([label, value]) =>
`<tr><td style="padding:4px 16px 4px 0;color:#666;vertical-align:top">${escapeHtml(label)}</td>` +
`<td style="padding:4px 0">${escapeHtml(value)}</td></tr>`
)
.join('');
let description = String(acquisition.description ?? '').trim();
if (description.length > MAX_DESCRIPTION_CHARS) {
description = `${description.slice(0, MAX_DESCRIPTION_CHARS).trimEnd()} …`;
}
const firstName = String(consultantName).trim().split(/\s+/)[0];
const html = [
'<div style="font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222">',
`<p>Hallo ${escapeHtml(firstName)},</p>`,
'<p>in der Akquise wurde ein neues Projekt eingetragen und dir zugeordnet:</p>',
`<table style="border-collapse:collapse;font-size:14px">${table}</table>`,
acquisition.link
? `<p><a href="${escapeHtml(acquisition.link)}">Zur Ausschreibung</a></p>`
: '',
description
? `<p style="white-space:pre-wrap;border-left:3px solid #ddd;padding-left:12px;color:#444">${escapeHtml(description)}</p>`
: '',
`<p><a href="${PUBLIC_URL}/">Im perinco CRM öffnen</a></p>`,
'<p style="color:#888;font-size:12px">Automatische Nachricht aus dem perinco CRM. ',
'Die Benachrichtigung lässt sich unter Einstellungen → Akquise je Berater abschalten.</p>',
'</div>',
].join('');
return { subject: `Neues Projekt für dich: ${acquisition.title}`, html };
}
/**
* Mails an die genannten Berater verschicken, soweit sie eine Adresse haben
* und der Schalter an ist. Wirft nicht - Fehler werden geloggt.
*/
export async function notifyConsultants(acquisition, names, actor) {
try {
if (!acquisition || names.length === 0) return;
if (!(await mailConfigured())) {
console.warn('[Akquise] Berater-Mail übersprungen: M365-Anbindung nicht eingerichtet.');
return;
}
const [options] = await pool.query(
`SELECT value, email FROM crm_acquisition_options
WHERE field = 'consultant' AND notify = 1 AND email IS NOT NULL AND email <> ''`
);
const byName = new Map(options.map((row) => [row.value.toLowerCase(), row]));
for (const name of names) {
const option = byName.get(name.toLowerCase());
if (!option) continue;
const { subject, html } = buildMessage(acquisition, option.value);
const message = {
subject,
body: { contentType: 'HTML', content: html },
toRecipients: [{ emailAddress: { address: option.email, name: option.value } }],
};
if (actor?.email) {
message.replyTo = [{ emailAddress: { address: actor.email, name: actor.name || actor.email } }];
}
try {
await graphRequest(mailboxPath('/sendMail'), {
method: 'POST',
body: { message, saveToSentItems: true },
});
console.log(`[Akquise] Mail an ${option.email} zu Eintrag ${acquisition.id} verschickt.`);
} catch (err) {
console.warn(
`[Akquise] Mail an ${option.email} zu Eintrag ${acquisition.id} fehlgeschlagen: ${err.message}`
);
}
}
} catch (err) {
console.warn(`[Akquise] Berater-Mail fehlgeschlagen: ${err.message}`);
}
}
+17
View File
@@ -299,6 +299,23 @@ async function addConsultantEmail() {
);
}
/*
* Schalter "Mail bei neuem Projekt" (siehe acquisitionNotify.js). Der
* Vorgabewert 1 schaltet ihn beim Anlegen der Spalte fuer alle vorhandenen
* Berater ein - so war es gewuenscht; abschalten geht je Berater.
*/
const [notifyColumn] = await pool.query(
`SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_acquisition_options'
AND COLUMN_NAME = 'notify' LIMIT 1`
);
if (!notifyColumn[0]) {
await pool.query(
'ALTER TABLE crm_acquisition_options ADD COLUMN notify TINYINT(1) NOT NULL DEFAULT 1 AFTER email'
);
console.log('[Akquise] Berater-Mail bei neuem Projekt eingefuehrt - fuer alle Berater an.');
}
const flag = 'consultant_email_seeded';
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
if (rows[0]?.value === 'true') return;
+47 -19
View File
@@ -18,6 +18,7 @@ import {
splitConsultants,
} from '../acquisitionRules.js';
import { normalizeLink } from '../link.js';
import { addedConsultants, notifyConsultants } from '../acquisitionNotify.js';
const router = express.Router();
router.use(requireAuth);
@@ -295,11 +296,16 @@ async function purgeOldEntries() {
/** Alle Auswahllisten als { sender: [...], channel: [...], consultant: [...] }. */
async function loadOptions() {
const [rows] = await pool.query(
'SELECT id, field, value, email FROM crm_acquisition_options ORDER BY field, value'
'SELECT id, field, value, email, notify FROM crm_acquisition_options ORDER BY field, value'
);
const options = { sender: [], channel: [], consultant: [] };
for (const row of rows) {
options[row.field].push({ id: row.id, value: row.value, email: row.email ?? null });
options[row.field].push({
id: row.id,
value: row.value,
email: row.email ?? null,
notify: Boolean(row.notify),
});
}
return options;
}
@@ -477,7 +483,7 @@ router.post('/options', async (req, res, next) => {
'INSERT INTO crm_acquisition_options (field, value, email, created_by) VALUES (?, ?, ?, ?)',
[field, value, email.value, req.user.id]
);
res.status(201).json({ id: result.insertId, field, value, email: email.value });
res.status(201).json({ id: result.insertId, field, value, email: email.value, notify: true });
} catch (err) {
if (err?.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Dieser Wert steht schon in der Liste.' });
@@ -498,47 +504,61 @@ router.patch('/options/:id', async (req, res, next) => {
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Options-ID.' });
/*
* `value` und `email` sind beide optional: die Einstellungen aendern mal den
* Namen, mal nur die Adresse. Fehlt `value`, bleibt der Name, wie er ist.
* `value`, `email` und `notify` sind alle optional: die Einstellungen aendern
* mal den Namen, mal nur die Adresse oder den Mail-Schalter. Was fehlt, bleibt.
*/
const emailGiven = req.body?.email !== undefined;
const email = emailGiven ? normalizeOptionEmail(req.body.email) : null;
if (email?.error) return res.status(400).json({ error: email.error });
const notifyGiven = req.body?.notify !== undefined;
if (notifyGiven && typeof req.body.notify !== 'boolean') {
return res.status(400).json({ error: 'Der Mail-Schalter muss an oder aus sein.' });
}
const valueGiven = req.body?.value !== undefined;
const requested = valueGiven ? emptyToNull(req.body.value) : null;
if (valueGiven && !requested) return res.status(400).json({ error: 'Ein Wert ist erforderlich.' });
if (!valueGiven && !emailGiven) return res.status(400).json({ error: 'Nichts zu ändern.' });
if (!valueGiven && !emailGiven && !notifyGiven) {
return res.status(400).json({ error: 'Nichts zu ändern.' });
}
const conn = await pool.getConnection();
try {
const [rows] = await conn.query(
'SELECT field, value, email FROM crm_acquisition_options WHERE id = ? LIMIT 1',
'SELECT field, value, email, notify FROM crm_acquisition_options WHERE id = ? LIMIT 1',
[id]
);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag der Liste nicht gefunden.' });
if (emailGiven && existing.field !== 'consultant') {
return res.status(400).json({ error: 'Eine E-Mail-Adresse gibt es nur bei Beratern.' });
if ((emailGiven || notifyGiven) && existing.field !== 'consultant') {
return res
.status(400)
.json({ error: 'E-Mail-Adresse und Benachrichtigung gibt es nur bei Beratern.' });
}
const value = requested ?? existing.value;
const newEmail = emailGiven ? email.value : existing.email ?? null;
const newNotify = notifyGiven ? req.body.notify : Boolean(existing.notify);
const limit = existing.field === 'channel' ? 60 : 190;
if (value.length > limit) {
return res.status(400).json({ error: `Der Wert ist zu lang (max. ${limit} Zeichen).` });
}
const result = { id, field: existing.field, value, email: newEmail, notify: newNotify };
if (value === existing.value) {
await conn.query('UPDATE crm_acquisition_options SET email = ? WHERE id = ?', [newEmail, id]);
return res.json({ id, field: existing.field, value, email: newEmail, updated: 0 });
await conn.query('UPDATE crm_acquisition_options SET email = ?, notify = ? WHERE id = ?', [
newEmail,
newNotify ? 1 : 0,
id,
]);
return res.json({ ...result, updated: 0 });
}
await conn.beginTransaction();
await conn.query('UPDATE crm_acquisition_options SET value = ?, email = ? WHERE id = ?', [
value,
newEmail,
id,
]);
await conn.query(
'UPDATE crm_acquisition_options SET value = ?, email = ?, notify = ? WHERE id = ?',
[value, newEmail, newNotify ? 1 : 0, id]
);
let touched;
if (existing.field === 'consultant') {
@@ -569,7 +589,7 @@ router.patch('/options/:id', async (req, res, next) => {
}
await conn.commit();
res.json({ id, field: existing.field, value, email: newEmail, updated: touched });
res.json({ ...result, updated: touched });
} catch (err) {
await conn.rollback().catch(() => {});
if (err?.code === 'ER_DUP_ENTRY') {
@@ -733,7 +753,10 @@ router.post('/', async (req, res, next) => {
VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
res.status(201).json(await fetchAcquisition(result.insertId));
const created = await fetchAcquisition(result.insertId);
res.status(201).json(created);
// Nach der Antwort: der Versand soll das Speichern weder bremsen noch kippen.
notifyConsultants(created, addedConsultants(null, created?.consultant), req.user);
} catch (err) {
next(err);
}
@@ -793,7 +816,12 @@ router.patch('/:id', async (req, res, next) => {
`UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => columns[key]), id]
);
res.json(await fetchAcquisition(id));
const updated = await fetchAcquisition(id);
res.json(updated);
// Nur wer neu dazugekommen ist, bekommt eine Mail - die anderen kennen das Projekt.
if ('consultant' in columns) {
notifyConsultants(updated, addedConsultants(existing.consultant, updated?.consultant), req.user);
}
} catch (err) {
next(err);
}
+1
View File
@@ -576,6 +576,7 @@ CREATE TABLE IF NOT EXISTS crm_acquisition_options (
field ENUM('sender','channel','consultant') NOT NULL,
value VARCHAR(190) NOT NULL,
email VARCHAR(190) NULL,
notify TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),