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;