docs(17): create phase plan
This commit is contained in:
@@ -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 && npx prisma validate && npx prisma migrate status</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/api && npx prisma db execute --stdin <<'SQL'
|
||||
SELECT indexname FROM pg_indexes WHERE tablename = 'TenderEmailConfig' ORDER BY indexname;
|
||||
SQL</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 && 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 && 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 && 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>
|
||||
@@ -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 && pnpm --filter @tessera/web type-check</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')"</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 && pnpm --filter @tessera/web type-check</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v?walk(v,p+k+'.'):[p+k]);const d=walk(de.tenderRadar),e=walk(en.tenderRadar);const miss=d.filter(k=>!e.includes(k)).concat(e.filter(k=>!d.includes(k)));if(miss.length){console.error('Fehlende Uebersetzungen:',miss);process.exit(1)}console.log('i18n vollstaendig:',d.length,'Schluessel')"</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 && pnpm --filter @tessera/web exec vitest run src/app/\(portal\)/modules/tender-radar</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders && pnpm --filter @tessera/api type-check && pnpm --filter @tessera/web type-check</automated>
|
||||
<automated>test ! -f .planning/todos/pending/2026-08-11-tender-radar-einstellungen-mischen-rollen.md && test -f .planning/todos/completed/2026-08-11-tender-radar-einstellungen-mischen-rollen.md</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>
|
||||
Reference in New Issue
Block a user