--- phase: quick-260909-ab3 plan: 01 subsystem: ldap tags: [ldap, sync, i18n, ui, prisma, react] status: complete dependency-graph: requires: [] provides: - "Freigaben-Matrix: Suche filtert Modul- und Gruppen-Achse unabhaengig — ein Treffer auf nur einer Achse leert die andere nicht mehr" - "resolveEmailForWrite(): geteilter Kollisionsentscheider fuer AD-Kontoanlage (LdapService.upsertMappedUser + importUsersByDn)" - "LdapSyncResult.{emailConflicts,skippedNoLogin,entryFailures}: strukturierter Sync-Bericht ohne rohen ORM-/englischen Techniktext" - "User.email optional (Migration geschrieben, nicht ausgefuehrt)" affects: - apps/web/src/app/(portal)/admin/modules/grants/page.tsx - apps/api/src/ldap/ldap.service.ts - apps/api/src/user/user.service.ts - apps/web/src/app/(portal)/admin/ldap/page.tsx - apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx - apps/web/src/app/(portal)/admin/users/page.tsx tech-stack: added: [] patterns: - "Kollisionsentscheider als private Methode, die vor jeder E-Mail-Schreibung prisma.user.findUnique befragt und nur zurueckgibt, wenn niemand die Adresse haelt oder der Halter der eigene Datensatz ist — nie ein Umhaengen zwischen Konten" - "Rohe ORM-/Techniktexte gehen ausschliesslich ueber logger.error ins Serverprotokoll; der Administrator sieht nur die Kennung des betroffenen Eintrags" actuals: tokens: 10082 tasks: 3 commits: 3 decisions: - "Task 3 Testfixture von der Plan-Vorgabe abgewichen: ein einzelnes Konto mit passendem Benutzernamen loest den Absturz wegen OR-Kurzschlussauswertung nie aus (bestaetigt: Test war mit der Plan-Fixture gruen gegen den unveraenderten Bestand). Ein zweites, nicht-treffendes Konto ohne Adresse in derselben Mock-Liste macht den Test echt rot — siehe Deviations." metrics: duration: 13 min completed: 2026-09-09 --- # Quick Task 260909-ab3: Matrix-Suche und Sync-Meldungen reparieren Summary Freigaben-Matrix-Suche filtert Modul- und Gruppenachse jetzt unabhaengig (WINDOWS #14 geschlossen); AD-Sync legt Konten mit kollidierender E-Mail-Adresse an — ohne Adresse — statt sie stillschweigend zu uebergehen, und der Bericht spricht durchgehend verstaendliches Deutsch (WINDOWS #15 geschlossen). ## What Was Built Drei getrennte, jeweils fuer sich lauffaehige Commits — exakt wie im Plan vorgegeben. **Task 1 — Matrix-Suche filtert nur noch die getroffene Achse (commit `e8c2411`)** - `page.tsx`: `filteredModules`/`filteredGroups` durch einen gemeinsamen `useMemo` ersetzt, der `moduleHits`/`groupHits` getrennt ermittelt und nach der im Plan festgelegten Wahrheitstabelle kombiniert (leeres Feld -> alles; nur eine Achse trifft -> die andere Achse bleibt vollstaendig stehen; beide treffen -> beide gefiltert; nichts trifft -> `noMatch`). - Regressionsschutz fuer WINDOWS #6c (Spaltensuche findet eine importierte Gruppe sowohl unter internem als auch AD-Namen) unveraendert erhalten und mit einem eigenen Testfall abgesichert. - Neue Meldung `adminModules.grants.noSearchResults` in de.json/en.json — de.json folgt der im Bestand bereits etablierten typografischen Anfuehrungszeichen-Konvention (`„...\"`, siehe `nameCollisionError`). - Vier neue Testfaelle in `grants-matrix.test.tsx` vorab gegen den unveraenderten Bestand rot gelaufen — alle vier aus den im Plan vorhergesagten Gruenden (fehlende Modul-/Gruppennamen bzw. fehlender Meldungstext). **Task 2 — Kollidierende AD-Konten werden angelegt, ohne Adresse (commit `1222951`)** - `schema.prisma`: `User.email` auf `String? @unique` gestellt; Migration `20260909120000_user_email_optional` geschrieben (nur `DROP NOT NULL`, Eindeutigkeitsindex unangetastet) — **nicht ausgefuehrt**, wie vom Plan verlangt. - `ldap.service.ts`: neue private Methode `resolveEmailForWrite(desiredEmail, ownRecordId)` fragt `prisma.user.findUnique({where:{email}}))` und gibt die Adresse nur frei, wenn niemand sie haelt oder der Halter der eigene Datensatz ist (T-Q3-01) — sonst wird kein Konto umgehaengt, sondern ein Kollisionsvermerk erzeugt. - In `upsertMappedUser` (Sync-Pfad) UND `importUsersByDn` (Handimport-Pfad) verdrahtet, damit der zweite Anlageweg nicht als Luecke mit rohem Datenbanktext bestehen bleibt. - `LdapSyncResult` um `emailConflicts` ({account, email}[]), `skippedNoLogin` (string[]) und `entryFailures` (string[]) erweitert. Die englische "no username mapped"-Meldung im Sync-Pfad ist verschwunden (wandert jetzt in `skippedNoLogin`); der generische Catch-Block schreibt den technischen Wortlaut nur noch via `this.logger.error`, nie in den Bericht (T-Q3-02). - `UserService.create` nimmt `email` jetzt optional entgegen. Type-Check deckte genau die im Plan vorhergesagten zwei Fundstellen auf (`tender-digest.scheduler.ts:140`, `tender-matching.service.ts:131`) — beide brechen jetzt mit `continue` ab, wenn der Empfaenger keine Adresse hat. - Fuenf neue Testfaelle in `ldap.service.spec.ts` vorab gegen den unveraenderten Bestand rot gelaufen, jeweils aus dem im Plan vorhergesagten Grund. Eine bestehende `toEqual`-Assertion (`empty base DN no-op`-Test) musste um die drei neuen Ergebnisfelder ergaenzt werden (legitime Bestandspflege, keine Verhaltensaenderung). **Task 3 — Verstaendlicher Sync-Bericht und Konten ohne Adresse in der UI (commit `2167046`)** - `admin/ldap/page.tsx`: `SyncResult`-Interface um die drei neuen Felder erweitert; drei neue Berichtsabschnitte in der vorgegebenen Reihenfolge (Kollision: Bernstein wie `defaultMarkerMoved`; uebersprungen ohne Anmeldenamen: `text-muted-foreground`, ausdruecklich kein Rot; unerwartete Fehler: `text-destructive`). - Vier neue Sprachschluessel unter `admin.ldap.sync` in de.json/en.json; Umlaut-Gate lief gruen ohne neue Allowlist-Eintraege (nur "Adresse" traf das `ss`-Muster, war bereits erlaubt). - `GroupMembersModal.tsx`: `TenantUser.email` auf `string | null`, Suchvergleich (Zeile 104) analog zum bestehenden `displayName`-Muster mit `(u.email ?? '')` abgesichert. - `users/page.tsx`: `User.email` auf `string | null`; Formularuebernahme faellt auf `''` zurueck, Tabellenzelle zeigt einen Gedankenstrich (`–`) statt einer leeren Zelle. `UserFormData.email` bleibt Pflichtfeld fuer die Handanlage. ## Verification Results - **API-Testsuite:** `pnpm --filter @tessera/api exec vitest run` — **651/651 gruen** (46 Dateien), inklusive `ldap.service.spec.ts` mit 67/67 (62 bestehend + 5 neu). - **API-Typpruefung:** `pnpm --filter @tessera/api type-check` — sauber. - **`prisma validate`/`prisma generate`:** beide sauber; Migration existiert, wurde **nicht ausgefuehrt**. - **Web-Testsuite:** `pnpm --filter @tessera/web exec vitest run` — **233/233 gruen** (38 Dateien), inklusive `grants-matrix.test.tsx` (12/12) und `groups-page.test.tsx` (13/13). - **Web-Typpruefung:** `pnpm --filter @tessera/web type-check` — sauber. - **Sprachschluessel-/Umlaut-Gate:** `umlaut-guard.spec.ts` gruen bei jedem Zwischenstand — alle neuen Schluessel existieren in de.json UND en.json, kein neues, ungeprueftes Umlaut-Ersatzwort. - **Alle vier Befehle aus `` des Plans** ein weiteres Mal am Ende, vom Wurzelverzeichnis aus, gruen. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Task-3-Testfixture konnte den bezeichneten Absturz nicht ausloesen** - **Found during:** Task 3, RED-Lauf vor der Implementierung - **Issue:** Der Plan gibt exakt einen Mock-Benutzer vor (`{ id: 'u12', username: 'funktionskonto', displayName: null, email: null }`) und den Suchbegriff `'funktion'`. Da `'funktionskonto'.includes('funktion')` bereits ueber `u.username` matcht, greift die JavaScript-OR-Kurzschlussauswertung (`||`) VOR der ungesicherten `u.email.toLowerCase()`-Zeile — der Testlauf gegen den unveraenderten Bestand war **gruen**, nicht rot wie vom Plan vorhergesagt. Empirisch mit `node -e` bestaetigt (siehe Commit-Nachricht). - **Fix:** Ein zweites Konto (`{ id: 'u13', username: 'anderes.konto', displayName: null, email: null }`) in dieselbe Mock-`/users`-Antwort aufgenommen. Sein Benutzername matcht den Suchbegriff nicht, sodass die OR-Kette bis zur ungesicherten Zeile durchlaeuft und dort tatsaechlich abstuerzt — Testlauf danach korrekt rot, exakt an `GroupMembersModal.tsx:104`. `funktionskonto` bleibt Teil der Fixture und der Assertion, wie vom Plan verlangt. - **Files modified:** apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx - **Verification:** Test rot vor der Implementierung (TypeError an der vorhergesagten Zeile), gruen danach; volle Testsuite (233/233) bleibt gruen. - **Committed in:** 2167046 (Task-3-Commit) --- **Total deviations:** 1 auto-fixed (Rule 1 — Testfixture-Korrektur, kein Produktionscode-Verhalten geaendert) **Impact on plan:** Der zugrunde liegende Defekt (ungesichertes `.toLowerCase()` auf einer moeglicherweise leeren Adresse) war real und wurde wie geplant behoben; nur die Testdaten mussten angepasst werden, damit der Test die Behauptung tatsaechlich pruefte statt trivial gruen zu sein. ## Threat Model Verification Alle sechs `mitigate`-Eintraege aus dem Plan-Threat-Register sind umgesetzt: - **T-Q3-01 (Spoofing, `upsertMappedUser`-Aktualisierungszweig):** Mitigiert — `resolveEmailForWrite` vergleicht Halter-ID gegen die eigene Datensatz-ID; Test 3 aus Task 2 belegt, dass `prisma.user.update` ohne `email`-Feld aufgerufen wird, wenn die Adresse einem Dritten gehoert. - **T-Q3-02 (Information Disclosure, Sync-Bericht):** Mitigiert — Test 5 aus Task 2 belegt per `JSON.stringify(result)`-Negativpruefung, dass der geworfene ORM-Text nirgends im Bericht landet; er erscheint nur im `logger.error`-Aufruf. - **T-Q3-04 (Spoofing, Passwort-Zuruecksetzung bei leerer Adresse):** Bestaetigt ohne Codeaenderung — die Suche in `auth.service.ts` laeuft ueber eine angefragte Zeichenkette, ein `null`-Wert in der Spalte wird davon nie getroffen. - **T-Q3-03/T-Q3-05/T-Q3-06/T-Q3-SC (accept):** Keine Codeaenderung noetig, Einschaetzung des Plans bestaetigt (keine neuen Pakete installiert, kein zusaetzliches DoS-Risiko bei den gemessenen Groessenordnungen). ## Self-Check: PASSED **Files:** - FOUND: apps/web/src/app/(portal)/admin/modules/grants/page.tsx - FOUND: apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx - FOUND: apps/api/prisma/schema.prisma - FOUND: apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql - FOUND: apps/api/src/ldap/ldap.service.ts - FOUND: apps/api/src/ldap/ldap.service.spec.ts - FOUND: apps/api/src/user/user.service.ts - FOUND: apps/api/src/tenders/tender-digest.scheduler.ts - FOUND: apps/api/src/tenders/tender-matching.service.ts - FOUND: apps/web/src/app/(portal)/admin/ldap/page.tsx - FOUND: apps/web/src/app/(portal)/admin/users/page.tsx - FOUND: apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx - FOUND: apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx - FOUND: apps/web/src/messages/de.json - FOUND: apps/web/src/messages/en.json **Commits:** - FOUND: e8c2411 (Task 1) - FOUND: 1222951 (Task 2) - FOUND: 2167046 (Task 3) ## Deployment Note (vom Plan uebernommen) Dieser Plan hat **nichts ausgerollt**: kein `docker compose build`, kein `up`, kein `prisma migrate deploy`. Die Migration `20260909120000_user_email_optional` steckt im API-Image und wird beim naechsten Neubau/Neustart der API automatisch angewandt (`apps/api/Dockerfile:38` ruft `prisma migrate deploy` vor dem Start auf). Solange das nicht geschehen ist, bleibt `User.email` in der laufenden Datenbank ein Pflichtfeld — ein kollidierendes Konto wird dann weiterhin nicht angelegt, scheitert aber sauber als gezaehlter `entryFailures`-Eintrag statt mit rohem Techniktext. Die fuenf im Plan genannten Browser-Nachpruefungen (Punkte 1-5 unter ``) sind vom Nutzer nach seinem naechsten Neubau von API und Web nachzuholen — nicht Teil dieser Ausfuehrung.