418 lines
24 KiB
Markdown
418 lines
24 KiB
Markdown
---
|
|
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 && npx prisma validate && npx prisma migrate status</automated>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check</automated>
|
|
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL'
|
|
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'TenderRssFeedSource' ORDER BY indexname;
|
|
SQL</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 && 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 && 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 && 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 && 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>
|