--- 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 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 `` 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*