Files
schalli dbeab8c38a docs(quick-260909-ab3): Plan fuer Matrix-Suche und Sync-Meldungen
WINDOWS #14 (Suche leert die jeweils andere Achse der Freigaben-Matrix) und
WINDOWS #15 (roher Prisma-/englischer Techniktext im AD-Sync, vier Konten
wegen geteilter E-Mail-Adresse nie importiert) als drei getrennte Tasks.

Gesperrte Nutzerentscheidung vom 2026-09-09: kollidierende Konten werden
angelegt, nur ohne Adresse. Dafuer wird User.email optional — Migration wird
geschrieben, nicht ausgefuehrt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 07:38:33 +02:00

34 KiB

quick_id, slug, date, status, relates_to, windows_ref, severity, phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
quick_id slug date status relates_to windows_ref severity phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
260909-ab3 matrix-suche-und-sync-meldungen-reparier 2026-09-09 planned 15-modul-berechtigungen-gruppen-user-grants, 16-ad-gruppen-synchronisation 14, 15 high quick-260909-ab3 01 execute 1
apps/web/src/app/(portal)/admin/modules/grants/page.tsx
apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql
apps/api/src/ldap/ldap.service.ts
apps/api/src/ldap/ldap.service.spec.ts
apps/api/src/user/user.service.ts
apps/api/src/tenders/tender-digest.scheduler.ts
apps/api/src/tenders/tender-matching.service.ts
apps/web/src/app/(portal)/admin/ldap/page.tsx
apps/web/src/app/(portal)/admin/users/page.tsx
apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx
apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
apps/web/src/messages/umlaut-dictionary.ts
true
PERM-02
PERM-03
tokens raw_tokens tasks confidence
85000 85000 3 low
truths artifacts key_links
Freigaben-Matrix: ein Suchbegriff, der nur einen GRUPPENNAMEN trifft, laesst ALLE Modulzeilen stehen — die Kreuzung Modul x Gruppe bleibt anklickbar.
Freigaben-Matrix: ein Suchbegriff, der nur einen MODULNAMEN trifft, laesst ALLE Gruppenspalten stehen — die Kreuzung bleibt anklickbar.
Freigaben-Matrix: die Spaltensuche findet eine importierte Gruppe weiterhin sowohl unter ihrem internen Namen als auch unter ihrem AD-Namen (Bestandsverhalten aus WINDOWS #6c bleibt erhalten).
Freigaben-Matrix: ein Begriff, der weder ein Modul noch eine Gruppe trifft, zeigt eine sichtbare Meldung statt einer stumm leeren Tabelle.
AD-Sync: ein Konto, dessen mail-Adresse bereits einem ANDEREN Konto gehoert, wird angelegt — ohne Adresse. Das zuerst angelegte Konto behaelt seine Adresse unveraendert.
AD-Sync: keine Zeile des Sync-Berichts enthaelt rohen ORM-Ausnahmetext oder englischen Techniktext; die technischen Angaben landen ausschliesslich im Serverprotokoll.
AD-Sync: Verzeichniseintraege ohne Anmeldenamen erscheinen als neutraler Hinweis (normaler Vorgang), nicht als Fehler.
Ein Benutzer ohne E-Mail-Adresse bricht weder die Benutzerliste noch die Mitgliedersuche der Gruppenverwaltung.
apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql
apps/web/src/app/(portal)/admin/modules/grants/page.tsx
apps/api/src/ldap/ldap.service.ts
apps/web/src/app/(portal)/admin/ldap/page.tsx
upsertMappedUser() -> UserService.create({ email? }) -> Prisma User.email nullable — die Kette muss durchgaengig optional sein, sonst scheitert die Anlage weiterhin.
LdapSyncResult (apps/api/src/ldap/ldap.service.ts) <-> interface SyncResult (apps/web/src/app/(portal)/admin/ldap/page.tsx) — neue Felder muessen auf beiden Seiten gleich heissen, sonst bleibt der Bericht leer.
de.json <-> en.json Schluesselgleichheit — abgesichert durch den dritten Test in apps/web/src/messages/umlaut-guard.spec.ts (flacht BEIDE Dateien vollstaendig ab).
Zwei unabhaengige, am 2026-09-07/09 im Browser auf alpha gemessene Defekte reparieren.

Defekt A (WINDOWS #14) — Die Suche in der Freigaben-Matrix filtert beide Achsen mit demselben Begriff und leert dadurch die jeweils andere: page.tsx:137 filtert Module ueber m.name, page.tsx:145 filtert Gruppen ueber internalName ?? name. Ein Begriff, der nur eine Achse trifft, entfernt die andere vollstaendig — es bleibt nie ein Kaestchen zum Klicken uebrig. Damit scheitert genau der Zweck der Suche.

Defekt B (WINDOWS #15) — Der AD-Sync reicht rohe Techniktexte durch und laesst echte Konten still liegen. Vier Funktionskonten (CN=uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss, usoftware_rw aus OU=CTL_PWS_Gruppen) tragen alle dieselbe Adresse mbuntz@ctl.de — die ihres Vorgesetzten. Sie scheitern deshalb an der Eindeutigkeitsregel auf User.email und werden NIE importiert; der Administrator liest nur die woertliche Prisma-Meldung. Sechs weitere Eintraege (Kontakte/Ressourcen ohne sAMAccountName) melden englisch no username mapped (check sAMAccountName mapping), obwohl sie voellig korrekt uebersprungen werden.

Gesperrte Produktentscheidung des Nutzers vom 2026-09-09 (nicht verhandelbar): solche Konten MUESSEN angelegt werden, nur eben OHNE E-Mail-Adresse. Wer eine Adresse zuerst belegt, behaelt sie; jedes spaetere Konto mit derselben Adresse entsteht ohne Adresse. Begruendung: die Anmeldung laeuft ueber den Benutzernamen, nicht ueber die Adresse — das Konto funktioniert also, lediglich Benachrichtigungen per E-Mail erreichen es nicht. Nichts geht mehr still verloren. Generisch umzusetzen: Tessera ist ein Mehrmandanten- Produkt, also keine firmenspezifischen Werte und keine Sonderfaelle fuer einzelne Konten im Code (Konvention "Keine Kunden-spezifischen Defaults").

Purpose: Die Freigaben-Matrix wird bedienbar, und der Sync verliert keine Konten mehr und spricht mit dem Administrator in verstaendlichem Deutsch. Output: Drei getrennte, jeweils fuer sich lauffaehige Commits — A, B-Verhalten, B-Anzeige.

Gemessener Ausgangszustand (am Arbeitsbaum geprueft, 2026-09-09)

Beleg Fundstelle
Beide Achsen unabhaengig gefiltert apps/web/src/app/(portal)/admin/modules/grants/page.tsx:133-147
User.email ist PFLICHTFELD (String @unique, kein ?) apps/api/prisma/schema.prisma:32
Kollisionsquelle: Anlage schreibt die Adresse ungeprueft apps/api/src/ldap/ldap.service.ts:421 (Sync) und :604 (Handimport)
Englische Uebersprungen-Meldung apps/api/src/ldap/ldap.service.ts:862 (Sync) und :576 (Handimport)
Roher ORM-Text landet in errors apps/api/src/ldap/ldap.service.ts:885 (generischer Auffang je Eintrag)
Bericht rendert errors unveraendert in Rot apps/web/src/app/(portal)/admin/ldap/page.tsx:1341-1350
Absturzrisiko bei leerer Adresse apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx:104 (u.email.toLowerCase())

Zur Datenbank-Aenderung (bitte woertlich so weitergeben)

User.email muss leer sein duerfen. Das ist eine Schema-Aenderung mit Migration. Die Migration wird in diesem Plan NUR geschrieben, NICHT ausgefuehrt — weder lokal noch auf dem Testserver. Das Ausrollen macht der Nutzer.

Wichtige Richtigstellung zur Aufgabenbeschreibung: die API ruft prisma migrate deploy sehr wohl beim Start auf — im Startbefehl des Images (apps/api/Dockerfile:38). Die Migration greift also automatisch, sobald der Nutzer die API neu baut und neu startet. Solange sie NICHT angewandt ist, bleibt die Spalte ein Pflichtfeld: ein Konto mit kollidierender Adresse wird dann weiterhin nicht angelegt, scheitert aber sauber als gezaehlter Eintragsfehler im Bericht statt mit rohem Techniktext. Kein Absturz, keine stille Luecke.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @CLAUDE.md

Bestandscode, der beim Umsetzen gelesen werden muss: @apps/web/src/app/(portal)/admin/modules/grants/page.tsx @apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx @apps/api/src/ldap/ldap.service.ts @apps/api/src/ldap/ldap.service.spec.ts @apps/web/src/app/(portal)/admin/ldap/page.tsx @apps/web/src/messages/umlaut-guard.spec.ts

Verbindliche Konventionen aus dem Bestand:

  • Strukturierte Rueckgabe statt uebersetzter Backend-Prosa: LdapGroupImportResult.nameCollisions ist genau dafuer eingefuehrt worden (ldap.service.ts:74-86) — Backend liefert Daten, das Frontend formuliert den Satz aus de.json/en.json.
  • Sprachschluessel-Gate: der dritte Test in apps/web/src/messages/umlaut-guard.spec.ts flacht de.json UND en.json vollstaendig ab und vergleicht die Schluesselmengen. Ein Schluessel nur in einer Datei faellt sofort auf.
  • Umlaut-Gate: jedes NEUE deutsche Wort in de.json, das die Folge ae, oe, ue oder ss enthaelt, muss in UMLAUT_ALLOWLIST (apps/web/src/messages/umlaut-dictionary.ts) stehen, sonst wird der Test rot. Betroffen von den hier geplanten Texten waeren z. B. Adressen oder Ressourcen (Adresse, muss, Passwort stehen bereits drin). Die Fehlermeldung des Tests nennt das fehlende Wort woertlich.
Task 1: Matrix-Suche filtert nur noch die getroffene Achse (WINDOWS #14) apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json Verbindliche Semantik (in dieser Reihenfolge auszuwerten), `moduleHits` = Module deren `name` passt, `groupHits` = Gruppen deren `internalName ?? name` ODER `name` passt:
1. Leeres Suchfeld -> alle Module, alle Gruppen. (Bestandsverhalten)
2. `moduleHits` nicht leer, `groupHits` leer -> nur `moduleHits` als Zeilen, ALLE Gruppen
   als Spalten.
3. `groupHits` nicht leer, `moduleHits` leer -> ALLE Module als Zeilen, nur `groupHits`
   als Spalten.
4. Beide nicht leer -> beide Achsen gefiltert. Das leert keine Achse, weil beide Mengen
   Treffer enthalten; die Kreuzung bleibt klickbar.
5. Beide leer (Begriff trifft nichts) -> Tabelle gar nicht rendern, stattdessen eine
   sichtbare Meldung.

Neue Testfaelle in `grants-matrix.test.tsx` (im bestehenden
`describe('AdminModuleGrantsPage (Permission-Matrix)')`):
- Test 1: Suche 'Buchhaltung' -> 'Ausschreibungs-Radar' UND 'DKV Flotte' sind sichtbar,
  'Alle Benutzer' ist verschwunden, `screen.getAllByRole('checkbox')` ist nicht leer.
- Test 2: Suche 'Flotte' -> 'Alle Benutzer' UND 'Buchhaltung' sind sichtbar,
  'Ausschreibungs-Radar' ist verschwunden.
- Test 3: Suche 'Vertrieb' (interner Name) und danach — nach `clear()` — 'Claude_VT'
  (AD-Name) -> in beiden Faellen ist die Spaltenueberschrift 'Vertrieb' sichtbar UND
  beide Modulnamen sind sichtbar. Regressionsschutz fuer WINDOWS #6c.
- Test 4: Suche 'zzz' -> die neue Meldung ist sichtbar und
  `screen.queryAllByRole('checkbox')` hat Laenge 0.

Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen und die Rotfaerbung
protokollieren. Erwartete Ursachen: Test 1 scheitert, weil beide Modulnamen fehlen;
Test 2, weil beide Gruppenueberschriften fehlen; Test 3, weil die Modulnamen fehlen;
Test 4, weil der Meldungstext nirgends existiert. Sind die Ursachen andere, erst klaeren,
dann umsetzen.
Zuerst die Testdatei erweitern, dann die Seite anpassen.
In `grants-matrix.test.tsx`: `mockMatrix.groups` um einen dritten Eintrag ergaenzen, der
eine importierte AD-Gruppe abbildet — `{ id: 'g3', name: 'Claude_VT', internalName: 'Vertrieb' }`.
Bestehende Tests duerfen dadurch nicht rot werden (sie pruefen namentlich genannte Texte,
eine zusaetzliche Spalte stoert nicht). Den lokalen `messages`-Mock im Namensraum
`adminModules.grants` um den neuen Schluessel ergaenzen, damit der Meldungstext im Test
aufgeloest wird. Danach die vier oben beschriebenen Testfaelle ergaenzen.

In `page.tsx`: `matches` (Zeile 134) unveraendert lassen. `filteredModules` (Zeile 136-140)
und `filteredGroups` (Zeile 141-147) durch einen gemeinsamen `useMemo` ueber
`[modules, groups, searchLower]` ersetzen, der `moduleHits`, `groupHits`, `filteredModules`,
`filteredGroups` und ein `noMatch`-Flag nach der Semantik oben liefert. Die vorhandene
Erklaerung zur doppelten Gruppen-Namenspruefung (interner Name UND AD-Name, D-04) bleibt als
Kommentar erhalten — dieses Verhalten ist gemessen richtig und darf nicht wegfallen.
`groupedModules` (Zeile 149-160) bleibt unveraendert und liest weiter `filteredModules`.

Im Renderteil: die Tabelle ab Zeile 205 nur noch rendern, wenn `noMatch` falsch ist. Bei
`noMatch` stattdessen einen Absatz mit `t('noSearchResults', { search })` ausgeben, gestaltet
wie der vorhandene Leerzustand weiter oben (`rounded-md border border-border bg-card p-6
text-center`, Text in `text-sm text-muted-foreground`). Das Suchfeld selbst bleibt immer
sichtbar, sonst kann der Nutzer seinen Begriff nicht mehr korrigieren.

Sprachdateien: `adminModules.grants.noSearchResults` in BEIDE Dateien.
de: `Kein Treffer fuer "{search}" — weder bei den Modulen noch bei den Gruppen.` — dabei die
typografischen Anfuehrungszeichen und das korrekte Wort mit u-Umlaut verwenden, keine
Ersatzschreibung (das Umlaut-Gate prueft genau das).
en: `No match for "{search}" — neither in the modules nor in the groups.`
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx" "src/messages/umlaut-guard.spec.ts" && pnpm --filter @tessera/web type-check Die vier neuen Testfaelle sind gruen, die acht bestehenden Testfaelle der Datei ebenfalls. Das Sprachschluessel-Gate (umlaut-guard.spec.ts) ist gruen, also existiert der neue Schluessel in de.json UND en.json. `pnpm --filter @tessera/web type-check` ist sauber. Eigener Commit, der ausschliesslich Defekt A enthaelt. Task 2: Kollidierende AD-Konten werden angelegt — ohne Adresse (WINDOWS #15, Verhalten) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql, apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts, apps/api/src/user/user.service.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-matching.service.ts

Das Aufheben der Pflicht auf User.email ist rueckgaengig zu machen, solange keine Zeile leer ist; sobald der erste Sync Konten ohne Adresse angelegt hat, verlangt die Rueckkehr zum Pflichtfeld ein Nachtragen oder Loeschen dieser Zeilen. Die Produktentscheidung ist vom Nutzer am 2026-09-09 gesperrt, daher kein Halt.

Neue Testfaelle in `ldap.service.spec.ts` — Konventionen des Bestands uebernehmen (gemocktes `ldapts`, `prisma` als Objekt-Mock, `userService.create` als `vi.fn()`). Der neue Kollisionscheck laeuft ueber `prisma.user.findUnique`; das ist im Bestand noch nicht gemockt und muss dem Prisma-Mock hinzugefuegt werden (Vorgabe `null`).
- Test 1 "legt ein Konto mit belegter Adresse trotzdem an, nur ohne Adresse":
  zwei Eintraege mit derselben `mail`, `findUnique` liefert beim zweiten den bereits
  angelegten Fremdbenutzer. Erwartung: `userService.create` wurde ZWEIMAL gerufen;
  der erste Aufruf traegt die Adresse, der zweite traegt keine (`email` ist
  `undefined`/nicht gesetzt). `result.created === 2`.
- Test 2 "meldet die Kollision strukturiert und nicht als Fehler":
  `result.emailConflicts` enthaelt genau einen Eintrag mit Kontoname und der belegten
  Adresse; `result.errors` ist leer.
- Test 3 "nimmt einem bestehenden Konto seine Adresse nicht weg":
  ein bereits vorhandener Benutzer (Treffer ueber `findFirst`) bekommt eine Adresse
  zugeordnet, die einem DRITTEN Benutzer gehoert. Erwartung: `prisma.user.update` wird
  OHNE `email` im `data`-Objekt gerufen; die Kollision steht in `emailConflicts`.
- Test 4 "meldet Eintraege ohne Anmeldenamen getrennt und nicht als Fehler":
  ein Eintrag ohne `sAMAccountName`. Erwartung: seine Kennung steht in
  `result.skippedNoLogin`, `result.errors` ist leer.
- Test 5 "reicht keinen rohen Datenbanktext an den Bericht durch":
  `userService.create` wirft einmal einen Fehler, dessen `message` den Wortlaut einer
  ORM-Ausnahme traegt. Erwartung: die Kennung des Eintrags steht in
  `result.entryFailures`; KEIN Element von `errors`, `entryFailures`, `skippedNoLogin`
  oder der Kollisionsliste enthaelt den geworfenen Ausnahmetext (Vergleich per
  `expect(JSON.stringify(result)).not.toContain(<geworfener Text>)`).

Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen. Erwartete Rotfaerbung:
Test 1 scheitert, weil der zweite `create`-Aufruf die Adresse mitschickt; Test 2/4 scheitern,
weil die Felder nicht existieren; Test 3, weil `update` die Adresse mitschreibt; Test 5,
weil der Ausnahmetext woertlich in `errors` steht. Andere Ursachen erst klaeren.
**Schritt 1 — Schema und Migration (nur schreiben, nicht ausfuehren).** In `apps/api/prisma/schema.prisma:32` das Feld auf optional stellen (`String? @unique`). Neues Verzeichnis `apps/api/prisma/migrations/20260909120000_user_email_optional/` mit `migration.sql`, das die Pflicht auf der Spalte aufhebt (`ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;`). Den vorhandenen Eindeutigkeitsindex NICHT anfassen: PostgreSQL behandelt leere Werte in einer Eindeutigkeitsregel als jeweils verschieden, mehrere Konten ohne Adresse sind also erlaubt. Kopfkommentar auf Deutsch im Stil der Bestandsmigrationen (siehe `20260812110000_tender_rss_feed_owner/migration.sql`): Anlass, gesperrte Nutzerentscheidung vom 2026-09-09, und der Hinweis, dass Bestandszeilen unangetastet bleiben. Danach den Client neu erzeugen, damit die Typen die Optionalitaet kennen — das ist reine Codegenerierung, kein Ausrollen.
**Schritt 2 — geteilter Kollisionsentscheider in `ldap.service.ts`.**
Eine private Hilfsmethode ergaenzen, die zu einer gewuenschten Adresse und der Kennung
eines eventuell schon vorhandenen eigenen Datensatzes entscheidet, ob die Adresse
geschrieben werden darf. Sie fragt `this.prisma.user.findUnique({ where: { email } })` und
gibt die Adresse nur zurueck, wenn niemand sie haelt oder der Halter derselbe Datensatz
ist; andernfalls gibt sie keine Adresse und einen Kollisionsvermerk aus Kontoname und
Adresse zurueck. Bewusst `findUnique` und nicht `findFirst`: die Spalte ist eindeutig, und
die bestehende `findFirst`-Nutzung in `upsertMappedUser` bleibt dadurch eindeutig
unterscheidbar — auch im Test.

In `upsertMappedUser` (ab Zeile 389) den Rueckgabetyp von `'created' | 'updated'` auf ein
Objekt erweitern, das den Status UND einen optionalen Kollisionsvermerk traegt. Im
Aktualisierungszweig (Zeile 410) die Adresse nur uebernehmen, wenn der Entscheider sie
freigibt — eine fremde Adresse wird niemals umgehaengt, der bestehende Wert bleibt stehen.
Im Anlagezweig (Zeile 419-427) die Adresse nur setzen, wenn sie freigegeben ist; ist sie
belegt, das Konto ohne Adresse anlegen. Der vorhandene Ersatzwert `${username}@ldap.local`
fuer Eintraege OHNE `mail`-Attribut bleibt unveraendert — er ist nicht Teil dieses Defekts
und wird hier ausdruecklich nicht angefasst.

Denselben Entscheider auch in `importUsersByDn` an der Anlagestelle (Zeile 602-609)
verwenden, damit der Handimport nicht als zweiter Weg mit rohem Datenbanktext bestehen
bleibt. `LdapUserImportResult` bekommt dabei KEIN neues Feld: das Konto entsteht, wird als
`created` gezaehlt, und die Anzeige dieses Wegs bleibt unveraendert.

**Schritt 3 — strukturierter Sync-Bericht.**
`LdapSyncResult` (ab Zeile 11) um drei Felder ergaenzen: eine Liste der Konten, die ohne
Adresse angelegt oder aktualisiert wurden (je Eintrag Kontoname und belegte Adresse); eine
Liste der Kennungen, die mangels Anmeldenamen uebersprungen wurden; eine Liste der
Kennungen, bei denen ein unerwarteter Fehler auftrat. Die Initialisierung bei Zeile 768
entsprechend ergaenzen — es ist die einzige Stelle im Code, an der ein `LdapSyncResult`
entsteht.

In `syncUsersForTenant`: die Meldung bei Zeile 861-863 durch einen Eintrag in der
Uebersprungen-Liste ersetzen; den generischen Auffang bei Zeile 880-886 so umbauen, dass
nur noch die Kennung des Eintrags in die Fehlerliste wandert, waehrend der technische
Wortlaut ueber `this.logger.error` ins Serverprotokoll geht und den Bericht nie erreicht.
Den Kollisionsvermerk aus `upsertMappedUser` in die Kollisionsliste uebernehmen.

Nicht anfassen: `result.errors` traegt weiterhin die Lauffehler (`Sync failed: ...`) und
die Gruppenmeldungen aus `syncBoundGroupsForTenant`/`syncGroupMembershipsForTenant`. Diese
sind bereits deutsche Prosa und nicht Gegenstand dieses Defekts.

**Schritt 4 — Folgeanpassungen der Typkette.**
`UserService.create` (`user.service.ts:49-58`) nimmt die Adresse ab jetzt optional
entgegen. Die Typpruefung wird nach der Neuerzeugung des Clients an zwei weiteren Stellen
rot, weil eine Adresse jetzt leer sein kann; beide sind Mailversand und beide muessen den
Empfaenger ueberspringen statt zu senden: `tender-digest.scheduler.ts:140` und
`tender-matching.service.ts:131` — vor dem Aufruf abbrechen, wenn der Benutzer keine
Adresse hat, und mit `continue` zum naechsten weitergehen (die umgebenden Schleifen tun das
bei fehlendem Benutzer bereits genauso). Das ist genau das zugesagte Verhalten: das Konto
funktioniert, nur Benachrichtigungen erreichen es nicht. Tauchen weitere Fundstellen auf,
nach demselben Muster behandeln und im SUMMARY vermerken.
cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma validate && DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma generate && cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/ldap/ldap.service.spec.ts && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/api type-check Die fuenf neuen Testfaelle sind gruen, die 62 bestehenden Testfaelle der Datei ebenfalls, und die vollstaendige API-Testsuite laeuft durch. `prisma validate` bestaetigt das Schema, `pnpm --filter @tessera/api type-check` ist sauber. Die Migrationsdatei existiert und wurde NICHT ausgefuehrt. Eigener Commit, getrennt von Task 1 und Task 3. Task 3: Verstaendlicher deutscher Sync-Bericht und Konten ohne Adresse in der Oberflaeche (WINDOWS #15, Anzeige) apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts Neuer Testfall in `groups-page.test.tsx`, im bestehenden `describe('GroupMembersModal (via AdminGroupsPage)')`: "Die Benutzersuche stuerzt bei einem Konto ohne Adresse nicht ab". `buildRouterFetchMock` so erweitern, dass der `/users`-Zweig (heute `[]`) einen Benutzer ohne Adresse liefert (`{ id: 'u12', username: 'funktionskonto', displayName: null, email: null }`). Im Test die Mitgliederansicht der Gruppe 'Buchhaltung' oeffnen, in das Suchfeld mit dem Platzhalter 'Benutzer suchen...' den Begriff 'funktion' tippen und erwarten, dass 'funktionskonto' in der Ergebnisliste erscheint.
Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen. Erwartete Rotfaerbung:
`GroupMembersModal.tsx:104` ruft `.toLowerCase()` auf einem leeren Wert auf und die
Darstellung bricht ab. Ist die Ursache eine andere, erst klaeren.
**Sync-Bericht.** In `apps/web/src/app/(portal)/admin/ldap/page.tsx` das `interface SyncResult` (Zeile 62-76) um die drei in Task 2 ergaenzten Felder erweitern — gleiche Namen, gleiche Formen wie im Backend, sonst bleibt der Bericht leer. Im Anzeigeblock (Zeile 1312-1351) unter den drei Zahlenzeilen drei getrennte Abschnitte ergaenzen, jeweils nur wenn die zugehoerige Liste nicht leer ist, und in dieser Reihenfolge:
1. Konten ohne Adresse — braucht Aufmerksamkeit, aber es ist kein Fehler. Farbe wie die
   vorhandene Bernstein-Zeile `defaultMarkerMoved` (`text-amber-700 dark:text-amber-400`).
   Ueberschriftssatz aus dem Sprachkatalog, darunter je Eintrag eine Zeile mit Kontoname
   und belegter Adresse.
2. Uebersprungene Eintraege ohne Anmeldenamen — neutraler Hinweis, Farbe
   `text-muted-foreground`, ausdruecklich NICHT `text-destructive`. Ueberschriftssatz aus
   dem Katalog, darunter die Kennungen als Datenzeilen.
3. Unerwartete Fehler je Eintrag — `text-destructive`, Ueberschriftssatz aus dem Katalog mit
   dem Hinweis, dass die technischen Angaben im Serverprotokoll stehen, darunter die
   Kennungen.

Der bestehende `errors`-Block bleibt unveraendert bestehen: er traegt weiterhin Lauf- und
Gruppenfehler.

**Sprachkatalog.** Unter `admin.ldap.sync` in BEIDE Dateien drei Ueberschriftsschluessel
und einen Zeilenschluessel fuer die Kollision ergaenzen. Inhaltlich verbindlich (Wortlaut
darf gefeilt werden, Aussage nicht):
- Kollision, Ueberschrift: diese Konten wurden ohne E-Mail-Adresse angelegt, weil die
  Adresse bereits zu einem anderen Konto gehoert; Anmeldung und Zugriff funktionieren,
  nur Benachrichtigungen per E-Mail erreichen sie nicht.
- Kollision, Zeile: Kontoname und die bereits vergebene Adresse, mit Platzhaltern
  `{account}` und `{email}`.
- Uebersprungen, Ueberschrift: ohne Anmeldenamen uebersprungen — normal fuer Kontakte und
  Verteiler im Verzeichnis.
- Unerwarteter Fehler, Ueberschrift: bei diesen Eintraegen trat ein unerwarteter Fehler
  auf, die technischen Angaben stehen im Protokoll des Servers.
Kein Fachbegriff, kein Produktname einer Bibliothek, keine englische Wendung im deutschen
Text. Jedes neue deutsche Wort mit der Folge `ae`/`oe`/`ue`/`ss`, das noch nicht in
`UMLAUT_ALLOWLIST` steht, dort ergaenzen — die Fehlermeldung des Gates nennt das Wort
woertlich, und `Adresse`, `muss`, `Passwort` stehen bereits drin.

**Konten ohne Adresse in der Oberflaeche.** In `GroupMembersModal.tsx` das Feld im
`interface TenantUser` als moeglicherweise leer deklarieren und den Suchvergleich bei
Zeile 104 gegen einen leeren Wert absichern — nach demselben Muster, das die Zeile darueber
fuer `displayName` bereits verwendet. Die Anzeige bei Zeile 233 bleibt wie sie ist und
stellt bei leerem Wert nichts dar. In `apps/web/src/app/(portal)/admin/users/page.tsx` das
Feld im `interface User` (Zeile 13) ebenfalls als moeglicherweise leer deklarieren; die
Uebernahme in das Formular (Zeile 96) faellt dann auf eine leere Zeichenkette zurueck, damit
das Eingabefeld ein kontrolliertes Feld bleibt, und die Tabellenzelle (Zeile 225) zeigt bei
leerem Wert einen Gedankenstrich statt einer leeren Zelle. `UserFormData.email` bleibt eine
Pflichtangabe: die Anlage von Hand verlangt weiterhin eine Adresse.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" "src/messages/umlaut-guard.spec.ts" && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check Der neue Testfall ist gruen, die uebrigen Testfaelle von `groups-page.test.tsx` ebenfalls, und die vollstaendige Web-Testsuite laeuft durch. Das Sprachschluessel-Gate ist gruen, also stehen alle neuen Schluessel in de.json UND en.json, und das Umlaut-Gate meldet kein ungepruefte neues Wort. `pnpm --filter @tessera/web type-check` ist sauber. Eigener Commit, getrennt von Task 1 und Task 2.

<threat_model>

Trust Boundaries

Boundary Description
Active Directory -> Tessera-Sync Fremdgepflegte Verzeichnisdaten (Anzeigename, mail, sAMAccountName) fliessen ungeprueft in eigene Datensaetze.
API -> Administrations-Oberflaeche Der Sync-Bericht transportiert Servertexte in den Browser eines Administrators.
Nutzereingabe -> Freigaben-Matrix Der Suchbegriff steuert, welche Kaestchen sichtbar und damit klickbar sind.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-Q3-01 Spoofing upsertMappedUser Aktualisierungszweig (ldap.service.ts:410) high mitigate Eine Adresse wird NIE von einem Datensatz auf einen anderen umgehaengt. Der Entscheider vergleicht die Kennung des Halters mit der des eigenen Datensatzes; passt sie nicht, bleibt die Adresse beim bisherigen Konto. Sonst koennte ein Verzeichniseintrag die Adresse eines echten Menschen an sich ziehen und darueber dessen Passwort-Zuruecksetzung empfangen. Abgesichert durch Test 3 aus Task 2.
T-Q3-02 Information Disclosure Sync-Bericht (ldap.service.ts:885 -> admin/ldap/page.tsx:1341) medium mitigate Roher Ausnahmetext der Datenbankschicht nennt Tabellen-, Spalten- und Aufrufnamen und wird einem Administrator im Klartext gezeigt. Ab jetzt wandert nur die Kennung des Eintrags in den Bericht, der technische Wortlaut ausschliesslich ins Serverprotokoll. Abgesichert durch Test 5 aus Task 2.
T-Q3-03 Information Disclosure Auflistung der Verzeichniskennungen im Bericht low accept Die Seite ist bereits auf ADMIN/SUPER_ADMIN beschraenkt, und wer den Sync ausloest, kennt das Verzeichnis ohnehin. Kein zusaetzlicher Schutz.
T-Q3-04 Spoofing Passwort-Zuruecksetzung bei leerer Adresse (auth.service.ts:143-146) medium mitigate Die Suche laeuft ueber eine angefragte Zeichenkette; ein leerer Wert in der Spalte wird davon nie getroffen, ein Konto ohne Adresse ist also ueber diesen Weg nicht ansprechbar — genau die zugesagte Wirkung. Keine Codeaenderung noetig, aber vor Abschluss zu bestaetigen.
T-Q3-05 Denial of Service Zusaetzliche Adressabfrage je Verzeichniseintrag low accept Eine zusaetzliche indizierte Punktabfrage je Eintrag; bei den gemessenen 405 Konten vernachlaessigbar, und der Sync laeuft ohnehin nicht im Anfragepfad eines Nutzers.
T-Q3-06 Tampering Anzeige der Matrix nach Suche low accept Die Suche veraendert ausschliesslich die Sichtbarkeit; jeder Umschaltvorgang laeuft weiterhin ueber den bestehenden, serverseitig geprueften Endpunkt. Eine gefilterte Ansicht kann keine Freigabe setzen, die der Server nicht erlaubt.
T-Q3-SC Tampering Paketinstallationen low accept Dieser Plan installiert kein einziges Paket (npm/pip/cargo). Das Legitimitaets-Gate greift daher nicht; entsteht beim Umsetzen doch ein Installationsbedarf, ist die Arbeit zu stoppen und der Bedarf vorzulegen.
</threat_model>
Nach allen drei Tasks, vom Wurzelverzeichnis aus:
pnpm --filter @tessera/api exec vitest run
pnpm --filter @tessera/api type-check
pnpm --filter @tessera/web exec vitest run
pnpm --filter @tessera/web type-check

Alle vier muessen sauber durchlaufen. Der Ausgangsstand vor der Arbeit ist gemessen: ldap.service.spec.ts 62/62 gruen, grants-matrix.test.tsx 8/8 gruen, API-Typpruefung sauber.

Vom Nutzer im Browser nachzuholen (nicht durch den Ausfuehrenden, siehe Konvention "Kein Docker-Deploy auf Testserver"), nach seinem naechsten Neubau von API und Web:

  1. Freigaben-Matrix oeffnen und nach Claude_VT bzw. Vertrieb suchen: die Gruppenspalte bleibt stehen UND alle Modulzeilen bleiben stehen, es gibt anklickbare Kaestchen.
  2. Dieselbe Matrix, Suche nach Cert: die Modulzeile bleibt stehen UND alle Gruppenspalten bleiben stehen.
  3. Dieselbe Matrix, Begriff ohne Treffer: eine verstaendliche Meldung statt einer leeren Tabelle.
  4. AD-Sync ausloesen: die vier Konten uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss und usoftware_rw erscheinen ab jetzt in der Benutzerliste — ohne Adresse. Das Konto, das mbuntz@ctl.de bereits haelt, behaelt die Adresse unveraendert.
  5. Derselbe Bericht: keine Zeile enthaelt englischen Techniktext oder Datenbankwortlaut; die sechs Kontakte ohne Anmeldenamen stehen als neutraler Hinweis, nicht in Rot.

<success_criteria>

  1. Suche nach einem Gruppennamen laesst alle Modulzeilen stehen; Suche nach einem Modulnamen laesst alle Gruppenspalten stehen; in beiden Faellen bleibt mindestens ein Kaestchen klickbar. Belegt durch zwei Testfaelle, die gegen den heutigen Stand rot sind.
  2. Die Spaltensuche findet eine importierte Gruppe weiterhin unter internem UND AD-Namen.
  3. Ein Begriff ohne Treffer erzeugt eine sichtbare Meldung, keine stumm leere Tabelle.
  4. Ein Verzeichniskonto mit bereits belegter Adresse wird angelegt — ohne Adresse — und der bisherige Halter der Adresse behaelt sie.
  5. Kein Bestandteil des Sync-Berichts traegt rohen Datenbank-Ausnahmetext oder englischen Techniktext; die technischen Angaben stehen nur im Serverprotokoll.
  6. Uebersprungene Eintraege ohne Anmeldenamen erscheinen als neutraler Hinweis, getrennt von echten Fehlern.
  7. Ein Konto ohne Adresse bricht weder die Benutzerliste noch die Mitgliedersuche.
  8. Alle neuen Anzeigetexte stehen in de.json UND en.json; beide Sprachgates sind gruen.
  9. Beide Testsuiten und beide Typpruefungen sind vollstaendig gruen.
  10. Drei getrennte Commits: Defekt A, Verhalten von Defekt B, Anzeige von Defekt B. </success_criteria>

<deployment_note> Dieser Plan rollt NICHTS aus: kein docker compose build, kein up, kein restart, kein prisma migrate deploy, keine Aktion auf dem Testserver. Die Migration wird ausschliesslich als Datei geschrieben.

Fuer den Nutzer: die Migration steckt im API-Image und wird vom Startbefehl des Containers angewandt (apps/api/Dockerfile:38 ruft prisma migrate deploy vor dem Start auf). Ein Neubau und Neustart der API genuegt also. Solange das nicht geschehen ist, bleibt die Spalte ein Pflichtfeld und die vier Konten werden weiterhin nicht angelegt — dann aber mit einer verstaendlichen Zeile im Bericht statt mit rohem Techniktext. </deployment_note>

Create `.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-SUMMARY.md` when done