docs(17-02): complete RSS-Feed-Besitzer plan

This commit is contained in:
2026-08-12 11:49:07 +02:00
parent 7dee116254
commit e45d7f2068
3 changed files with 225 additions and 10 deletions
@@ -0,0 +1,210 @@
---
phase: 17-eigene-ausschreibungs-quellen-je-nutzer
plan: 02
subsystem: api
tags: [prisma, postgresql, nestjs, multi-tenancy, migration, rss]
# Dependency graph
requires:
- phase: 17-01
provides: "Handgeschriebene Migrationstechnik (migrate diff + Handdatei + migrate deploy), tenantId-Denormalisierungsmuster, Nutzerfreigabe fuer beide Datenbank-Umbauten der Phase"
provides:
- "TenderRssFeedSource.userId/tenantId (nullable) — plattformweite vs. persoenliche RSS-Feeds (D-02)"
- "Handgeschriebene Migration (@@unique([userId,url]) ersetzt url @unique), Bestandszeilen unangetastet"
- "TenderRssFeedSourceService: listForUser/createForUser/createPlatform/remove(id,{userId,isAdmin})"
- "GET/POST/DELETE /modules/tender-radar/rss-feeds @UseModule('tender-radar') statt @Roles(ADMIN,SUPER_ADMIN); POST scope:'platform' bleibt admin-only (Inline-Check)"
- "RssAdapter taggt Datensaetze aus persoenlichen Feeds mit ownerTenantId (D-06, D-13-Wiederverwendung)"
- "seedServiceBundRssFeed() (tenders.seed.ts) — find-then-create statt upsert-on-url, testbar ohne Nest-Bootstrap"
affects: [17-03-ui-aufteilung]
# Actuals (#2632)
actuals:
tokens: 19468
tasks: 3
commits: 3
tech-stack:
added: []
patterns:
- "Handgeschriebene Prisma-Migration ohne Bestandsdaten-Umzug (nur Spalten+Index-Wechsel) — leerer Besitzer entspricht bereits dem Ist-Zustand, kein Backfill noetig, anders als 17-01"
- "Zusammengesetzte Eindeutigkeitsregel mit einem Feld ohne Pflichtwert: Prismas generierter Compound-Unique-Typ verlangt das Feld trotzdem als Pflicht-String — Zugriff auf die NULL-Seite nur ueber findFirst/deleteMany mit gewoehnlicher Bedingung, nie ueber den Compound-Typ (betraf sowohl die Startbestueckung als auch remove())"
- "Delete-Protection als einzelne bedingte deleteMany-Anweisung (Besitz-Vergleich in der DB-Bedingung, kein TOCTOU-Fenster) statt 'erst lesen, dann loeschen'"
- "Auswertender Prisma-Doppelgaenger in Tests (matchesWhere mit OR/AND/Gleichheit) statt eines Doppelgaengers, der Bedingungen ignoriert und Erfolg vortaeuscht"
key-files:
created:
- apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql
- apps/api/src/tenders/rss-feed-migration-sql.spec.ts
modified:
- apps/api/prisma/schema.prisma
- 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/tenders.seed.ts
- apps/api/src/tenders/adapters/rss.adapter.ts
key-decisions:
- "Checkpoint-Freigabe aus 17-01 deckte beide Datenbank-Umbauten der Phase ausdruecklich ab — kein zweiter Halt fuer diese Migration (im Ausfuehrungsauftrag vorgegeben, bestaetigt durch gemessene 1 Bestandszeile lokal, unveraendert plattformweit nach der Migration)"
- "[Rule 3] Startbestueckung (tenders.module.ts) von 'upsert auf url' auf 'erst suchen, dann anlegen' umgestellt bereits in Task 1 statt erst in Task 3 — Task 1s eigene <verify> verlangt einen fehlerfreien Type-Check ueber das gesamte Projekt, und der Compile-Fehler (Prismas generierter Compound-Unique-Typ verlangt userId als Pflicht-String) war sofort da, sobald das Schema in Task 1 geaendert war. Gleiche Vorgehensweise wie in 17-01 (dortige Deviation 1/2)."
- "seedServiceBundRssFeed() aus TendersModule.onModuleInit() in tenders.seed.ts extrahiert (gleiches Muster wie das bereits vorhandene seedTendersModule) — noetig, damit die in Task 3 verlangte Bestueckungs-Idempotenz-Pruefung eine echte, produktiv laufende Funktion testet statt eine Kopie der Logik"
- "DELETE /rss-feeds/:feedId von @Roles(ADMIN,SUPER_ADMIN) auf @UseModule('tender-radar') umgestellt — die Besitzpruefung im Dienst ersetzt die Rollenpruefung vollstaendig (T-17-07)"
- "POST /rss-feeds mit scope:'platform' bleibt admin-only, aber als Inline-Pruefung im Controller (role aus demselben Ort wie RolesGuard) statt als Decorator, weil dieselbe Route jetzt auch den persoenlichen Anlegeweg fuer jeden Modulnutzer bedient (T-17-08)"
requirements-completed: [SRC-02, SRC-03]
coverage:
- id: D1
description: "TenderRssFeedSource.userId/tenantId ergaenzt (Migration ohne Bestandsdaten-Umzug); @@unique([userId,url]) ersetzt url @unique; listForUser/createForUser/createPlatform ersetzen list/create; GET/POST /rss-feeds sind @UseModule-gated, scope:'platform' bleibt admin-only"
requirement: "SRC-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-rss-feed.service.spec.ts (26 Tests: SSRF/Denylist auf createPlatform, Ownership-Scoping in listForUser, createForUser-Owner-Stempel, gleiche URL fuer zwei Nutzer)"
status: pass
- kind: unit
ref: "apps/api/src/tenders/tenders.controller.spec.ts (RSS-Feeds-Block: GET/POST-Delegation, isPlatformWide-Mapping, T-17-08 USER/ADMIN-Faelle)"
status: pass
- kind: integration
ref: "prisma migrate deploy gegen lokale Dev-DB (172.19.0.2) + prisma migrate status + Index-Abfrage (TenderRssFeedSource_userId_url_key vorhanden, TenderRssFeedSource_url_key entfernt)"
status: pass
human_judgment: false
- id: D2
description: "Loeschschutz: nur Besitzer oder (Administrator UND plattformweiter Feed) kann loeschen, sonst NotFoundException statt ForbiddenException; Obergrenze 20 persoenliche Feeds je Nutzer; Sperrliste/SSRF-Schutz greift auch auf dem persoenlichen Anlegeweg"
requirement: "SRC-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-rss-feed.service.spec.ts (remove()-Block mit auswertendem Prisma-Doppelgaenger, 6 Faelle; Obergrenze-Block, 2 Faelle; SSRF-auf-createForUser-Block, 2 Faelle)"
status: pass
- kind: unit
ref: "apps/api/src/tenders/tenders.controller.spec.ts (DELETE-Faelle mit isAdmin true/false)"
status: pass
human_judgment: false
- id: D3
description: "Ausschreibungen aus einem persoenlichen RSS-Feed tragen die ownerTenantId-Herkunftsmarkierung des Feed-Besitzers (D-06); plattformweite Feeds bleiben unmarkiert; der Sammel-Fetch faengt weiterhin einen ausfallenden Feed ab, ohne die uebrigen zu blockieren, auch ueber Besitzer-Grenzen hinweg"
requirement: "SRC-02"
verification:
- kind: unit
ref: "apps/api/src/tenders/adapters/rss.adapter.spec.ts (D-06-Block: getaggte vs. ungetaggte Datensaetze, zwei Besitzer in einem Durchlauf mit Catch-per-Feed)"
status: pass
human_judgment: false
- id: D4
description: "Der seit Phase 14 gesetzte service.bund.de-Feed bleibt nach der Migration unveraendert aktiv und plattformweit (leerer Besitzer); die Startbestueckung ist bei wiederholten Anwendungsstarts folgenlos (kein Duplikat)"
requirement: "SRC-03"
verification:
- kind: unit
ref: "apps/api/src/tenders/tenders.seed.spec.ts (seedServiceBundRssFeed-Block: Einzelanlage, zweifacher Lauf bleibt bei einer Zeile, vorhandener persoenlicher Feed derselben Adresse bleibt unangetastet)"
status: pass
- kind: integration
ref: "SELECT auf TenderRssFeedSource nach angewendeter Migration: genau 1 Zeile, service.bund.de, isActive=true, userId/tenantId leer"
status: pass
human_judgment: false
duration: 58min
completed: 2026-08-12
status: complete
---
# Phase 17 Plan 02: RSS-Feeds bekommen einen Besitzer Summary
**TenderRssFeedSource per Handmigration um nullable userId/tenantId erweitert (D-02) — plattformweite Feeds bleiben admin-gepflegt und fuer alle sichtbar, jeder Modulnutzer kann jetzt zusaetzlich eigene RSS-Feeds anlegen, mit atomarem Loeschschutz, 20er-Obergrenze und ownerTenantId-Herkunftsmarkierung fuer den Abruf.**
## Performance
- **Duration:** 58 min
- **Started:** 2026-08-12T11:28:00Z
- **Completed:** 2026-08-12T12:26:00Z
- **Tasks:** 3 (Tracer-Task durchgehende Bahn, Loeschschutz+Obergrenze, Abruf-Tagging+Startbestueckungs-Test)
- **Files modified:** 14 (2 neu, 12 geaendert)
## Accomplishments
- `TenderRssFeedSource.userId`/`tenantId` (beide nullable) ersetzen die reine URL-Eindeutigkeit — leerer Besitzer heisst plattformweit (D-02), gesetzt heisst persoenlicher Feed genau eines Nutzers
- Handgeschriebene Migration ohne Bestandsdaten-Umzug (anders als 17-01): der eine vorhandene service.bund.de-Feed braucht keine Zuordnung, ein leerer Besitzer entspricht bereits dem heutigen Zustand
- `TenderRssFeedSourceService`: `listForUser`/`createForUser`/`createPlatform` ersetzen die alte Admin-only-CRUD; `remove(id, {userId, isAdmin})` ist eine einzige bedingte `deleteMany`-Anweisung ohne Zeitfenster zwischen Pruefung und Loeschung
- `GET`/`POST`/`DELETE /rss-feeds` sind jetzt `@UseModule('tender-radar')`-gated statt `@Roles(ADMIN,SUPER_ADMIN)` — jeder Modulnutzer kann eigene Feeds anlegen/sehen/loeschen; `POST` mit `scope: 'platform'` bleibt Administratoren vorbehalten (Inline-Pruefung im Controller)
- Obergrenze von 20 persoenlichen Feeds je Nutzer (T-17-10) — plattformweite Feeds zaehlen nicht mit
- `RssAdapter` taggt Datensaetze aus einem Feed mit gesetztem `tenantId` mit derselben `ownerTenantId`-Herkunftsmarkierung, die Postfach-Datensaetze seit Phase 14 tragen (D-06); plattformweite Feeds bleiben unmarkiert
- Startbestueckung des service.bund.de-Feeds als eigene, testbare Funktion `seedServiceBundRssFeed()` extrahiert (find-then-create statt upsert-on-url) — nachweislich idempotent bei wiederholten Anwendungsstarts
## Task Commits
Jede Aufgabe wurde einzeln committet:
1. **Task 1: Ein persoenlicher Feed — durchgehend von der Datenbank bis zum Endpunkt** — `adb72f6` (feat)
2. **Task 2: Niemand fasst fremde Feeds an — Loeschschutz und Mengenbegrenzung** — `9616155` (feat)
3. **Task 3: Abruf und Startbestueckung ziehen nach** — `7dee116` (test)
**Plan metadata:** wird mit diesem SUMMARY committet (docs)
## Files Created/Modified
- `apps/api/prisma/schema.prisma` — `TenderRssFeedSource.userId`/`tenantId` ergaenzt, `url @unique` durch `@@unique([userId, url])` ersetzt, `@@index([userId])` ergaenzt
- `apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql` — Handmigration: beide Spalten ohne Pflichtwert, alter eindeutiger Index auf der Adresse entfernt, neuer eindeutiger Index ueber Besitzer+Adresse, gewoehnlicher Index auf dem Besitzer; keine Bestandsdaten-Anweisung
- `apps/api/src/tenders/rss-feed-migration-sql.spec.ts` — neuer Text-Spec (kein DB-Zugriff), prueft Spalten/Indizes/Reihenfolge der Handmigration
- `apps/api/src/tenders/dto/tender-rss-feed.dto.ts` — `scope?: 'personal' | 'platform'` ergaenzt (Wunsch, keine Berechtigung)
- `apps/api/src/tenders/tender-rss-feed.service.ts` — `listForUser`/`createForUser`/`createPlatform` (Task 1), `remove(id,{userId,isAdmin})` als bedingte `deleteMany`, 20er-Obergrenze in `createForUser` (Task 2)
- `apps/api/src/tenders/tender-rss-feed.service.spec.ts` — auf neue Methoden umgestellt, auswertender Prisma-Doppelgaenger (OR/AND/Gleichheit) statt ignorierender Doppelgaenger, 26 Tests gesamt
- `apps/api/src/tenders/tenders.controller.ts` — RSS-Feeds-Routen von `@Roles` auf `@UseModule`, `extractTriageContext` um `role` erweitert, Inline-Rollenpruefung fuer `scope:'platform'`, `removeRssFeed` reicht `{userId,isAdmin}` durch
- `apps/api/src/tenders/tenders.controller.spec.ts` — Fake-Service-Signaturen angepasst, `makeFakeRequest` um `role` erweitert, neue Faelle fuer isPlatformWide-Mapping, DELETE-isAdmin, T-17-08 USER/ADMIN
- `apps/api/src/tenders/tenders.module.ts` — Startbestueckung ruft `seedServiceBundRssFeed(this.prisma)` statt der weggefallenen `upsert`-Form auf
- `apps/api/src/tenders/tenders.seed.ts` — `seedServiceBundRssFeed()` neu (find-then-create, testbare Funktion)
- `apps/api/src/tenders/tenders.seed.spec.ts` — 3 neue Faelle fuer `seedServiceBundRssFeed` (Einzelanlage, Idempotenz bei zweifachem Lauf, unangetasteter persoenlicher Feed derselben Adresse)
- `apps/api/src/tenders/adapters/rss.adapter.ts` — Fan-out taggt Datensaetze mit `feed.tenantId` als `ownerTenantId`, wenn gesetzt (D-06); `parseFeed` unveraendert
- `apps/api/src/tenders/adapters/rss.adapter.spec.ts` — 3 neue Faelle fuer D-06-Tagging (getaggt/ungetaggt/zwei-Besitzer-ein-Durchlauf)
## Decisions Made
- Checkpoint-Freigabe aus 17-01 deckte diese Migration bereits ab — kein zweiter Halt, gemessene 1 Bestandszeile blieb nach der Migration unveraendert plattformweit
- [Rule 3] Startbestueckung bereits in Task 1 auf find-then-create umgestellt statt erst in Task 3 — Task 1s eigener Type-Check-Verify brach sonst sofort
- `seedServiceBundRssFeed()` aus dem Modul-Lifecycle-Hook extrahiert, damit die in Task 3 verlangte Idempotenz-Pruefung echten Produktivcode testet
- `DELETE /rss-feeds/:feedId` von rollenbasiert auf modulbasiert umgestellt — die Besitzpruefung im Dienst ersetzt die Rollenpruefung vollstaendig
- `POST /rss-feeds` mit `scope:'platform'` bleibt admin-only als Inline-Pruefung, nicht als Decorator, weil dieselbe Route jetzt auch den persoenlichen Weg bedient
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Startbestueckung (tenders.module.ts) vorgezogen aus Task 3 nach Task 1**
- **Found during:** Task 1 (Type-Check nach der Schema-Aenderung)
- **Issue:** Der Plan warnte in Task 1s eigenem Text ausdruecklich vor diesem Bruch ("betrifft die Startbestueckung, Task 3"), ordnete die Behebung aber Task 3 zu. Task 1s eigenes `<verify>` verlangt jedoch einen fehlerfreien projektweiten Type-Check — der `upsert`-Aufruf mit `where: { url }` kompiliert nicht mehr, sobald `@@unique([userId, url])` das Schema ersetzt, weil Prismas generierter Compound-Unique-Typ `userId` als Pflicht-String verlangt (kann daher keinen plattformweiten Feed mit leerem Besitzer ansprechen)
- **Fix:** Startbestueckung auf "erst suchen (Besitzer leer + Adresse), dann anlegen" umgestellt — exakt die in Task 3 beschriebene Loesung, nur zeitlich vorgezogen
- **Files modified:** apps/api/src/tenders/tenders.module.ts
- **Verification:** `pnpm --filter @tessera/api type-check` fehlerfrei ab Task 1; DB-Zaehlung bestaetigt den service.bund.de-Feed nach der Migration weiterhin genau einmal vorhanden
- **Committed in:** adb72f6 (Task 1 commit)
**2. [Rule 3 - Blocking] Test-Dateien an neue Service-/Controller-Signaturen angepasst**
- **Found during:** Task 1 und Task 2 (Type-Check nach den jeweiligen API-Aenderungen)
- **Issue:** `tender-rss-feed.service.spec.ts` und `tenders.controller.spec.ts` riefen die alten Methoden `list()`/`create()`/`remove(id)` auf bzw. den Controller ohne `req`-Parameter — nicht anpassbar ohne Kompilierfehler
- **Fix:** Fake-Service-Signaturen und alle betroffenen Testfaelle auf `listForUser`/`createForUser`/`createPlatform`/`remove(id,{userId,isAdmin})` umgestellt; `makeFakeRequest` um `role` erweitert
- **Files modified:** apps/api/src/tenders/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
- **Verification:** `pnpm --filter @tessera/api exec vitest run src/tenders` — 362/362 gruen nach Task 3
- **Committed in:** adb72f6, 9616155 (jeweils im zugehoerigen Task-Commit)
---
**Total deviations:** 2 auto-fixed (beide Rule 3 — Kompilierfaehigkeit, dieselbe Kategorie wie in 17-01)
**Impact on plan:** Beide Anpassungen waren fuer die Korrektheit der eigentlichen Plan-Aenderung zwingend notwendig. Kein Scope Creep — Deviation 1 ist exakt die von Task 3 selbst beschriebene Loesung, nur zeitlich vorgezogen; Deviation 2 haelt die Test-Suite durchgaengig kompilierbar.
## Issues Encountered
- Lokaler Postgres-Container (`tessera-ctl-db-1`) lief bereits (aus 17-01) — direkt ueber die Container-IP (172.19.0.2, `tessera:tessera_dev`) angebunden, keine weitere Vorbereitung noetig
- Keine weiteren Bloecker
## User Setup Required
None — keine externe Dienstkonfiguration erforderlich. Die Migration ist bisher nur lokal angewendet; das Ausrollen auf dem Testserver (192.168.13.12) bleibt bewusst Nutzeraktion beim naechsten Deploy (Projektregel: kein `docker compose pull/up/rebuild` durch Claude auf dem Testserver).
## Next Phase Readiness
- Backend-Teil von D-02 vollstaendig: Migration lokal angewendet und verifiziert (362/362 `src/tenders`-Tests, beide Typpruefungen — API und Web — fehlerfrei, Web-Tests 192/192 gruen)
- Frontend (`RssFeedListForm.tsx`, aktuell noch hinter der alten Admin-Annahme gebaut) ist bewusst NICHT Teil dieses Plans — Plan 17-03 ("ui-aufteilung") ordnet die Einstellungsseite neu und baut die "Meine Quellen"-Oberflaeche fuer RSS-Feeds analog zu 17-01s Postfach-Seite
- Rollout auf dem Testserver bleibt Nutzeraktion; beide Migrationen der Phase (17-01 und 17-02) liegen bereit, sind dort aber noch nicht angewendet
- Kein offener WINDOWS.md-Eintrag aus diesem Plan — alle Pruefungen liefen automatisiert und wurden ausgefuehrt
## Self-Check: PASSED
Alle im SUMMARY genannten Dateien existieren auf der Festplatte; alle drei Task-Commit-Hashes (`adb72f6`, `9616155`, `7dee116`) sind im Git-Log auffindbar.
---
*Phase: 17-eigene-ausschreibungs-quellen-je-nutzer*
*Completed: 2026-08-12*