diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f2573d8..fe2e9fa 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -609,3 +609,39 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 14. RSS, Email-Alert Ingestion & Module Rollout | 5/5 | In Progress| | | 15. Modul-Berechtigungen: Gruppen & User-Grants | 8/8 | Complete | 2026-08-04 (Bericht 15-04 am 2026-08-11 nachgezogen) | | 16. AD-Gruppen-Synchronisation | 5/5 | Complete | 2026-08-11 | +| 17. Eigene Ausschreibungs-Quellen je Nutzer | 0/3 | Planned | - | + +### Phase 17: Eigene Ausschreibungs-Quellen je Nutzer + +**Goal:** Jeder Nutzer speist seine eigenen Ausschreibungs-Quellen ein — eigenes Alert-Postfach und eigene RSS-Feeds statt einer gemeinsamen Konfiguration. Die Einstellungsseite trennt danach sauber nach Zustaendigkeit: Plattform-Administration (Abrufintervall der oeffentlichen Quelle) bleibt Admin-Sache, Quellen und Digest-Intervall gehoeren dem Nutzer. + +**Ausdruecklich NICHT in dieser Phase:** Ausschreibungsdaten bleiben plattform-global (Entscheidung D-03 aus Phase 10). Geaendert wird, WER Quellen einspeist — nicht, wer Treffer sieht. Was ueber das Postfach von Nutzer A hereinkommt, steht danach weiterhin auch in der Trefferliste von Nutzer B. Eine Sichtbarkeitstrennung waere ein deutlich groesserer Umbau und ist hier bewusst ausgeklammert. + +**Ausloeser:** Backlog-Punkt `2026-08-11-tender-radar-einstellungen-mischen-rollen.md`. Beim Durchsprechen am 2026-08-11 kam heraus, dass die urspruengliche Zustimmung zur gemeinsamen Konfiguration auf einer missverstaendlichen Erklaerung meinerseits beruhte — zwei Nutzer mit je eigenem Portal-Konto koennen ihre Alerts heute gar nicht beide anbinden. + +**Requirements**: SRC-01, SRC-02, SRC-03, SRC-04, SRC-05 — beim Planen am 2026-08-12 aus dem Backlog-Punkt und 17-CONTEXT.md abgeleitet, neue modul-lokale Kategorie SRC (Quellen-Besitz): + +- **SRC-01**: Jeder Nutzer bindet sein eigenes Alert-Postfach an; ein Mandant kann mehrere Postfaecher haben (D-01). +- **SRC-02**: RSS-Feeds haben einen Besitzer; Feeds ohne Besitzer bleiben plattformweit und von der Administration gepflegt (D-02). +- **SRC-03**: Der zeitgesteuerte Abruf holt weiterhin alle Quellen in einem Durchlauf; eine kaputte Quelle blockiert die anderen nicht. +- **SRC-04**: Nutzereigene Einstellungen (Postfach, eigene Feeds, Digest-Intervall) liegen auf einer eigenen, fuer jeden Modulnutzer erreichbaren Seite (D-04). +- **SRC-05**: Die verbleibenden Administrationseinstellungen sind fuer normale Nutzer nicht sichtbar; die Rollenpruefung greift in der Oberflaeche wie in der API (D-03). + +**Depends on:** Phase 16 +**Plans:** 3 plans + +**Success Criteria** (what must be TRUE): + + 1. Zwei Nutzer desselben Mandanten koennen gleichzeitig je ein eigenes Alert-Postfach anbinden; keiner sieht oder ueberschreibt das des anderen + 2. Ein Nutzer legt eigene RSS-Feeds an und sieht in seiner Liste nur die eigenen plus die plattformweiten; der seit Phase 14 gesetzte service.bund.de-Feed bleibt fuer alle unveraendert aktiv + 3. Kein Nutzer kann einen fremden oder plattformweiten Feed loeschen oder einen plattformweiten Feed anlegen — beides bleibt der Administration vorbehalten + 4. Der Abruf holt alle Postfaecher und Feeds in einem Durchlauf; faellt eine Quelle aus, laufen die uebrigen weiter + 5. Auf `/modules/tender-radar/my-sources` findet jeder Nutzer Postfach, eigene Feeds und Benachrichtigungsintervall an einer Stelle + 6. Auf `/modules/tender-radar/settings` bleibt nur Abrufintervall und plattformweite Feed-Pflege; ein normaler Nutzer sieht dort keine Bedienelemente mehr, die beim Speichern in eine Fehlermeldung laufen + 7. `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05 / Phase-10-D-03 unangetastet) + +Plans (Wellenstruktur — streng nacheinander, alle drei fassen Schema, Controller oder die Zugriffsschicht gemeinsam an): + +- [ ] 17-01-PLAN.md (Welle 1) — Alert-Postfach wechselt vom Mandanten zum Nutzer: Schema, handgeschriebene Migration mit Besitzer-Zuordnung, Dienst und Endpunkt, erste Fassung der Seite "Meine Quellen". Enthaelt den Entscheidungspunkt fuer beide Datenbank-Umbauten der Phase. +- [ ] 17-02-PLAN.md (Welle 2, nach 17-01) — RSS-Feeds bekommen einen Besitzer: Schema und Migration, Besitzerlogik, Schutz gegen fremdes Loeschen, Mengenbegrenzung, Herkunftsmarkierung im Abruf, angepasste Startbestueckung. +- [ ] 17-03-PLAN.md (Welle 3, nach 17-01 und 17-02) — Oberflaeche nach Zustaendigkeit trennen: "Meine Quellen" vollstaendig, Administrationsseite reduziert und rollengeprueft, Zahnrad umgehaengt, Beschriftungen in beiden Sprachen, Backlog-Punkt geschlossen. diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md new file mode 100644 index 0000000..42c42c7 --- /dev/null +++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-PLAN.md @@ -0,0 +1,398 @@ +--- +phase: 17-eigene-ausschreibungs-quellen-je-nutzer +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - 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/api/src/tenders/adapters/email-alert.adapter.ts + - apps/api/src/tenders/tender-email-config.service.spec.ts + - apps/api/src/tenders/adapters/email-alert.adapter.spec.ts + - apps/api/src/tenders/email-config-migration-sql.spec.ts + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx +autonomous: false +requirements: [SRC-01, SRC-03] +user_setup: [] + +estimate: + tokens: 60000 + raw_tokens: 60000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Zwei verschiedene Nutzer desselben Mandanten koennen gleichzeitig je ein eigenes Alert-Postfach hinterlegen (D-01)." + - "Beim Abruf werden beide Postfaecher in einem Durchlauf geleert; faellt eines aus, laufen die anderen weiter (D-01, offener Punkt 3)." + - "Was aus dem Postfach eines Nutzers hereinkommt, bleibt weiterhin auf dessen Mandanten begrenzt — die in Phase 14 gebaute Herkunftsmarkierung bleibt unveraendert (D-05)." + - "Eine bereits vorhandene Postfach-Zeile geht bei der Umstellung nicht verloren, sondern bekommt einen Besitzer (offener Punkt 1)." + - "Die Seite `/modules/tender-radar/my-sources` ist fuer jeden Nutzer mit Modulzugang erreichbar und zeigt sein eigenes Postfach." + artifacts: + - apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql + - apps/api/src/tenders/email-config-migration-sql.spec.ts + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx + key_links: + - "TenderEmailConfig.userId <-> extractTriageContext(req).userId — Besitz kommt ausschliesslich aus dem angemeldeten Konto, nie aus dem Request-Body (IDOR)." + - "TenderEmailConfig.tenantId <-> EmailAlertAdapter.extractCandidates(..., cfg.tenantId, ...) — die Herkunftsmarkierung der eingelesenen Ausschreibungen haengt weiter an diesem Feld." + - "GET/PUT /modules/tender-radar/email-config <-> Reihenfolge vor `@Get(':id')` — statische Route darf nicht von der Parameter-Route verdeckt werden." +--- + + +Das Alert-Postfach wechselt vom Mandanten zum einzelnen Nutzer. Heute gibt es +genau ein Postfach pro Mandant (`TenderEmailConfig.tenantId @unique`) — ein +zweiter Kollege mit eigenem Portal-Konto kann seine Ausschreibungs-Alarme +schlicht nicht anbinden. Nach diesem Plan hinterlegt jeder Nutzer sein eigenes +Postfach, und der Abruf holt alle hinterlegten Postfaecher in einem Durchlauf ab. + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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: + +1. **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. +2. **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. + + + + + + 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: + +1. Spalte `userId` als `TEXT` ohne Pflicht ergaenzen. +2. Bestandszeilen befuellen: je Zeile den aeltesten aktiven Nutzer mit Rolle + `ADMIN` oder `SUPER_ADMIN` desselben Mandanten ermitteln (sortiert nach + `createdAt`, bei Gleichstand nach `id`, `LIMIT 1`) und dessen `id` eintragen. +3. Zeilen, die danach immer noch keinen Besitzer haben, entfernen (kein + Administrator im Mandanten vorhanden — siehe Begruendung im Objective). +4. Eindeutigkeitsregel auf `tenantId` entfernen + (`TenderEmailConfig_tenantId_key`), gewoehnlichen Index `..._tenantId_idx` + stehen lassen. +5. Spalte `userId` auf Pflicht setzen, eindeutigen Index + `TenderEmailConfig_userId_key` und Index `TenderEmailConfig_userId_idx` + anlegen. + +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. + + + + + Task 3: Der Abruf holt alle Postfaecher — und beweist es mit Tests + + apps/api/src/tenders/adapters/email-alert.adapter.ts, + apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, + apps/api/src/tenders/tender-email-config.service.spec.ts, + apps/api/src/tenders/email-config-migration-sql.spec.ts + + + apps/api/src/tenders/adapters/email-alert.adapter.spec.ts, + apps/api/src/tenders/tender-email-config.service.spec.ts, + apps/api/src/tenders/doe-url-migration-sql.spec.ts + + + - Zwei aktive Postfaecher **desselben** Mandanten werden in einem einzigen Abruf beide geleert. Das ist genau die Faehigkeit, die es vorher nicht gab — der Test faellt ohne Task 2 durch. + - Faellt eines von zwei Postfaechern mit einem Fehler aus, liefert der Abruf trotzdem die Datensaetze des anderen und protokolliert eine Warnung. + - Die eingelesenen Datensaetze tragen weiterhin die Herkunftsmarkierung aus dem Mandantenfeld der jeweiligen Postfach-Zeile (unveraenderte Sichtbarkeit gegenueber anderen Mandanten, D-05). + - Die Sammelabfrage liest weiterhin alle aktiven Postfaecher ueber Mandantengrenzen hinweg in einem Zug — der Plattform-Zeitplan ist keine Anfrage eines einzelnen Mandanten. + - Die Migrationsdatei ordnet Bestandszeilen einem Administrator zu, bevor sie unbesetzte Zeilen entfernt. + + +**Anpassung im Abruf (`email-alert.adapter.ts`).** Inhaltlich aendert sich +nichts an der Mechanik: die Sammelabfrage bleibt exakt wie sie ist (alle +aktiven Zeilen in einem Zug), und die Herkunftsmarkierung der Datensaetze kommt +weiterhin aus dem Mandantenfeld der jeweiligen Zeile — dieses Feld existiert +nach Task 2 unveraendert. Zu aendern sind nur zwei Dinge: (a) die Warnmeldung +im Fehlerfall benennt jetzt das Postfach ueber die Zeilen-`id` und die +Besitzer-Kennung statt des Mandanten, weil ein Mandant ab sofort mehrere +Postfaecher haben kann und die alte Meldung sonst mehrdeutig waere — Zugangsdaten +duerfen dabei nach wie vor nirgends im Protokoll landen; (b) der +Klassenkommentar erklaert, dass die Auffaecherung ab Phase 17 pro Postfach und +nicht mehr pro Mandant laeuft. Die ausdrueckliche Warnung im Kommentar, dass +diese Sammelabfrage niemals in einen Mandanten-Wrapper gehoert, bleibt Wort fuer +Wort stehen. Die Fehlerbehandlung je Postfach (ein kaputtes Postfach blockiert +die anderen nicht) bleibt unangetastet. + +**Tests `email-alert.adapter.spec.ts`.** Bestehende Faelle auf die neue +Datenform nachziehen (Zeilen tragen jetzt zusaetzlich `userId`) und drei neue +Faelle ergaenzen: +1. 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. +2. Von zwei Zeilen wirft die erste beim Abholen einen Fehler: das Ergebnis + enthaelt trotzdem die Datensaetze der zweiten, und eine Warnung wurde + protokolliert. +3. 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. + + + + + + +## 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. | + + + +1. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen. +2. `pnpm --filter @tessera/api type-check` und `pnpm --filter @tessera/web type-check` — fehlerfrei. +3. `npx prisma migrate status` im API-Verzeichnis — keine offene Migration. +4. Indexliste von `TenderEmailConfig` enthaelt einen eindeutigen Index auf dem + Besitzerfeld und keinen mehr auf dem Mandantenfeld. +5. Browser-Gegenprobe mit zwei Konten desselben Mandanten (siehe `human-check` + in Task 2). + + + +- 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-sources` ist fuer jeden Nutzer mit Modulfreigabe + erreichbar und zeigt dessen Postfach. +- `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05). + + + +Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md` when done. +Im SUMMARY festhalten: Anzahl der auf dem Testserver vorgefundenen +Postfach-Zeilen und wem sie zugeordnet wurden. + diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md new file mode 100644 index 0000000..aa7ddc5 --- /dev/null +++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-PLAN.md @@ -0,0 +1,417 @@ +--- +phase: 17-eigene-ausschreibungs-quellen-je-nutzer +plan: 02 +type: execute +wave: 2 +depends_on: ["17-01"] +files_modified: + - apps/api/prisma/schema.prisma + - apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql + - apps/api/src/tenders/tender-rss-feed.service.ts + - apps/api/src/tenders/dto/tender-rss-feed.dto.ts + - apps/api/src/tenders/tenders.controller.ts + - apps/api/src/tenders/tenders.module.ts + - apps/api/src/tenders/adapters/rss.adapter.ts + - apps/api/src/tenders/tender-rss-feed.service.spec.ts + - apps/api/src/tenders/tenders.controller.spec.ts + - apps/api/src/tenders/adapters/rss.adapter.spec.ts + - apps/api/src/tenders/rss-feed-migration-sql.spec.ts +autonomous: true +requirements: [SRC-02, SRC-03] +user_setup: [] + +estimate: + tokens: 65000 + raw_tokens: 65000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Ein Nutzer legt eigene RSS-Feeds an und sieht in seiner Liste nur die eigenen plus die plattformweiten (D-02)." + - "Der seit Phase 14 gesetzte service.bund.de-Feed bleibt fuer alle unveraendert aktiv (D-02, offener Punkt 2)." + - "Ein Nutzer kann keinen fremden Feed loeschen und keinen plattformweiten Feed anlegen — beides bleibt der Administration vorbehalten." + - "Die Sperrliste und der Schutz gegen interne Adressen greifen auch auf dem neuen, fuer jeden Nutzer offenen Weg." + - "Ausschreibungen aus einem persoenlichen Feed bleiben auf den Mandanten des Besitzers begrenzt; plattformweite Feeds bleiben fuer alle sichtbar." + artifacts: + - apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql + - apps/api/src/tenders/rss-feed-migration-sql.spec.ts + key_links: + - "TenderRssFeedSource.userId <-> extractTriageContext(req).userId — Besitz kommt aus dem angemeldeten Konto, nie aus dem Anfragerumpf." + - "TenderRssFeedSource.tenantId <-> RawTenderRecord.ownerTenantId — die vorhandene Herkunftsmarkierung aus Phase 14 traegt persoenliche Feeds, plattformweite Feeds bleiben ohne Markierung." + - "TendersModule.onModuleInit() <-> Startbestueckung des service.bund.de-Feeds — muss ohne die weggefallene Eindeutigkeitsregel auf der Adresse auskommen." +--- + + +RSS-Feeds bekommen einen Besitzer. Heute gilt die Feed-Liste fuer alle, und wer +einen Feed loescht, nimmt ihn allen weg. Nach diesem Plan gibt es zwei Sorten: +plattformweite Feeds ohne Besitzer, von der Administration gepflegt und fuer +alle aktiv (dazu gehoert der seit Phase 14 gesetzte service.bund.de-Feed), und +persoenliche Feeds, die genau einem Nutzer gehoeren. + +Purpose: D-02 aus 17-CONTEXT.md umsetzen — "Wieso kann eine Person nicht mehrere +Portale abfragen als eine andere?" +Output: Erweiterte Tabelle samt Migration, Besitzerlogik im Dienst, Schutz gegen +fremdes Loeschen, angepasste Startbestueckung, Herkunftsmarkierung im Abruf. + +**Gegenlesen von D-02, wie in 17-CONTEXT.md gewuenscht.** Der Zuschnitt +"Besitzerfeld darf leer bleiben, leer heisst plattformweit" ist richtig: die +Alternative haette den seit Phase 14 laufenden Standard-Feed fuer alle +stillgelegt. Zwei technische Folgen muessen aber mitgeplant werden, sonst faellt +die Entscheidung an anderer Stelle auf die Fuesse: + +1. In PostgreSQL gelten leere Werte in einer Eindeutigkeitsregel als jeweils + verschieden. `@@unique([userId, url])` verhindert deshalb **nicht**, dass + dieselbe Adresse zweimal plattformweit angelegt wird. Das ist hinnehmbar: die + Zusammenfuehrung gleicher Ausschreibungen laeuft ohnehin ueber den + Dedup-Schluessel, ein doppelter Feed kostet also nur einen zusaetzlichen + Abruf, verdoppelt aber keine Eintraege. Absichern liesse es sich nur mit + einem Teil-Index in Roh-SQL — den kennt Prisma nicht, er wuerde bei der + naechsten Schema-Abgleichung als Abweichung erscheinen und stillschweigend + wieder entfernt werden. Deshalb bewusst nicht. +2. Aus demselben Grund laesst sich die Startbestueckung nicht mehr ueber die + Adresse als Schluessel schreiben. Sie wird auf "erst suchen, dann anlegen" + umgestellt — siehe Task 3. + +**Zusaetzliche Entscheidung (D-06, ueber 17-CONTEXT.md hinaus).** Persoenliche +Feeds tragen zusaetzlich das Mandantenfeld ihres Besitzers, und die daraus +eingelesenen Ausschreibungen bekommen dieselbe Herkunftsmarkierung, die +Postfach-Ausschreibungen seit Phase 14 schon haben. Grund: ohne das wuerde ein +persoenlicher Feed eines Nutzers Ausschreibungen in die Trefferliste **fremder +Mandanten** spuelen — heute unmoeglich, weil die Feed-Liste ausschliesslich von +der Administration gepflegt wird. Das waere keine Umsetzung von D-05, sondern +eine Ausweitung ueber den heutigen Stand hinaus. Es wird kein neues +Sichtbarkeitsmodell gebaut: benutzt wird ausschliesslich das bereits +ausgelieferte Feld aus Phase 14, mit derselben Bedeutung. Innerhalb eines +Mandanten bleibt weiterhin alles fuer alle sichtbar — genau wie mit dem Nutzer +besprochen. Plattformweite Feeds bleiben unmarkiert und damit fuer alle +sichtbar, exakt wie heute. + +**Harte Grenze (D-05) bleibt gewahrt:** `Tender` bekommt kein Mandantenfeld, +keine RLS, keinen Mandanten-Wrapper. Die Trefferliste wird nicht nach Nutzern +getrennt. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md +@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md + +@apps/api/prisma/schema.prisma +@apps/api/src/tenders/tender-rss-feed.service.ts +@apps/api/src/tenders/adapters/rss.adapter.ts +@apps/api/src/tenders/dto/tender-rss-feed.dto.ts + + + + + + Task 1: Ein persoenlicher Feed — durchgehend von der Datenbank bis zum Endpunkt + Die Migration aus Plan 17-01 ist lokal angewandt (`npx prisma migrate status` im API-Verzeichnis meldet keine offene Migration). + Die Migration ergaenzt nur Spalten ohne Pflichtwert und tauscht eine Eindeutigkeitsregel; kein Datenverlust, aber ein Rueckweg braucht eine zweite Migration. Die Freigabe hierfuer wurde bereits im Entscheidungspunkt in Plan 17-01 eingeholt, der beide Umbauten ausdruecklich benennt — deshalb hier kein zweiter Halt. + + apps/api/prisma/schema.prisma, + apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql, + apps/api/src/tenders/tender-rss-feed.service.ts, + apps/api/src/tenders/dto/tender-rss-feed.dto.ts, + apps/api/src/tenders/tenders.controller.ts + + + apps/api/src/tenders/tender-rss-feed.service.ts, + apps/api/src/tenders/dto/tender-rss-feed.dto.ts, + apps/api/src/tenders/tenders.controller.ts (Zeilen 228-270), + apps/api/prisma/migrations/20260723111946_add_rss_feed_source_and_poll_granularity/migration.sql + + + - Nutzer A legt einen Feed an; die Liste von A enthaelt diesen Feed plus alle plattformweiten. + - Die Liste von Nutzer B enthaelt den Feed von A nicht, wohl aber dieselben plattformweiten. + - Zwei verschiedene Nutzer duerfen dieselbe Adresse verfolgen; das ist kein Konflikt mehr. + - Derselbe Nutzer kann dieselbe Adresse nicht zweimal anlegen. + - Jeder Eintrag der Liste sagt, ob er plattformweit ist oder dem Nutzer gehoert. + + +**Schema (`schema.prisma`, Modell `TenderRssFeedSource`).** Zwei Felder ohne +Pflichtwert ergaenzen: `userId String?` (leer = plattformweiter Feed) und +`tenantId String?` (der Mandant des Besitzers, denormalisiert, leer bei +plattformweiten Feeds — gleiche Rolle wie das gleichnamige Feld in +`TenderEmailConfig` und `TenderMatch`). Die Eindeutigkeit auf `url` faellt weg +und wird `@@unique([userId, url])`; `@@index([userId])` ergaenzen. Den +Kopfkommentar des Modells umschreiben: die Liste ist ab Phase 17 zweigeteilt, +und leere Werte in einer Eindeutigkeitsregel gelten in PostgreSQL als jeweils +verschieden — die Regel deckt daher nur persoenliche Feeds ab (Begruendung siehe +Objective). + +**Migration.** Verzeichnis +`apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/` anlegen, +`migration.sql` von Hand schreiben. Erwarteter Ablauf: beide Spalten ohne +Pflichtwert ergaenzen, den alten eindeutigen Index auf der Adresse entfernen, +neuen eindeutigen Index ueber Besitzer und Adresse anlegen, gewoehnlichen Index +auf dem Besitzer anlegen. Bestandszeilen werden nicht angefasst — sie behalten +einen leeren Besitzer und sind damit plattformweit, also genau der heutige +Zustand (17-CONTEXT.md, offener Punkt 2). Deutscher Kommentarkopf wie bei den +anderen handgeschriebenen Migrationen. Anwenden mit `prisma migrate deploy` +gegen die lokale Datenbank, danach `prisma generate`. + +**Achtung beim erzeugten Prisma-Client:** Bei einer zusammengesetzten +Eindeutigkeitsregel mit einem Feld ohne Pflichtwert erzeugt Prisma einen +Suchtyp, in dem beide Bestandteile Pflicht sind — ein leerer Besitzer laesst +sich darueber nicht ansprechen. Nach `prisma generate` den erzeugten Typ +`TenderRssFeedSourceUserIdUrlCompoundUniqueInput` einmal aufschlagen und +bestaetigen; alles, was auf diesem Weg einen plattformweiten Feed ansprechen +will, muss ueber `findFirst`/`deleteMany` mit einer gewoehnlichen Bedingung +gehen (betrifft die Startbestueckung, Task 3). + +**Datenvertrag (`dto/tender-rss-feed.dto.ts`).** Feld `scope` ergaenzen, +optional, erlaubt nur die beiden Werte `personal` und `platform`, Vorgabe +`personal`. Kein Besitzer- und kein Mandantenfeld in der DTO — beides kommt +ausschliesslich aus dem angemeldeten Konto. Kommentar ergaenzen, dass `scope` +nur ein Wunsch ist und die Berechtigung dafuer serverseitig geprueft wird. + +**Dienst (`tender-rss-feed.service.ts`).** Die vorhandene Adresspruefung +(Schema, Sperrliste, private/lokale Adressen) bleibt **unveraendert** und +laeuft weiterhin auf jedem Schreibweg — sie wird jetzt sogar wichtiger, weil +nicht mehr nur Administratoren dorthin gelangen. Neu: +- `listForUser(userId)`: liefert alle Zeilen mit leerem Besitzer plus die des + Nutzers, sortiert nach Anlagedatum aufsteigend. +- `createForUser({ userId, tenantId }, dto)`: legt einen persoenlichen Feed an, + Besitzer und Mandant aus dem Aufruf. +- `createPlatform(dto)`: legt einen Feed ohne Besitzer und ohne Mandant an. +- Die alte `list()`/`create()`-Form entfaellt; `remove()` wird in Task 2 + ersetzt. +Klassenkommentar auf die neue Zweiteilung umschreiben. + +**Endpunkte (`tenders.controller.ts`).** Die drei RSS-Routen behalten **exakt +ihre bisherige Position im Datei-Aufbau** — 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). Es werden bewusst **keine +neuen Routen** angelegt, damit an dieser Reihenfolge nichts zu bewegen ist; die +Unterscheidung laeuft ueber `scope` im Rumpf. +- `GET /rss-feeds`: `@Roles(...)` entfaellt, `@UseModule('tender-radar')` tritt + an die Stelle. Ruft `listForUser(userId)` und ergaenzt je Eintrag ein + abgeleitetes Kennzeichen, ob der Eintrag plattformweit ist. Das Besitzerfeld + selbst braucht die Oberflaeche nicht — es aus der Antwort herausnehmen. +- `POST /rss-feeds`: `@Roles(...)` entfaellt, `@UseModule('tender-radar')` tritt + an die Stelle. Bei `scope: 'platform'` zuerst pruefen, ob die Rolle des + angemeldeten Kontos ADMIN oder SUPER_ADMIN ist — sonst + `ForbiddenException`. Andernfalls persoenlich anlegen. +Die Rolle aus demselben Ort lesen, aus dem sie auch die vorhandene +Rollenpruefung liest (`roles.guard.ts` vorher aufschlagen und den Zugriffsweg +uebernehmen). Dazu `extractTriageContext` um die Rolle erweitern, damit es +genau eine Stelle gibt, an der Kontodaten aus der Anfrage gelesen werden. + + + 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 + cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL' +SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'TenderRssFeedSource' ORDER BY indexname; +SQL + + +Die Indexliste zeigt einen eindeutigen Index ueber Besitzer und Adresse und +keinen eindeutigen Index mehr allein auf der Adresse. Die Typpruefung laeuft +fehlerfrei. Bestandszeilen haben einen leeren Besitzer. + + + + + Task 2: Niemand fasst fremde Feeds an — Loeschschutz und Mengenbegrenzung + + apps/api/src/tenders/tender-rss-feed.service.ts, + apps/api/src/tenders/tenders.controller.ts, + apps/api/src/tenders/tender-rss-feed.service.spec.ts, + apps/api/src/tenders/tenders.controller.spec.ts + + + apps/api/src/tenders/tender-rss-feed.service.spec.ts, + apps/api/src/tenders/tenders.controller.spec.ts (Zeilen 1000-1050) + + + - Nutzer A loescht seinen eigenen Feed: erfolgreich. + - Nutzer A versucht den Feed von Nutzer B zu loeschen: der Feed bleibt bestehen, die Antwort ist "nicht gefunden" und verraet damit nicht, dass es ihn gibt. + - Nutzer A versucht einen plattformweiten Feed zu loeschen: bleibt bestehen, "nicht gefunden". + - Ein Administrator loescht einen plattformweiten Feed: erfolgreich. + - Ein Administrator loescht **nicht** ungefragt den persoenlichen Feed eines beliebigen Nutzers ueber diesen Weg. + - Ein Nutzer mit bereits 20 eigenen Feeds bekommt beim 21. eine verstaendliche Fehlermeldung statt eines Eintrags. + - Eine gesperrte oder interne Adresse wird auch auf dem persoenlichen Weg abgelehnt. + + +**Dienst.** `remove(id, { userId, isAdmin })` ersetzt die bisherige +`remove(id)`-Form. Geloescht wird in einer einzigen Anweisung ueber eine +Mengenloeschung mit Bedingung: die Kennung stimmt **und** entweder gehoert der +Eintrag dem Aufrufer, oder der Aufrufer ist Administrator und der Eintrag hat +keinen Besitzer. Kommt dabei nichts zurueck, `NotFoundException` werfen — nicht +`ForbiddenException`, damit die Antwort nicht verraet, dass ein fremder Eintrag +mit dieser Kennung existiert. Bewusst als eine Anweisung statt "erst lesen, dann +loeschen": kein Zeitfenster zwischen Pruefung und Ausfuehrung, und der +Besitzvergleich liegt in der Datenbankbedingung, nicht im Anwendungscode. + +Ausserdem im Dienst eine Obergrenze fuer persoenliche Feeds einfuehren +(Konstante, 20). `createForUser` zaehlt vorher die eigenen Eintraege des Nutzers +und lehnt darueber hinaus mit einer verstaendlichen deutschen Meldung ab +(`BadRequestException`). Grund: Der Anlegeweg steht ab dieser Phase jedem +Modulnutzer offen und loest bei jedem Abruf eine ausgehende Verbindung je Feed +aus — ohne Deckel waere das ein bequemer Hebel, den Plattform-Zeitplan +zuzustellen. Plattformweite Feeds sind davon nicht betroffen. + +**Endpunkt.** `DELETE /rss-feeds/:feedId`: `@Roles(...)` entfaellt, +`@UseModule('tender-radar')` tritt an die Stelle; Besitzer und Rolle kommen aus +dem angemeldeten Konto und werden an den Dienst durchgereicht. Der +Parametername `:feedId` bleibt wie er ist (er darf nie mit der +Ausschreibungs-Kennung verwechselt werden), und die Position der Route im +Datei-Aufbau bleibt unveraendert. + +**Tests.** In `tender-rss-feed.service.spec.ts` einen Prisma-Doppelgaenger +benutzen, der die uebergebene Bedingung **tatsaechlich auswertet** (Vorbild: +der auswertende Doppelgaenger in `tenders.controller.spec.ts` rund um Zeile +1012) — ein Doppelgaenger, der die Bedingung ignoriert und einfach einen Erfolg +zurueckmeldet, wuerde den Loeschschutz nur vortaeuschen. Alle sieben Faelle aus +dem Abschnitt "behavior" abdecken, dazu die bestehenden Faelle zur +Adresspruefung (Sperrliste, private Adressen, falsches Schema) unveraendert +weiterlaufen lassen und einen davon zusaetzlich ueber den persoenlichen +Anlegeweg fuehren. + +In `tenders.controller.spec.ts` zwei Faelle ergaenzen: ein Konto mit Rolle USER +bekommt beim Anlegen mit `scope: 'platform'` eine Ablehnung; ein Konto mit +Rolle ADMIN darf es. Erwartungswerte von Hand hinschreiben, nicht aus derselben +Hilfsfunktion erzeugen, die auch der Produktivcode benutzt. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/tender-rss-feed.service.spec.ts src/tenders/tenders.controller.spec.ts + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check + + +Alle sieben Verhaltensfaelle bestehen. Der Loeschversuch an einem fremden Feed +laesst den Eintrag nachweislich stehen und antwortet mit "nicht gefunden". Der +21. persoenliche Feed wird abgelehnt. + + + + + Task 3: Abruf und Startbestueckung ziehen nach + + apps/api/src/tenders/adapters/rss.adapter.ts, + apps/api/src/tenders/tenders.module.ts, + apps/api/src/tenders/adapters/rss.adapter.spec.ts, + apps/api/src/tenders/rss-feed-migration-sql.spec.ts + + + apps/api/src/tenders/adapters/rss.adapter.ts, + apps/api/src/tenders/tenders.module.ts (Zeilen 265-305), + apps/api/src/tenders/adapters/rss.adapter.spec.ts, + apps/api/src/tenders/doe-url-migration-sql.spec.ts + + + - Der Abruf holt weiterhin alle aktiven Feeds in einem Durchlauf, plattformweite und persoenliche gemeinsam. + - Ein kaputter Feed wird uebersprungen und blockiert die uebrigen nicht. + - Datensaetze aus einem Feed mit Mandantenangabe tragen die Herkunftsmarkierung dieses Mandanten. + - Datensaetze aus einem plattformweiten Feed tragen keine Herkunftsmarkierung und bleiben damit fuer alle sichtbar — wie heute. + - Ein zweiter Start der Anwendung legt den service.bund.de-Feed nicht ein zweites Mal an. + - Der Feed aus der Startbestueckung hat keinen Besitzer. + + +**Abruf (`rss.adapter.ts`).** Die Sammelabfrage ueber alle aktiven Feeds bleibt +unveraendert — der Plattform-Zeitplan loest weiterhin alle Feeds in einem +Durchlauf auf, und die Fehlerbehandlung je Feed bleibt bestehen. Neu ist +ausschliesslich: hat die Feed-Zeile eine Mandantenangabe, bekommen die daraus +erzeugten Datensaetze dieselbe Herkunftsmarkierung, die Postfach-Datensaetze +seit Phase 14 tragen; hat sie keine, bleiben die Datensaetze unmarkiert. Die +reine Umwandlungsfunktion `parseFeed` nicht anfassen — sie kennt keine +Feed-Zeile, die Markierung gehoert in die Auffaecherung darueber. +Klassenkommentar um zwei Saetze zu dieser Zweiteilung ergaenzen und darin auf +D-06 aus 17-02-PLAN verweisen. + +**Startbestueckung (`tenders.module.ts`).** Das bisherige +"anlegen-oder-aktualisieren ueber die Adresse" funktioniert nicht mehr, weil die +Eindeutigkeit jetzt aus Besitzer und Adresse besteht und ein leerer Besitzer +ueber diesen Weg nicht ansprechbar ist (siehe Hinweis in Task 1). Umstellen auf: +erst suchen, ob bereits eine Zeile mit leerem Besitzer und dieser Adresse +existiert; nur wenn nicht, anlegen — ohne Besitzer und ohne Mandant, sonst +unveraendert (aktiv, Bezeichnung `service-bund`). Das bleibt bei wiederholten +Starts folgenlos. Kommentar ergaenzen, warum der Weg gewechselt hat. + +**Tests `rss.adapter.spec.ts`.** Bestehende Faelle auf die neue Zeilenform +nachziehen und drei ergaenzen: ein Feed mit Mandantenangabe erzeugt ausschliesslich +markierte Datensaetze; ein Feed ohne Mandantenangabe erzeugt ausschliesslich +unmarkierte; zwei Feeds unterschiedlicher Besitzer werden in einem Durchlauf +beide abgeholt, und faellt der erste aus, kommen die Datensaetze des zweiten +trotzdem an. Erwartungswerte von Hand hinschreiben. + +**Neuer Test `rss-feed-migration-sql.spec.ts`.** Nach dem Vorbild von +`doe-url-migration-sql.spec.ts` die handgeschriebene Migrationsdatei rein +textuell pruefen: beide neuen Spalten werden ohne Pflichtwert ergaenzt (sonst +wuerde die Migration an Bestandszeilen scheitern); der alte eindeutige Index auf +der Adresse wird entfernt; ein eindeutiger Index ueber Besitzer und Adresse +entsteht; es gibt keine Anweisung, die Bestandszeilen aendert oder loescht. + +**Startbestueckung testen.** In der bestehenden Testdatei zur Bestueckung (oder, +falls sie dafuer nicht passt, in `tender-rss-feed.service.spec.ts`) einen Fall +ergaenzen, der die Bestueckung zweimal hintereinander ausfuehrt und zeigt, dass +danach genau eine Zeile existiert und diese keinen Besitzer hat. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/adapters/rss.adapter.spec.ts src/tenders/rss-feed-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 "persoenlicher Feed +erzeugt markierte Datensaetze" und der Fall "plattformweiter Feed erzeugt +unmarkierte Datensaetze" bestehen beide. Die Bestueckung ist bei zweimaligem +Lauf folgenlos. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser -> API (`/modules/tender-radar/rss-feeds`) | Der Anlegeweg fuer Feed-Adressen steht ab dieser Phase jedem Modulnutzer offen, nicht mehr nur Administratoren. | +| API -> beliebige Internetadresse | Der Abruf holt Inhalte von Adressen, die ein Nutzer eingetragen hat. | +| Nutzer A -> Datensatz von Nutzer B | Loeschen und Lesen ueber eine Kennung aus der Anfrage. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-17-07 | Elevation of Privilege | `DELETE /rss-feeds/:feedId` | high | mitigate | Mengenloeschung mit Besitzbedingung in einer einzigen Anweisung; nichts geloescht heisst "nicht gefunden". Test mit auswertendem Prisma-Doppelgaenger. | +| T-17-08 | Elevation of Privilege | `POST /rss-feeds` mit `scope: 'platform'` | high | mitigate | Rollenpruefung im Handler gegen dieselbe Quelle, aus der auch die vorhandene Rollenpruefung liest; Test mit Rolle USER und Rolle ADMIN. | +| T-17-09 | Tampering / SSRF | Adresspruefung im Dienst | high | mitigate | Die bestehende Pruefung (Schema, Sperrliste, private und lokale Adressen) bleibt unveraendert und laeuft auf beiden Anlegewegen; ein Test fuehrt einen Sperrfall ausdruecklich ueber den persoenlichen Weg. | +| T-17-10 | Denial of Service | Anzahl persoenlicher Feeds | medium | mitigate | Obergrenze 20 je Nutzer im Dienst, mit verstaendlicher Ablehnung. Die vorhandene Groessen- und Zeitbegrenzung je Abruf bleibt bestehen. | +| T-17-11 | Information Disclosure | Ausschreibungen aus persoenlichen Feeds | high | mitigate | Herkunftsmarkierung aus dem Mandanten des Besitzers (D-06) — verhindert, dass ein persoenlicher Feed in die Trefferliste fremder Mandanten spuelt. Zwei Tests. | +| T-17-12 | Information Disclosure | Antwort von `GET /rss-feeds` | low | mitigate | Die Antwort enthaelt nur plattformweite Eintraege und die eigenen; das Besitzerfeld wird aus der Antwort herausgenommen. | +| T-17-13 | Repudiation | Doppelter plattformweiter Feed | low | accept | Leere Werte gelten in einer Eindeutigkeitsregel als verschieden; ein doppelter Feed kostet nur einen zusaetzlichen Abruf, die Zusammenfuehrung gleicher Ausschreibungen faengt Doppel ab (Begruendung im Objective). | +| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket. | + + + +1. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen. +2. `pnpm --filter @tessera/api type-check` — fehlerfrei. +3. `npx prisma migrate status` — keine offene Migration. +4. Indexliste von `TenderRssFeedSource`: eindeutiger Index ueber Besitzer und + Adresse vorhanden, eindeutiger Index allein auf der Adresse verschwunden. +5. Zaehlabfrage: alle Bestandszeilen haben einen leeren Besitzer, der + service.bund.de-Feed ist genau einmal vorhanden und aktiv. + + + +- Zwei Nutzer koennen dieselbe Feed-Adresse unabhaengig voneinander verfolgen. +- Kein Nutzer kann einen fremden oder plattformweiten Feed loeschen. +- Kein Nutzer ohne Administratorrolle kann einen plattformweiten Feed anlegen. +- Der service.bund.de-Feed bleibt unveraendert fuer alle aktiv. +- Ausschreibungen aus persoenlichen Feeds bleiben auf den Mandanten des + Besitzers begrenzt; aus plattformweiten Feeds bleiben sie fuer alle sichtbar. +- `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05). + + + +Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-SUMMARY.md` when done. + diff --git a/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md new file mode 100644 index 0000000..8b67c1e --- /dev/null +++ b/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-PLAN.md @@ -0,0 +1,363 @@ +--- +phase: 17-eigene-ausschreibungs-quellen-je-nutzer +plan: 03 +type: execute +wave: 3 +depends_on: ["17-01", "17-02"] +files_modified: + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx + - apps/web/src/app/(portal)/modules/tender-radar/page.tsx + - apps/web/src/lib/tender-radar-api.ts + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json + - .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md +autonomous: true +requirements: [SRC-04, SRC-05] +user_setup: [] + +estimate: + tokens: 55000 + raw_tokens: 55000 + tasks: 3 + confidence: low + +must_haves: + truths: + - "Auf `/modules/tender-radar/my-sources` findet ein normaler Nutzer alles, was ihm gehoert: sein Postfach, seine Feeds, sein Benachrichtigungsintervall (D-01, D-02, D-04)." + - "Auf `/modules/tender-radar/settings` bleibt nur Administrationskram: Abrufintervall der oeffentlichen Quelle und die plattformweiten Feeds (D-03)." + - "Ein normaler Nutzer sieht auf der Administrationsseite keine Bedienelemente mehr, die beim Speichern in eine Fehlermeldung laufen (offener Punkt 5)." + - "Das Zahnrad auf der Modulseite fuehrt jeden Nutzer dorthin, wo er etwas einstellen darf." + - "Alle neuen Beschriftungen liegen in beiden Sprachdateien vor." + artifacts: + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx + - apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx + - apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx + key_links: + - "RssFeedListForm(scope) <-> POST /rss-feeds mit `scope` — dieselbe Komponente bedient beide Seiten, die Berechtigung entscheidet der Server." + - "useAuthStore().user.role <-> Sichtbarkeit der Administrationsabschnitte — reine Anzeigefrage, die verbindliche Pruefung bleibt im Server." + - "Zahnrad in tender-radar/page.tsx <-> /modules/tender-radar/my-sources — der Weg, den jeder Nutzer gehen darf." +--- + + +Die Oberflaeche wird nach Zustaendigkeit getrennt. Bisher stehen vier Abschnitte +untereinander auf einer Seite, die drei verschiedenen Zustaendigkeiten gehoeren — +wer nur seine Benachrichtigungen auf woechentlich stellen will, scrollt an +Abrufintervallen, Feed-Adressen und Postfach-Zugangsdaten vorbei und sieht +Bedienelemente, die er gar nicht bedienen darf. + +Nach diesem Plan gibt es zwei Seiten: + +- **Meine Quellen** (`/modules/tender-radar/my-sources`, fuer jeden mit + Modulzugang): mein Postfach, meine Feeds, mein Benachrichtigungsintervall. +- **Einstellungen** (`/modules/tender-radar/settings`, nur Administration): + Abrufintervall der oeffentlichen Quelle und die plattformweiten Feeds. + +Purpose: D-03, D-04 und den urspruenglichen Anlass des Backlog-Punkts abschliessen. +Output: Vollstaendige Nutzerseite, reduzierte und rollengepruefte +Administrationsseite, angepasstes Zahnrad, Beschriftungen in beiden Sprachen. + +**Entscheidung zum offenen Punkt 5 (Rollenpruefung).** Die Administrationsseite +prueft die Rolle des angemeldeten Kontos aus dem bereits vorhandenen +Anmelde-Speicher im Browser und zeigt einem normalen Nutzer statt der +Bedienelemente einen kurzen Satz mit einem Verweis auf "Meine Quellen". +Begruendung: Die verbindliche Absicherung liegt und bleibt im Server — die +Endpunkte fuer das Abrufintervall und fuer plattformweite Feeds sind dort +rollengeprueft. Die Pruefung im Browser ist ausschliesslich eine Anzeigefrage: +sie verhindert, dass jemand Knoepfe sieht, die beim Speichern in eine +Fehlermeldung laufen. Genau das war der Anlass des Backlog-Punkts. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-CONTEXT.md +@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-01-SUMMARY.md +@.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-SUMMARY.md +@.planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md + +@apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx +@apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx +@apps/web/src/lib/tender-radar-api.ts +@apps/web/src/lib/stores/auth-store.ts + + + + + + Task 1: "Meine Quellen" wird vollstaendig — Postfach, eigene Feeds, Benachrichtigung + + apps/web/src/lib/tender-radar-api.ts, + apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx, + apps/web/src/app/(portal)/modules/tender-radar/settings/components/DigestIntervalForm.tsx, + apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx, + apps/web/src/messages/de.json, + apps/web/src/messages/en.json + + + apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx, + apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx, + apps/web/src/lib/tender-radar-api.ts (Zeilen 370-560) + + + - Auf "Meine Quellen" stehen drei Abschnitte untereinander: Mein Postfach, Meine Feeds, Benachrichtigung. + - Die Feed-Liste dort zeigt ausschliesslich die eigenen Feeds; plattformweite Feeds erscheinen darunter als kurze, nicht bedienbare Aufzaehlung mit dem Hinweis, dass die Administration sie pflegt. + - Ein neu angelegter Feed erscheint sofort in der eigenen Liste. + - Zu einem plattformweiten Feed gibt es auf dieser Seite keinen Entfernen-Knopf. + - Die Auswahl des Benachrichtigungsintervalls speichert und quittiert wie bisher. + + +**Zugriffsschicht (`lib/tender-radar-api.ts`).** Der Typ `RssFeedSource` bekommt +das vom Server gelieferte Kennzeichen, ob der Eintrag plattformweit ist. +`createRssFeed` bekommt einen zweiten Parameter fuer den Wirkungsbereich +(`personal` oder `platform`, Vorgabe `personal`), der in den Rumpf der Anfrage +wandert. `listRssFeeds` und `deleteRssFeed` bleiben in ihrer Signatur, ihr +Verhalten aendert sich serverseitig. + +**Feed-Liste (`RssFeedListForm.tsx`).** Die Komponente bekommt eine +Eigenschaft `scope` mit den Werten `personal` und `platform`. Sie laedt +weiterhin die eine Liste vom Server und teilt sie selbst auf: +- `scope="personal"`: bearbeitbar sind die eigenen Eintraege (anlegen, + entfernen). Die plattformweiten Eintraege erscheinen darunter als schlichte + Aufzaehlung ohne Knoepfe, mit einem Satz, dass die Administration sie pflegt — + so ist auf einen Blick klar, dass niemand bei null anfaengt. +- `scope="platform"`: bearbeitbar sind die plattformweiten Eintraege; angelegt + wird mit dem entsprechenden Wirkungsbereich. Eigene Eintraege anderer Nutzer + tauchen hier gar nicht auf, weil der Server sie nicht liefert. +Die Fehleranzeige bleibt wie sie ist: eine abgelehnte Adresse zeigt weiterhin +die ausfuehrliche Meldung des Servers, nicht einen selbstgebauten Ersatztext. +Die Ablehnung wegen erreichter Obergrenze (aus Plan 17-02) laeuft ueber denselben +Weg und braucht keine Sonderbehandlung. + +**Benachrichtigungsintervall (`DigestIntervalForm.tsx`).** Den heute in der +Einstellungsseite eingebetteten Block (Laden beim Aufbau, Auswahlfeld +Taeglich/Woechentlich/Aus, Speichern bei Aenderung, Erfolgs- und Fehlermeldung) +unveraendert in eine eigene Komponente herausloesen. Kein Verhalten aendern, +keine neuen Beschriftungsschluessel — nur verschieben, damit beide Seiten +dieselbe Umsetzung benutzen koennen und die Einstellungsseite in Task 2 sauber +kleiner wird. + +**Seite (`my-sources/page.tsx`).** Die in Plan 17-01 angelegte Seite um zwei +Abschnitte erweitern: unter "Mein Postfach" folgt "Meine Feeds" +(`RssFeedListForm` mit `scope="personal"`), darunter "Benachrichtigung" +(`DigestIntervalForm`). Aufbau, Abstaende und Trennlinien wie auf der heutigen +Einstellungsseite uebernehmen, damit nichts fremd wirkt. Fuer Konten mit +Administratorrolle unten ein Verweis auf die Administrationsseite; fuer alle +anderen erscheint dieser Verweis nicht. Die Rolle kommt aus dem vorhandenen +Anmelde-Speicher; solange dieser noch nicht befuellt ist, den Verweis +weglassen statt kurz aufblitzen zu lassen. + +**Beschriftungen (`messages/de.json`, `messages/en.json`).** Alle neuen +Schluessel im Namensraum `tenderRadar` unter `mySources` anlegen und in beiden +Dateien pflegen: Seitentitel, die drei Abschnittsueberschriften, der ehrliche +Hinweis zu D-05 (Ausschreibungen aus den eigenen Quellen erscheinen auch bei +Kollegen desselben Mandanten), der Satz zu den von der Administration +gepflegten Feeds, der Verweis auf die Administrationsseite. Keine Beschriftung +nur auf Deutsch anlegen. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check + cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')" + Als normaler Nutzer `/modules/tender-radar/my-sources` oeffnen: drei Abschnitte sind sichtbar. Einen eigenen RSS-Feed anlegen — er erscheint in der eigenen Liste, der plattformweite service.bund.de-Feed steht darunter ohne Entfernen-Knopf. Das Benachrichtigungsintervall auf "Woechentlich" stellen, Seite neu laden, Wert steht noch. Kein Verweis auf die Administrationsseite sichtbar. + + +Die Typpruefung laeuft fehlerfrei, beide Sprachdateien decken denselben +Schluesselsatz ab. Ein normaler Nutzer kann auf einer Seite Postfach, eigene +Feeds und Benachrichtigungsintervall pflegen und sieht keinen Knopf, der ihm +verwehrt ist. + + + + + Task 2: Die Administrationsseite schrumpft auf das, was Administration ist + + apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx, + apps/web/src/app/(portal)/modules/tender-radar/page.tsx, + apps/web/src/messages/de.json, + apps/web/src/messages/en.json + + + apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx, + apps/web/src/app/(portal)/modules/tender-radar/page.tsx (Zeilen 60-95), + apps/web/src/lib/stores/auth-store.ts + + + - Ein Konto mit Rolle ADMIN oder SUPER_ADMIN sieht auf der Einstellungsseite genau zwei Abschnitte: Abrufintervall und plattformweite Feeds. + - Ein Konto mit Rolle USER sieht dort keinen dieser Abschnitte, sondern einen kurzen Satz und einen Verweis auf "Meine Quellen". + - Solange die Kontodaten im Browser noch nicht geladen sind, erscheint weder das eine noch das andere, sondern ein Platzhalter — kein kurzes Aufblitzen der Administrationsabschnitte. + - Postfach und Benachrichtigungsintervall kommen auf dieser Seite nicht mehr vor. + - Das Zahnrad auf der Modulseite fuehrt fuer jeden Nutzer nach "Meine Quellen". + + +**Einstellungsseite (`settings/page.tsx`).** Die Abschnitte "E-Mail-Alerts" und +"Benachrichtigungen" ersatzlos herausnehmen — sie leben ab jetzt auf "Meine +Quellen" (D-01, D-04). Uebrig bleiben das Abrufintervall (`SourceConfigForm`, +D-03) und die plattformweiten Feeds (`RssFeedListForm` mit `scope="platform"`). +Der ganze Inhalt wird von einer Rollenpruefung umschlossen: Rolle aus dem +vorhandenen Anmelde-Speicher lesen; ist sie noch nicht bekannt, einen +Ladeplatzhalter zeigen; ist sie ADMIN oder SUPER_ADMIN, den Inhalt zeigen; sonst +einen kurzen Satz ("Diese Seite verwaltet plattformweite Einstellungen und ist +Administratoren vorbehalten.") mit einem Verweis auf `/modules/tender-radar/my-sources`. +Der Kopfkommentar der Datei wird auf den neuen Zuschnitt umgeschrieben; die +Absaetze zu den entfernten Abschnitten entfallen, ein Absatz zu Phase 17 kommt +hinzu. Beim Abschnitt der plattformweiten Feeds einen Satz ergaenzen, dass diese +fuer alle Nutzer gelten und persoenliche Feeds woanders liegen. + +**Modulseite (`page.tsx`).** Das Zahnrad zeigt heute auf die +Administrationsseite. Es zeigt kuenftig auf `/modules/tender-radar/my-sources` — +das ist die Seite, die jeder Nutzer aufrufen darf, und Administratoren gehen von +dort mit einem Klick weiter. Beschriftung und Vorlesetext des Knopfes +entsprechend anpassen (neuer Schluessel, alter Schluessel bleibt fuer den Verweis +auf der Nutzerseite in Gebrauch, falls passend — sonst entfernen). Den +vorhandenen Kommentar zum absoluten Verweis (die dynamische Einstellungsroute +greift fuer dieses Modul nicht) beibehalten und auf den neuen Zielpfad +anpassen. + +**Beschriftungen.** Neue Schluessel in beiden Sprachdateien anlegen: der +Hinweistext fuer Nutzer ohne Administratorrolle, der Verweistext auf "Meine +Quellen", der Zusatzsatz beim Abschnitt der plattformweiten Feeds, die neue +Zahnrad-Beschriftung. Nicht mehr benutzte Schluessel (`settings.emailSectionTitle`, +`settings.emailSectionBody`, `settings.notificationsSectionTitle` und die +Digest-Schluessel, sofern sie auf die Nutzerseite umziehen) erst entfernen, +nachdem im gesamten `apps/web/src` nachgesehen wurde, dass sie nirgends mehr +verwendet werden. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web type-check + cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')" + Als Konto mit Rolle USER `/modules/tender-radar/settings` direkt in der Adresszeile aufrufen: es erscheinen keine Bedienelemente, sondern der Hinweis samt Verweis. Danach als Administrator dieselbe Adresse: Abrufintervall und plattformweite Feeds sind da, Postfach und Benachrichtigungsintervall nicht mehr. Auf der Modulseite fuehrt das Zahnrad in beiden Faellen nach "Meine Quellen". + + +Die Einstellungsseite traegt nur noch Abrufintervall und plattformweite Feeds +und ist fuer Konten ohne Administratorrolle leer bis auf Hinweis und Verweis. +Das Zahnrad fuehrt jeden Nutzer auf eine Seite, auf der er etwas einstellen darf. +Beide Sprachdateien decken denselben Schluesselsatz ab. + + + + + Task 3: Tests fuer beide Seiten und der Backlog-Punkt wird zugemacht + + apps/web/src/app/(portal)/modules/tender-radar/my-sources/my-sources.test.tsx, + apps/web/src/app/(portal)/modules/tender-radar/settings/settings-roles.test.tsx, + apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx, + .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md + + + apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx, + apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx + + + - `RssFeedListForm` mit `scope="personal"`: die gelieferte Liste enthaelt einen plattformweiten und einen eigenen Eintrag; nur der eigene traegt einen Entfernen-Knopf, der plattformweite erscheint ohne. + - `RssFeedListForm` mit `scope="platform"`: umgekehrt. + - `RssFeedListForm` mit `scope="personal"` schickt beim Anlegen den Wirkungsbereich `personal` mit, mit `scope="platform"` entsprechend `platform`. + - Einstellungsseite mit Rolle USER: die Ueberschriften der Administrationsabschnitte kommen nicht vor, der Verweis auf "Meine Quellen" schon. + - Einstellungsseite mit Rolle ADMIN: die Administrationsabschnitte kommen vor. + - Einstellungsseite ohne geladene Kontodaten: weder das eine noch das andere. + - "Meine Quellen": alle drei Abschnittsueberschriften kommen vor; der Verweis auf die Administrationsseite nur bei Rolle ADMIN. + + +**Testaufbau.** Vorbild sind die vorhandenen Tests im selben Verzeichnis: die +Zugriffsschicht `@/lib/tender-radar-api` wird ersetzt, `next-intl` wird durch +eine Zuordnung von Schluessel auf Text ersetzt. Neu hinzu kommt das Ersetzen des +Anmelde-Speichers, damit die Rolle im Test gesetzt werden kann — dieselbe +Ersetzungsform wie bei den uebrigen Ersetzungen, ueber den Modulpfad des +Speichers. + +**Wichtig zur Aussagekraft.** Die Pruefungen muessen an dem haengen, was der +Nutzer sieht: vorhandene beziehungsweise fehlende Beschriftungen und Knoepfe im +gerenderten Ergebnis. Nicht pruefen, ob eine Komponente "aufgerufen wurde" — +und die erwarteten Texte im Test woertlich hinschreiben, nicht aus derselben +Zuordnung ableiten, die auch die Komponente benutzt. Ein Test, der seine +Erwartung mit dem Pruefling selbst baut, beweist nichts. + +**`RssFeedListForm.test.tsx`.** Bestehende Faelle auf die neue Eigenschaft +`scope` nachziehen (Vorgabe im Test ausdruecklich setzen, nicht raten) und die +drei Faelle aus dem Abschnitt "behavior" ergaenzen. Der bestehende Fall, dass die +ausfuehrliche Servermeldung bei einer gesperrten Adresse angezeigt wird, muss +unveraendert weiterlaufen. + +**`settings-roles.test.tsx` (neu).** Die drei Rollenfaelle der +Einstellungsseite. Zusaetzlich ein Fall, der belegt, dass die Ueberschriften der +entfernten Abschnitte (Postfach, Benachrichtigung) auf dieser Seite nicht mehr +vorkommen. + +**`my-sources.test.tsx` (neu).** Die drei Abschnitte sind da; der Verweis auf +die Administrationsseite erscheint nur bei Administratorrolle. + +**Backlog-Punkt.** `2026-08-11-tender-radar-einstellungen-mischen-rollen.md` +nach `.planning/todos/completed/` verschieben und im Kopf einen kurzen Abschnitt +ergaenzen, wie er geloest wurde: nicht nur die Rollenpruefung aus der +urspruenglichen Skizze, sondern der Umbau auf nutzereigene Quellen, und die +Grundsatzfrage aus Punkt 2 ist mit "eigene nutzerseitige Modulseite" beantwortet +— das ist ab jetzt die Bauform, an der sich DKV-Fleet und kuenftige Module +orientieren. + + + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run + cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check + test ! -f .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md && test -f .planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md + + +Alle Tests in Web und API laufen gruen, beide Typpruefungen sind sauber, der +Backlog-Punkt liegt im Ordner der erledigten Punkte. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Browser-Anzeige -> Serverpruefung | Die Rollenpruefung im Browser ist eine Anzeigefrage; die verbindliche Pruefung liegt im Server. | +| Nutzerseite -> Endpunkte fuer plattformweite Einstellungen | Die Nutzerseite darf keinen Weg zu plattformweiten Schreibvorgaengen oeffnen. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-17-14 | Elevation of Privilege | Rollenpruefung im Browser | medium | mitigate | Die Pruefung blendet nur Bedienelemente aus. Die Endpunkte fuer Abrufintervall und plattformweite Feeds bleiben serverseitig rollengeprueft (Plan 17-02, T-17-08). Ein Test belegt, dass die Rolle USER die Abschnitte nicht zu sehen bekommt. | +| T-17-15 | Elevation of Privilege | `RssFeedListForm` mit `scope="personal"` | high | mitigate | Der Wirkungsbereich wandert als Wunsch in die Anfrage; ob er gewaehrt wird, entscheidet ausschliesslich der Server. Ein manipuliertes Frontend erreicht nichts. | +| T-17-16 | Information Disclosure | Kurzes Aufblitzen der Administrationsabschnitte vor dem Laden der Kontodaten | low | mitigate | Solange die Rolle unbekannt ist, wird ein Platzhalter gezeigt; ein Test deckt genau diesen Zustand ab. | +| T-17-17 | Repudiation | Unvollstaendige Uebersetzung | low | mitigate | Ein Pruefschritt vergleicht die Schluesselsaetze beider Sprachdateien und bricht bei Abweichung ab. | +| T-17-SC | Tampering | Paketinstallationen | low | accept | Diese Phase installiert kein neues Paket. | + + + +1. `pnpm --filter @tessera/web exec vitest run` — vollstaendig gruen. +2. `pnpm --filter @tessera/api exec vitest run src/tenders` — vollstaendig gruen. +3. Beide Typpruefungen fehlerfrei. +4. Schluesselvergleich der beiden Sprachdateien ohne Abweichung. +5. Browser-Gegenprobe mit zwei Konten (Rolle USER und Rolle ADMIN) wie in den + `human-check`-Schritten der Tasks 1 und 2. Eine erfolgreiche Antwort des + Servers allein reicht als Nachweis nicht — es zaehlt, was auf der Seite steht. + + + +- "Meine Quellen" traegt Postfach, eigene Feeds und Benachrichtigungsintervall. +- Die Einstellungsseite traegt nur Abrufintervall und plattformweite Feeds. +- Ein normaler Nutzer sieht auf der Einstellungsseite keine Bedienelemente mehr, + die beim Speichern in eine Fehlermeldung laufen. +- Das Zahnrad fuehrt jeden Nutzer auf eine fuer ihn bedienbare Seite. +- Alle Beschriftungen liegen in Deutsch und Englisch vor. +- Der Backlog-Punkt ist abgeschlossen und die Grundsatzfrage darin beantwortet. + + + +Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-SUMMARY.md` when done. +