Files
tessera-ctl/.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-PLAN.md
T
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

521 lines
34 KiB
Markdown

---
quick_id: 260909-ab3
slug: matrix-suche-und-sync-meldungen-reparier
date: 2026-09-09
status: planned
relates_to: 15-modul-berechtigungen-gruppen-user-grants, 16-ad-gruppen-synchronisation
windows_ref: 14, 15
severity: high
phase: quick-260909-ab3
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
requirements: [PERM-02, PERM-03]
estimate:
tokens: 85000
raw_tokens: 85000
tasks: 3
confidence: low
must_haves:
truths:
- "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."
artifacts:
- 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
key_links:
- "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)."
---
<objective>
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.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<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.
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Matrix-Suche filtert nur noch die getroffene Achse (WINDOWS #14)</name>
<files>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</files>
<behavior>
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.
</behavior>
<action>
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.`
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx" "src/messages/umlaut-guard.spec.ts" &amp;&amp; pnpm --filter @tessera/web type-check</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Kollidierende AD-Konten werden angelegt — ohne Adresse (WINDOWS #15, Verhalten)</name>
<files>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</files>
<reversibility rating="costly">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.</reversibility>
<behavior>
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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api &amp;&amp; DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma validate &amp;&amp; DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma generate &amp;&amp; cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/ldap/ldap.service.spec.ts &amp;&amp; pnpm --filter @tessera/api exec vitest run &amp;&amp; pnpm --filter @tessera/api type-check</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Verstaendlicher deutscher Sync-Bericht und Konten ohne Adresse in der Oberflaeche (WINDOWS #15, Anzeige)</name>
<files>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</files>
<behavior>
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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" "src/messages/umlaut-guard.spec.ts" &amp;&amp; pnpm --filter @tessera/web exec vitest run &amp;&amp; pnpm --filter @tessera/web type-check</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
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.
</verification>
<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>
<output>
Create `.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-SUMMARY.md` when done
</output>