diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 82b6a01..65a57f1 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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 diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md index 37926ed..d5f855f 100644 --- a/docs/06-betrieb.md +++ b/docs/06-betrieb.md @@ -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: diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 366dcde..3e998b8 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 465ca38..f52fd4e 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 | diff --git a/docs/11-postfach.md b/docs/11-postfach.md index aa22078..980c358 100644 --- a/docs/11-postfach.md +++ b/docs/11-postfach.md @@ -66,6 +66,9 @@ Login. 3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* → `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; diff --git a/server/.env.example b/server/.env.example index a98603d..20bbbe6 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/package.json b/server/package.json index ad1dd14..f6f3f1c 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/acquisitionNotify.js b/server/src/acquisitionNotify.js new file mode 100644 index 0000000..c570d1c --- /dev/null +++ b/server/src/acquisitionNotify.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** "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]) => + `${escapeHtml(label)}` + + `${escapeHtml(value)}` + ) + .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 = [ + '
', + `

Hallo ${escapeHtml(firstName)},

`, + '

in der Akquise wurde ein neues Projekt eingetragen und dir zugeordnet:

', + `${table}
`, + acquisition.link + ? `

Zur Ausschreibung

` + : '', + description + ? `

${escapeHtml(description)}

` + : '', + `

Im perinco CRM öffnen

`, + '

Automatische Nachricht aus dem perinco CRM. ', + 'Die Benachrichtigung lässt sich unter Einstellungen → Akquise je Berater abschalten.

', + '
', + ].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}`); + } +} diff --git a/server/src/db.js b/server/src/db.js index ff723c3..68b31fd 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -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; diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index 85de5a5..d574d08 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -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); } diff --git a/server/src/schema.sql b/server/src/schema.sql index 9c76d23..1be9aaa 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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), diff --git a/server/test/acquisitionNotify.test.mjs b/server/test/acquisitionNotify.test.mjs new file mode 100644 index 0000000..02a4f34 --- /dev/null +++ b/server/test/acquisitionNotify.test.mjs @@ -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