docs(17): create phase plan

This commit is contained in:
2026-08-12 08:26:00 +02:00
parent 614629325d
commit 105683b94b
4 changed files with 1214 additions and 0 deletions
+36
View File
@@ -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| | | 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) | | 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 | | 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.
@@ -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."
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Freigabe fuer die beiden Datenbank-Umbauten dieser Phase</name>
<decision>Darf ich die Tabellenstruktur fuer Postfaecher und RSS-Feeds jetzt umbauen?</decision>
<context>
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.
</context>
<options>
<option id="weiter">
<name>Weiter — Umbau durchfuehren</name>
<pros>Die Phase kann wie besprochen gebaut werden; jeder bekommt sein eigenes Postfach.</pros>
<cons>Der Schritt ist nur mit einer weiteren Aenderung rueckdrehbar.</cons>
</option>
<option id="stopp">
<name>Stopp — erst Sicherung, dann neu ansetzen</name>
<pros>Kein Risiko, bis eine Sicherung vorliegt.</pros>
<cons>Die Phase pausiert.</cons>
</option>
</options>
<resume-signal>Antworten Sie mit "weiter" oder "stopp".</resume-signal>
</task>
<task type="tracer" tdd="true">
<name>Task 2: Ein eigenes Postfach je Nutzer — durchgehend von der Datenbank bis zur Seite</name>
<precondition>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").</precondition>
<reversibility rating="one-way">Die Migration schreibt Bestandsdaten um und entfernt eine Eindeutigkeitsregel; ein Rueckweg braucht eine zweite Migration. Freigabe erfolgt in Task 1.</reversibility>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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).
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api &amp;&amp; npx prisma validate &amp;&amp; npx prisma migrate status</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api type-check &amp;&amp; pnpm --filter @tessera/web type-check</automated>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api &amp;&amp; npx prisma db execute --stdin &lt;&lt;'SQL'
SELECT indexname FROM pg_indexes WHERE tablename = 'TenderEmailConfig' ORDER BY indexname;
SQL</automated>
<human-check>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.</human-check>
</verify>
<done>
`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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Der Abruf holt alle Postfaecher — und beweist es mit Tests</name>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; 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</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/tenders</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
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).
</verification>
<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-sources` ist fuer jeden Nutzer mit Modulfreigabe
erreichbar und zeigt dessen Postfach.
- `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05).
</success_criteria>
<output>
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.
</output>
@@ -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."
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: Ein persoenlicher Feed — durchgehend von der Datenbank bis zum Endpunkt</name>
<precondition>Die Migration aus Plan 17-01 ist lokal angewandt (`npx prisma migrate status` im API-Verzeichnis meldet keine offene Migration).</precondition>
<reversibility rating="costly">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.</reversibility>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api &amp;&amp; npx prisma validate &amp;&amp; npx prisma migrate status</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api type-check</automated>
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api &amp;&amp; npx prisma db execute --stdin &lt;&lt;'SQL'
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'TenderRssFeedSource' ORDER BY indexname;
SQL</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Niemand fasst fremde Feeds an — Loeschschutz und Mengenbegrenzung</name>
<files>
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
</files>
<read_first>
apps/api/src/tenders/tender-rss-feed.service.spec.ts,
apps/api/src/tenders/tenders.controller.spec.ts (Zeilen 1000-1050)
</read_first>
<behavior>
- 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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/tenders/tender-rss-feed.service.spec.ts src/tenders/tenders.controller.spec.ts</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api type-check</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Abruf und Startbestueckung ziehen nach</name>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/tenders/adapters/rss.adapter.spec.ts src/tenders/rss-feed-migration-sql.spec.ts</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/tenders</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- 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).
</success_criteria>
<output>
Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-02-SUMMARY.md` when done.
</output>
@@ -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."
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: "Meine Quellen" wird vollstaendig — Postfach, eigene Feeds, Benachrichtigung</name>
<files>
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
</files>
<read_first>
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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web type-check</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; 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'&amp;&amp;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')"</automated>
<human-check>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.</human-check>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Die Administrationsseite schrumpft auf das, was Administration ist</name>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- 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".
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web type-check</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; 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'&amp;&amp;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')"</automated>
<human-check>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".</human-check>
</verify>
<done>
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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Tests fuer beide Seiten und der Backlog-Punkt wird zugemacht</name>
<files>
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
</files>
<read_first>
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
</read_first>
<behavior>
- `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.
</behavior>
<action>
**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.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/web exec vitest run</automated>
<automated>cd /home/vicolab/projects/tessera-ctl &amp;&amp; pnpm --filter @tessera/api exec vitest run src/tenders &amp;&amp; pnpm --filter @tessera/api type-check &amp;&amp; pnpm --filter @tessera/web type-check</automated>
<automated>test ! -f .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md &amp;&amp; test -f .planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md</automated>
</verify>
<done>
Alle Tests in Web und API laufen gruen, beide Typpruefungen sind sauber, der
Backlog-Punkt liegt im Ordner der erledigten Punkte.
</done>
</task>
</tasks>
<threat_model>
## 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. |
</threat_model>
<verification>
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.
</verification>
<success_criteria>
- "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.
</success_criteria>
<output>
Create `.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-03-SUMMARY.md` when done.
</output>