24 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | estimate | must_haves | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 17-eigene-ausschreibungs-quellen-je-nutzer | 01 | execute | 1 |
|
false |
|
|
|
Dieser Plan ist die duenne, aber vollstaendige Bahn durch alle Schichten: Datenbank -> Migration -> Dienst -> Endpunkt -> eine eigene, fuer jeden Modulnutzer erreichbare Seite. Erst wenn diese eine Bahn nachweislich laeuft, bauen die Plaene 17-02 (RSS-Feeds) und 17-03 (Oberflaeche aufteilen) daneben weiter.
Purpose: D-01 aus 17-CONTEXT.md umsetzen und damit die eigentliche Ursache des Backlog-Punkts beheben — nicht nur die Anzeige, sondern den Zuschnitt. Output: Geaenderte Tabelle samt Umzug der Bestandsdaten, nutzerbezogener Dienst und Endpunkt, neue Seite "Meine Quellen" mit dem Postfach-Formular.
Harte Grenze (D-05): Ausschreibungsdaten bleiben plattform-global. Tender
bekommt kein Mandantenfeld, keine RLS, keinen Mandanten-Wrapper. Geaendert wird
ausschliesslich, wer Quellen einspeist — nicht, wer Treffer sieht. Innerhalb
eines Mandanten sieht weiterhin jeder alles.
Entscheidung zum offenen Punkt 1 (Besitzer der Bestandszeile): Eine vorhandene Postfach-Zeile wird dem aeltesten aktiven ADMIN bzw. SUPER_ADMIN ihres Mandanten zugeordnet, nicht geloescht. Begruendung: Die Zeile wurde seinerzeit von genau dieser Rolle angelegt, sie enthaelt verschluesselte Zugangsdaten, und ein Loeschen wuerde den laufenden Abruf ohne Vorwarnung stilllegen. Nur falls ein Mandant ueberhaupt keinen aktiven Administrator hat, wird die Zeile entfernt — dann gaebe es niemanden, der sie je wieder bearbeiten koennte, waehrend das Postfach im Hintergrund weiter abgefragt wuerde.
Entscheidung zum offenen Punkt 4 (wohin mit den nutzereigenen Abschnitten):
Es entsteht eine eigene nutzerseitige Modulseite /modules/tender-radar/my-sources
("Meine Quellen"), getrennt von der Administrationsseite. Begruendung: Die
Alternative, alles nach /settings/general zu verschieben, sammelt dort mit
jedem neuen Modul modulfremde Einstellungen an — genau der Einwand aus dem
Backlog-Punkt. Modulsachen bleiben beim Modul; DKV-Fleet und kuenftige Module
koennen dieselbe Bauform uebernehmen. Der Adresspfad bleibt englisch
(my-sources), wie jeder andere Pfad im Projekt auch; die Beschriftung ist
deutsch.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md@apps/api/prisma/schema.prisma @apps/api/src/tenders/tender-email-config.service.ts @apps/api/src/tenders/adapters/email-alert.adapter.ts @apps/api/prisma/migrations/20260811140000_encrypt_ldap_bind_password/migration.sql @apps/api/src/tenders/doe-url-migration-sql.spec.ts
Task 1: Freigabe fuer die beiden Datenbank-Umbauten dieser Phase Darf ich die Tabellenstruktur fuer Postfaecher und RSS-Feeds jetzt umbauen? Diese Phase aendert zwei Tabellen dauerhaft. Beides laesst sich nur mit einer weiteren Datenbank-Aenderung zurueckdrehen, nicht durch einfaches Rueckgaengigmachen der Dateien:- Postfaecher (dieser Plan): Das Postfach haengt danach am Nutzer statt am Mandanten. Eine bereits eingerichtete Postfach-Zeile bekommt dabei den aeltesten aktiven Administrator ihres Mandanten als Besitzer. Findet sich dort kein Administrator, wird die Zeile entfernt — sie waere sonst fuer niemanden mehr bearbeitbar, wuerde aber weiter abgefragt.
- RSS-Feeds (Plan 17-02): Die Feeds bekommen ein Besitzer-Feld. Alle heute vorhandenen Feeds bleiben unveraendert plattformweit fuer alle aktiv — dort geht nichts verloren.
Auf dem Testserver liegen echte Daten. Der Umbau selbst laeuft hier lokal; auf dem Testserver wird er erst wirksam, wenn Sie dort selbst neu ausrollen. Vor diesem Ausrollen ist eine Datenbank-Sicherung sinnvoll.
Bevor ich anfange, zaehle ich nach, wie viele Postfach-Zeilen es auf dem Testserver ueberhaupt gibt, und nenne Ihnen die Zahl — nur lesend, ich fasse dort nichts an. Weiter — Umbau durchfuehren Die Phase kann wie besprochen gebaut werden; jeder bekommt sein eigenes Postfach. Der Schritt ist nur mit einer weiteren Aenderung rueckdrehbar. Stopp — erst Sicherung, dann neu ansetzen Kein Risiko, bis eine Sicherung vorliegt. Die Phase pausiert. Antworten Sie mit "weiter" oder "stopp".
Task 2: Ein eigenes Postfach je Nutzer — durchgehend von der Datenbank bis zur Seite Die lokale Entwicklungsdatenbank ist ohne Host-Port erreichbar: Container-IP des `db`-Containers ermitteln (`docker inspect`) und `DATABASE_URL` mit Zugang `tessera:tessera_dev` darauf zeigen lassen — sonst laesst sich die Migration nicht anwenden (Projektwissen "Lokale DB-Migrationen"). Die Migration schreibt Bestandsdaten um und entfernt eine Eindeutigkeitsregel; ein Rueckweg braucht eine zweite Migration. Freigabe erfolgt in Task 1. apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tenders.controller.ts, apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tenders.controller.ts (Zeilen 90-115 und 270-305), apps/api/prisma/migrations/20260723113917_tender_email_config_owner_tenant_id/migration.sql, apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx - Ein Nutzer speichert sein Postfach; danach liest derselbe Nutzer genau diese Werte zurueck. - Ein zweiter Nutzer desselben Mandanten speichert ein anderes Postfach; beide Zeilen existieren nebeneinander, keine ueberschreibt die andere. - Nutzer B liest nie die Werte von Nutzer A — gelesen wird ausschliesslich nach dem angemeldeten Konto. - Das Passwort verlaesst den Server nie: die Antwort enthaelt weiterhin nur `hasPassword: boolean`. - Wird nur der Benutzername geaendert und das Passwortfeld leer gelassen, bleibt das gespeicherte Passwort erhalten (bestehende Semantik, unveraendert). **Vorabpruefung (nur lesend, Testserver).** Ueber SSH auf 192.168.13.12 mit `psql` zaehlen, wie viele Zeilen in `TenderEmailConfig` stehen und zu welchem Mandanten sie gehoeren. Ergebnis im SUMMARY festhalten. Nichts aendern, kein `docker compose`-Kommando dort ausfuehren — Ausrollen ist Sache des Nutzers.Schema (apps/api/prisma/schema.prisma, Modell TenderEmailConfig).
Feld userId String @unique ergaenzen. tenantId von @unique auf ein
gewoehnliches Feld zurueckstufen (bleibt erhalten, denormalisiert, fuer die
SMTP-Aufloesung und die Herkunftsmarkierung — gleiche Rolle wie in
TenderMatch, D-01). Bestehenden @@index([tenantId]) behalten,
@@index([userId]) ergaenzen. Den Kopfkommentar des Modells um zwei Saetze
erweitern: Besitz liegt beim Nutzer (D-01, Phase 17), tenantId bleibt
denormalisiert.
Migration. Verzeichnis
apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/
anlegen, migration.sql von Hand schreiben (Projektentscheidung
"Migrationsverfahren angepasst": prisma migrate dev verweigert die
nicht-interaktive Shell). Erwarteter Ablauf, in dieser Reihenfolge:
- Spalte
userIdalsTEXTohne Pflicht ergaenzen. - Bestandszeilen befuellen: je Zeile den aeltesten aktiven Nutzer mit Rolle
ADMINoderSUPER_ADMINdesselben Mandanten ermitteln (sortiert nachcreatedAt, bei Gleichstand nachid,LIMIT 1) und dessenideintragen. - Zeilen, die danach immer noch keinen Besitzer haben, entfernen (kein Administrator im Mandanten vorhanden — siehe Begruendung im Objective).
- Eindeutigkeitsregel auf
tenantIdentfernen (TenderEmailConfig_tenantId_key), gewoehnlichen Index..._tenantId_idxstehen lassen. - Spalte
userIdauf Pflicht setzen, eindeutigen IndexTenderEmailConfig_userId_keyund IndexTenderEmailConfig_userId_idxanlegen.
Ueber die Anweisungen einen deutschen Kommentarkopf setzen, der erklaert, warum
umgestellt wird und warum die Bestandszeile dem aeltesten Administrator
zufaellt — gleiche Form wie
20260811140000_encrypt_ldap_bind_password/migration.sql.
Anwenden mit prisma migrate deploy gegen die lokale Datenbank, danach
prisma generate.
Dienst (tender-email-config.service.ts). getConfigForApi nimmt statt
tenantId nun userId und sucht darueber. saveConfig nimmt ein Objekt
{ userId, tenantId } als ersten Parameter; im upsert ist userId der
Schluessel, tenantId wird sowohl beim Anlegen als auch beim Aktualisieren
mitgeschrieben (der Mandant eines Nutzers kann sich aendern, das
denormalisierte Feld muss mitziehen). EMAIL_CONFIG_SAFE_SELECT um userId
ergaenzen; der verschluesselte Zugangsdaten-Block bleibt wie bisher aus jeder
Antwort ausgeschlossen. Die Semantik "Passwort leer heisst gespeichertes
Passwort behalten" bleibt unveraendert; nur die Suchbedingungen wechseln von
tenantId auf userId. Klassenkommentar entsprechend nachziehen.
Endpunkt (tenders.controller.ts). GET/PUT /email-config: die
Rollenpruefung @Roles(ADMIN, SUPER_ADMIN) entfaellt und wird durch
@UseModule('tender-radar') ersetzt — das Postfach ist ab jetzt eine
Nutzereinstellung, der Zugang haengt an der Modulfreigabe aus Phase 15.
userId und tenantId kommen weiterhin ausschliesslich aus
extractTriageContext(req), nie aus dem Rumpf der Anfrage. Die Position
beider Handler im Datei-Aufbau nicht verschieben: sie stehen vor der
Parameter-Route @Get(':id'), sonst verdeckt diese sie und die Endpunkte
antworten mit 404 (dokumentierte Falle, Unit-Tests fangen sie nicht). Die
vorhandenen Kommentare zu dieser Reihenfolge stehen lassen und den Hinweis auf
die Mandantenbindung im Kommentar auf die Nutzerbindung umschreiben.
Seite (apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx).
Neue Client-Seite, gleiche Bauform wie die vorhandene Einstellungsseite:
Ueberschrift aus dem i18n-Namensraum tenderRadar (neuer Schluessel
mySources.title, deutsch "Meine Quellen", englisch "My sources"; beide
Sprachdateien apps/web/src/messages/de.json und en.json ergaenzen),
darunter ein Abschnitt "Mein Postfach" mit dem unveraenderten bestehenden
EmailAlertConfigForm (nur importieren, nicht kopieren, nicht umbauen — es
spricht dieselben Endpunkte an). Ein kurzer Hinweistext unter der Ueberschrift
benennt ehrlich, was D-05 bedeutet: die hier eingespeisten Ausschreibungen
erscheinen in der Trefferliste aller Kollegen desselben Mandanten. Keine
Aenderung am Modul-Verzeichnis noetig — verschachtelte Route unterhalb der
bereits freigeschalteten Modulseite, gleiche Begruendung wie bei der
Einstellungsseite.
cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma validate && npx prisma migrate status
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check
cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL'
SELECT indexname FROM pg_indexes WHERE tablename = 'TenderEmailConfig' ORDER BY indexname;
SQL
Im Browser als normaler Nutzer (Rolle USER mit Modulfreigabe) /modules/tender-radar/my-sources oeffnen: die Seite laedt, das Postfach-Formular ist bedienbar, Speichern quittiert erfolgreich und nach einem Neuladen stehen die Werte wieder da. Danach mit einem zweiten Konto desselben Mandanten anmelden: dort steht ein leeres Formular, nicht die Werte des ersten Nutzers.
prisma migrate status meldet alle Migrationen angewandt. Die Indexliste zeigt
TenderEmailConfig_userId_key und enthaelt TenderEmailConfig_tenantId_key
nicht mehr. Beide Typpruefungen laufen fehlerfrei. Zwei Konten desselben
Mandanten haben je eine eigene Postfach-Zeile.
Tests email-alert.adapter.spec.ts. Bestehende Faelle auf die neue
Datenform nachziehen (Zeilen tragen jetzt zusaetzlich userId) und drei neue
Faelle ergaenzen:
- Zwei aktive Zeilen mit gleichem Mandanten, aber verschiedenen Besitzern und verschiedenen Postfachdaten: der Postfach-Anbieter wird zweimal aufgerufen, mit den jeweils eigenen Zugangsdaten, und das Ergebnis enthaelt die Kandidaten aus beiden Postfaechern.
- Von zwei Zeilen wirft die erste beim Abholen einen Fehler: das Ergebnis enthaelt trotzdem die Datensaetze der zweiten, und eine Warnung wurde protokolliert.
- Die Herkunftsmarkierung der erzeugten Datensaetze entspricht dem Mandantenfeld der jeweiligen Zeile — bei zwei Zeilen unterschiedlicher Mandanten also zwei unterschiedliche Werte.
Wichtig: Die Erwartungswerte in diesen Tests von Hand hinschreiben. Sie duerfen nicht ueber dieselbe Hilfsfunktion erzeugt werden, die auch der Produktivcode benutzt — ein Test, der seine Erwartung mit dem Pruefling selbst baut, beweist nichts (dokumentierte Anti-Pattern im Projekt).
Tests tender-email-config.service.spec.ts. Auf den neuen Schluessel
umstellen: gelesen und geschrieben wird nach Besitzer. Zwei Faelle ergaenzen:
Speichern legt beim Anlegen sowohl Besitzer als auch Mandant an; ein zweiter
Nutzer im selben Mandanten erzeugt eine zweite Zeile statt die erste zu
ueberschreiben. Der bestehende Fall, dass der verschluesselte Zugangsdatenblock
nie in der Antwort auftaucht, bleibt erhalten und muss weiterhin durchlaufen.
Neuer Test email-config-migration-sql.spec.ts. Nach dem Vorbild von
doe-url-migration-sql.spec.ts die von Hand geschriebene Migrationsdatei ohne
Datenbank pruefen, rein als Text: die Zuordnung zum Administrator kommt in der
Datei vor dem Entfernen unbesetzter Zeilen; die Auswahl beschraenkt sich auf
aktive Nutzer mit Administratorrolle desselben Mandanten und nimmt genau einen;
die alte Eindeutigkeitsregel auf dem Mandantenfeld wird entfernt und eine neue
auf dem Besitzerfeld angelegt; die Pflichtsetzung der neuen Spalte steht nach
der Befuellung.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/adapters/email-alert.adapter.spec.ts src/tenders/tender-email-config.service.spec.ts src/tenders/email-config-migration-sql.spec.ts
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders
Alle Tests im Bereich src/tenders laufen gruen. Der Fall "zwei Postfaecher
desselben Mandanten werden beide abgeholt" existiert und besteht. Der Fall "ein
kaputtes Postfach blockiert die anderen nicht" existiert und besteht. Die
Migrationsdatei wird textuell geprueft.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser -> API (/modules/tender-radar/email-config) |
Ab sofort erreicht jeder Nutzer mit Modulfreigabe diese Endpunkte, nicht mehr nur Administratoren. Besitzangaben aus der Anfrage sind grundsaetzlich nicht vertrauenswuerdig. |
API -> Datenbank (TenderEmailConfig) |
Traegt verschluesselte Postfach-Zugangsdaten. |
| Plattform-Zeitplan -> fremdes Postfach | Ausgehende Verbindung an eine vom Nutzer angegebene Adresse. |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-17-01 | Elevation of Privilege | GET/PUT /email-config |
high | mitigate | Besitzer und Mandant kommen ausschliesslich aus extractTriageContext(req); die DTO traegt kein Besitzer- und kein Mandantenfeld. Test: ein zweiter Nutzer liest nie die Zeile des ersten. |
| T-17-02 | Information Disclosure | Antwort von GET /email-config |
high | mitigate | EMAIL_CONFIG_SAFE_SELECT schliesst den verschluesselten Zugangsdatenblock weiterhin aus; die Antwort traegt nur hasPassword. Bestehender Test bleibt aktiv. |
| T-17-03 | Information Disclosure | Protokollausgabe im Abruf | medium | mitigate | Die neue Warnmeldung nennt nur Zeilen-id und Besitzer-Kennung, nie Benutzername, Passwort oder Postfachadresse. |
| T-17-04 | Denial of Service | Abruf ueber mehrere Postfaecher | medium | mitigate | Fehlerbehandlung je Postfach bleibt erhalten — ein haengendes Postfach blockiert die uebrigen nicht. Test deckt den Fall ab. |
| T-17-05 | Tampering | Hand geschriebene Migration | high | mitigate | Reihenfolge (befuellen vor loeschen, Pflicht nach Befuellung) wird textuell getestet; Anwendung zuerst nur lokal, Ausrollen auf den Testserver bleibt Nutzeraktion. |
| T-17-06 | Elevation of Privilege | Wegfall der Rollenpruefung an den Postfach-Endpunkten | medium | accept | Beabsichtigt (D-01): das Postfach ist eine Nutzereinstellung. Der Zugang bleibt ueber die Modulfreigabe aus Phase 15 gedeckelt. |
| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket — keine Lieferketten-Pruefung noetig. |
| </threat_model> |
<success_criteria>
- Zwei Nutzer desselben Mandanten haben je eine eigene Postfach-Zeile.
- Ein Nutzer sieht ueber die API ausschliesslich sein eigenes Postfach.
- Der Abruf holt in einem Durchlauf alle aktiven Postfaecher; ein Ausfall blockiert die anderen nicht.
- Eine vorhandene Postfach-Zeile hat nach der Migration einen Besitzer.
/modules/tender-radar/my-sourcesist fuer jeden Nutzer mit Modulfreigabe erreichbar und zeigt dessen Postfach.Tenderbleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05). </success_criteria>