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
+1
View File
@@ -104,6 +104,7 @@ erDiagram
enum field "sender | channel | consultant"
varchar value "Inhalt des Dropdowns"
varchar email "nur bei consultant"
tinyint notify "Mail bei neuem Projekt, Vorgabe 1"
}
crm_acquisition_history {
date found_on PK
+1
View File
@@ -137,6 +137,7 @@ entfallen.
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung für `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
| Berater bekommt keine Mail bei neuem Projekt | Adresse und Schalter unter *Einstellungen → Akquise*; Log `journalctl -u perinco-crm-backend \| grep 'Akquise] Mail'`; bei `ErrorAccessDenied` fehlt die Zustimmung für `Mail.Send` |
Nützliche Abfragen:
+9
View File
@@ -983,6 +983,15 @@ wurden die vorhandenen Berater einmalig mit `vorname.nachname@perinco.de`
vorbelegt - Umlaute ausgeschrieben, bei mehrteiligen Namen erster und letzter
Teil. Berater mit nur einem Namen blieben leer und brauchen eine Adresse von Hand.
Darunter steht der Haken **Mail bei neuem Projekt** (seit 21.10.2026, anfangs
bei allen an). Ist er gesetzt, bekommt der Berater eine Mail von
crm@perinco.de, sobald er in der Akquise einem Projekt zugeordnet wird - beim
Anlegen und auch, wenn er später beim Bearbeiten dazukommt. Wer schon
zugeordnet war, bekommt beim erneuten Speichern keine zweite Mail. Die Mail
enthält die Projektdaten und den Link zur Ausschreibung; eine Antwort darauf
geht an den, der das Projekt eingetragen hat. Ohne hinterlegte Adresse geht
nichts heraus, der Haken zeigt dann einen Hinweis.
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
Hintergrund-Scanner die Börsen durchsucht.
+2 -1
View File
@@ -59,7 +59,7 @@ Mal etwas:
* `addConsultantEmail()` legt `crm_acquisition_options.email` an und belegt die
vorhandenen Berater einmalig mit `vorname.nachname@perinco.de` vor
(`guessPerincoEmail()`, Merker `consultant_email_seeded`). Namen ohne
Nachnamen bleiben leer.
Nachnamen bleiben leer. Dieselbe Funktion legt `notify` (Vorgabe 1) an.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
@@ -106,6 +106,7 @@ Alle unter `router.use(requireAuth)`.
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete |
| `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist |
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft |
| `acquisitionNotify.js` (kein Router) | Berater-Mail bei neuem Projekt: `addedConsultants()` (wer ist neu), `buildMessage()` (Betreff + escaptes HTML), `notifyConsultants()` (Versand über Graph `sendMail` aus `MS_MAILBOX`, wirft nie). Aufgerufen von `POST`/`PATCH` in `acquisitions.js` nach der Antwort. Siehe [11 Postfach](11-postfach.md#berater-mail-bei-neuem-projekt) |
| `link.js` (kein Router) | `normalizeLink()` für den Link zur Ausschreibung: nur http/https, ohne Schema wird `https://` ergänzt, max. 1024 Zeichen. Geteilt von Akquise und Opportunities, damit ein übernommener Link nicht an einer anderen Prüfung scheitert |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IN_INBOX`, `IN_ARCHIVE`, `RETURNED`, `IS_OPEN` (nur noch für den Löschlauf), `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) |
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
+40 -2
View File
@@ -66,6 +66,9 @@ Login.
3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr;
`Mail.Read`, danach Administratorzustimmung erteilen.
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
Seit 21.10.2026 zusätzlich `Mail.Send` (erteilt) - damit verschickt das CRM
die [Berater-Mail bei neuem Projekt](#berater-mail-bei-neuem-projekt) aus
diesem Postfach.
4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen
gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
@@ -386,10 +389,45 @@ Fremdschlüssel nicht.
| HTML-Anzeige | ungefiltert | serverseitig entschärft, `sandbox=""`, externe Bilder gesperrt |
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
## Berater-Mail bei neuem Projekt
Seit 21.10.2026 verschickt das CRM selbst Mails - bisher nur eine Sorte: Wird in
der Akquise ein Projekt eingetragen und dabei ein Berater ausgewählt, bekommt
dieser eine Mail mit Titel, Endkunde, Anbieter, Tagessatz, Zeitraum, Link zur
Ausschreibung und Beschreibung (gekürzt auf 1500 Zeichen).
```mermaid
sequenceDiagram
participant B as Browser
participant API as POST/PATCH /api/acquisitions
participant N as acquisitionNotify.js
participant G as Graph sendMail
B->>API: Eintrag mit Berater speichern
API-->>B: 201 / 200 (sofort)
API->>N: notifyConsultants(eintrag, neue Berater, benutzer)
N->>N: Adresse + Schalter je Berater aus crm_acquisition_options
N->>G: POST /users/crm@perinco.de/sendMail
```
* **Absender** ist `MS_MAILBOX` (crm@perinco.de), Kopie in *Gesendete
Elemente*. **Reply-To** ist der Benutzer, der den Eintrag gespeichert hat -
eine Antwort des Beraters landet also bei ihm, nicht im CRM-Postfach.
* **Wer** eine Mail bekommt: Beim Anlegen alle ausgewählten Berater, beim
Bearbeiten nur die neu hinzugekommenen. Voraussetzung je Berater: Adresse
hinterlegt und Schalter *Mail bei neuem Projekt* an (*Einstellungen →
Akquise*). Der Schalter war bei Einführung für alle an.
* **Fehler** brechen das Speichern nie ab. Der Versand läuft nach der Antwort an
den Browser; Erfolg und Fehler stehen im Log:
`journalctl -u perinco-crm-backend | grep 'Akquise] Mail'`.
* **Ohne M365-Anbindung** (Secret fehlt) wird nur eine Warnung geloggt.
* Die `ApplicationAccessPolicy` (siehe oben) gilt auch für `Mail.Send` - die App
darf also nur aus crm@perinco.de senden, nicht im Namen anderer.
## Was noch nicht drin ist
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
und damit eine erneute Administratorzustimmung.
* **Antworten, Weiterleiten aus dem Postfachreiter.** Senden geht seit der
Berater-Mail (`Mail.Send`); Antworten auf eine konkrete Mail bräuchte
zusätzlich `Mail.ReadWrite`.
* **Anhänge.** `has_attachments` wird geführt und angezeigt, die Dateien
selbst werden nicht geholt.
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
+4
View File
@@ -46,6 +46,10 @@ MS_CLIENT_ID=
MS_CLIENT_SECRET=
MS_MAILBOX=crm@perinco.de
# Adresse des CRM fuer den Link in der Berater-Mail bei neuem Projekt
# (braucht zusaetzlich die Anwendungsberechtigung Mail.Send).
# CRM_PUBLIC_URL=https://crm.perinco.de
# Ordner im Postfach. "inbox" ist ein von Graph vorgegebener Name.
# MS_MAIL_FOLDER=inbox
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs && node test/ai.test.mjs"
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/acquisitionNotify.test.mjs &&node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs && node test/msSecret.test.mjs && node test/ai.test.mjs"
},
"dependencies": {
"bcryptjs": "^2.4.3",
+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),
+57
View File
@@ -0,0 +1,57 @@
/*
* Pruefungen der Berater-Mail (acquisitionNotify.js): laeuft mit `npm test`.
*
* Geprueft wird, wer eine Mail bekommt (nur neu hinzugekommene Berater) und
* dass der Mailtext Eingaben escaped - Titel und Beschreibung kommen aus
* Freitextfeldern und landen als HTML im Postfach des Beraters.
*/
const { addedConsultants, buildMessage } = await import('../src/acquisitionNotify.js');
let failures = 0;
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
console.log('\nWer bekommt eine Mail');
check('Anlegen: alle', same(addedConsultants(null, 'Nico, Lisa'), ['Nico', 'Lisa']));
check('Ohne Berater: niemand', same(addedConsultants(null, null), []));
check('Bearbeiten: nur neue', same(addedConsultants('Nico', 'Nico, Lisa'), ['Lisa']));
check('Schreibweise egal', same(addedConsultants('nico', 'Nico'), []));
check('Entfernen: niemand', same(addedConsultants('Nico, Lisa', 'Nico'), []));
console.log('\nMailtext');
const message = buildMessage(
{
id: 1,
title: 'SAP <script>',
company: 'ACME & Co',
provider: 'Hays',
daily_rate: 950,
start_date: '2026-11-01',
end_date: '2026-12-31',
link: 'https://example.com/p?a=1&b=2',
description: 'Zeile 1\nZeile 2',
created_by_name: 'Sascha Wolf',
},
'Jörg Müller'
);
check('Betreff mit Titel', message.subject === 'Neues Projekt für dich: SAP <script>');
check('Titel escaped', message.html.includes('SAP &lt;script&gt;') && !message.html.includes('<script>'));
check('Firma escaped', message.html.includes('ACME &amp; Co'));
check('Anrede mit Vorname', message.html.includes('Hallo Jörg,'));
check('Datum deutsch', message.html.includes('01.11.2026 bis 31.12.2026'));
check('Link enthalten', message.html.includes('href="https://example.com/p?a=1&amp;b=2"'));
check('Leere Felder fehlen', !message.html.includes('Projektpreis'));
console.log(
failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`
);
process.exit(failures === 0 ? 0 : 1);
@@ -84,13 +84,53 @@ const EmailField: React.FC<{
);
};
/*
* Schalter "Mail bei neuem Projekt". Ohne Adresse bleibt er bedienbar, sagt
* aber, dass so keine Mail herausgeht - sonst wirkt er an und tut nichts.
*/
const NotifyToggle: React.FC<{
option: AcquisitionOption;
onChange: (notify: boolean) => Promise<void>;
}> = ({ option, onChange }) => {
const [isSaving, setIsSaving] = useState(false);
const toggle = async (notify: boolean) => {
setIsSaving(true);
try {
await onChange(notify);
} catch {
// Meldung kommt aus onChange, der Haken bleibt auf dem gespeicherten Stand.
} finally {
setIsSaving(false);
}
};
return (
<label className="mt-1.5 flex cursor-pointer items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={option.notify}
disabled={isSaving}
onChange={(event) => toggle(event.target.checked)}
className="h-4 w-4 accent-brand-500"
/>
Mail bei neuem Projekt
{option.notify && !option.email && (
<span className="text-amber-600">- ohne Adresse geht keine Mail heraus</span>
)}
</label>
);
};
const OptionRow: React.FC<{
option: AcquisitionOption;
onRenamed: (value: string) => Promise<void>;
onDelete: () => void;
/** Nur bei "Berater" gesetzt - dann zeigt die Zeile das Adressfeld. */
onEmailSaved?: (email: string) => Promise<void>;
}> = ({ option, onRenamed, onDelete, onEmailSaved }) => {
/** Nur bei "Berater" gesetzt - Schalter fuer die Mail bei neuem Projekt. */
onNotifyChanged?: (notify: boolean) => Promise<void>;
}> = ({ option, onRenamed, onDelete, onEmailSaved, onNotifyChanged }) => {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(option.value);
const [isSaving, setIsSaving] = useState(false);
@@ -125,6 +165,7 @@ const OptionRow: React.FC<{
<EmailField option={option} onSave={onEmailSaved} />
</div>
)}
{onNotifyChanged && <NotifyToggle option={option} onChange={onNotifyChanged} />}
</li>
);
@@ -262,6 +303,22 @@ export const AcquisitionSettings: React.FC = () => {
}
};
const saveNotify = async (option: AcquisitionOption, notify: boolean) => {
try {
await acquisitionOptionsApi.updateNotify(option.id, notify);
addToast(
notify
? `${option.value} bekommt eine Mail bei neuen Projekten.`
: `Keine Mails mehr an ${option.value}.`,
'success'
);
await load(true);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
throw err;
}
};
const confirmDelete = async () => {
if (!toDelete) return;
try {
@@ -364,6 +421,9 @@ export const AcquisitionSettings: React.FC = () => {
onEmailSaved={
field === 'consultant' ? (email) => saveEmail(option, email) : undefined
}
onNotifyChanged={
field === 'consultant' ? (notify) => saveNotify(option, notify) : undefined
}
/>
))}
</ul>
+7
View File
@@ -510,6 +510,13 @@ export const acquisitionOptionsApi = {
{ method: 'PATCH', body: JSON.stringify({ email }) }
),
/** Mail an den Berater bei neuem Projekt an- oder abschalten. */
updateNotify: (id: number, notify: boolean) =>
request<AcquisitionOption & { field: AcquisitionOptionField; updated: number }>(
`/acquisitions/options/${id}`,
{ method: 'PATCH', body: JSON.stringify({ notify }) }
),
/** Entfernt nur die Auswahl; `still_in_use` zaehlt die Eintraege, die den Wert behalten. */
remove: (id: number) =>
request<{ ok: true; still_in_use: number }>(`/acquisitions/options/${id}`, { method: 'DELETE' }),
+2
View File
@@ -399,6 +399,8 @@ export interface AcquisitionOption {
value: string;
/** Nur bei "Berater" gepflegt, sonst immer null. */
email: string | null;
/** Nur bei "Berater": Mail, sobald er in der Akquise einem Projekt zugeordnet wird. */
notify: boolean;
}
/*