- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
apps/web+packages, noUselessEscapeInRegex, useConst,
useExponentiationOperator) sowie fuenf ungesicherte Regeln
(useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
(ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
noUnusedImports/noUnusedFunctionParameters zu erreichen
Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.
Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
der Seite stehen (keine Weiterleitung, keine Aktualisierung der
Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
- 30 verbleibende forTenant()-Aufrufstellen in sieben Diensten (calendar 6,
dashboard 9, favorites 5, tender-email-config 3, tender-notification-pref 2,
tender-rss-feed 2, tender-triage 3) reichen userId als drittes Argument
durch. tender-digest.scheduler.ts bleibt zweistellig (Hintergrunddienst,
Etappe 3c), mit Begruendung im Kommentar. Keine Methodensignatur, kein
Controller angefasst, keine anwendungsseitige userId-Filterung entfernt.
- rls-scratch-check.mjs: zwoelf Extraktionsstellen auf die neue Migration
umgeleitet (TenderEmailConfig/TenderNotificationPref/TenderSavedSearch/
TenderTriage/TenderRssFeedSource in runTendersAreaChecks, SearchProvider in
runSearchProviderAreaChecks/runDashboardAreaChecks, DashboardLayout/
WidgetInstance, CalendarSource/FavoriteLink samt regelstand-eindeutig-Gates).
SearchProvider/TenderRssFeedSource jetzt mit extractAllPolicySql (4 Regeln).
runUserDimensionChecks() um die uebrigen neun Tabellen erweitert (neue
Routine runCommandSeparatedPersonalTableCheck fuer die zwei NULL-faehigen
Tabellen inkl. gemeinsame-Zeile-Pruefungen).
- Sechs Loch-Pruefungen umgedreht (dashboardlayout, widgetinstance,
searchprovider, calendarsource, favoritelink-Doppelaussage getrennt) —
alte Messung ohne Benutzer bleibt unter neuem Namen, Umkehrung MIT
Benutzer erwartet das Gegenteil; kein alter Name mehr als Kennung.
- Baseline: 1020/62 Tests weiterhin gruen, Typpruefung sauber, Werkzeug
203/203 bestanden (vorher 146).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- Neue Migration 20260911120000_rls_user_dimension_personal_tables: current_user_id()
(NULLIF-gefaltet), zehn persoenliche Tabellen umgestellt (acht als eine Regel,
SearchProvider/TenderRssFeedSource als je vier befehlsgetrennte Regeln), vier
Verwaltungstabellen bewusst unveraendert. Lokal angewendet (migrate deploy,
Prisma-Binary aus apps/api/node_modules/.bin), schema.prisma unveraendert.
- forTenant(prisma, tenantId, userId?): beide set_config in EINER getaggten
Anweisung, $transaction-Array bleibt bei zwei Eintraegen (WINDOWS #20),
Leerstring ohne Benutzer statt Weglassen.
- tender-saved-search.service.ts: alle vier forTenant()-Aufrufe reichen userId
durch; Detektor-Regex bestaetigt 4 Treffer.
- rls-scratch-check.mjs: current_user_id() aus der neuen Migration geschnitten
(nicht getippt), drei Funktionsfaelle gemessen, neue runUserDimensionChecks()
mit generiertem Client fuer TenderSavedSearch (vier Wahrheiten + Spaltenabgleich),
die alte Loch-Pruefung tendersavedsearch-fremder-nutzer-desselben-mandanten-sichtbar
umgedreht (alte Messung unter neuem Namen erhalten, neue Umkehrung MIT Benutzer).
sqlStateOf() um Message-Fallback ergaenzt (RLS-Ablehnung ueber generierten
Client traegt den SQLSTATE nur im Fehlertext, nicht in .meta.code).
- Baseline: 1020/62 Tests, Typpruefung sauber, Werkzeug 146/146 bestanden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- TenderRssFeedSourceService.listForUser() nimmt jetzt (userId, tenantId)
entgegen und laeuft ueber einen gebundenen Klienten (forTenant) — die neue
Leseregel schliesst plattformweite Zeilen ein, die Reparatur haette den
ungebundenen Pfad sonst still auf nur die plattformweiten Zeilen reduziert
(Befund F). createPlatform/remove bleiben bewusst ungebunden, Kommentare an
der neuen Regel richtiggestellt.
- TendersController.listRssFeeds reicht die Mandantenkennung aus dem
Aufrufzusammenhang durch.
- Vier Aufzeichnungen im Quelltext (module-access.service.ts,
groups.service.ts, module-grants.service.ts, rls-coverage.spec.ts) sagen
jetzt, dass die Datenbankregel seit 20260910120000_rls_widen_membership_
grant_and_platform_read beide Seiten prueft; die Anwendungspruefungen
bleiben unveraendert bestehen (zweites Netz, wirkt vor dem Scharfschalten
als einziger Schutz).
- Zwei-Klienten-Nachweis in module-grants.service.spec.ts ergaenzt (Kommentar,
warum die beiden Cross-Tenant-Tests nach der Regelaenderung nicht entfallen
duerfen) und in tender-rss-feed.service.spec.ts umgekehrt (listForUser
bindet jetzt).
- Rule 1: implizites any beim Destrukturieren in listRssFeeds (feeds ist seit
der Bindung `any`) mit expliziter Annotation behoben.
- Falsifizierungsnachweis durchgefuehrt: Bindungsaufruf zurueckgenommen,
genau ein Test wurde rot (AssertionError, 0 statt der erwarteten
Aufrufe), Ruecknahme rueckgaengig gemacht.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- module-registry.service.ts: findActiveForTenant, activateForTenant,
isModuleActive binden je einen Aktivierungszugriff, deactivateForTenant
bindet beide (Lesen+Schreiben) ueber EINEN Klienten unter tenantPrisma;
alle sechs Katalogzugriffe (findAll/findBySlug/beide
Existenzpruefungen/isModuleActive-Katalogsuche/seedModule) bleiben
bewusst ungebunden, mit Kommentar der Messung von Bedingung trennt
- isModuleActive-Kopfkommentar richtiggestellt: der Waechter ruft sie
nicht auf (0 Aufrufer, TEIL 3 von Aufgabe 1) — Waechter nimmt findBySlug
+ ModuleAccessService.getAccessibleModuleIds
- module-registry.service.spec.ts: NEU, Zwei-Klienten-Nachweis, deckt die
bislang ungetestete Datei mit elf der siebzehn Zugriffe des Bereichs ab,
inkl. der lauten (deactivate ohne Aktivierung) und stillen (isModuleActive
ohne Aktivierung) Richtung und dem Katalog-Wachhund
- tender-scheduler.service.spec.ts: forTenant() auf Identitaet gemockt
(dieselbe Konvention wie ldap.service.spec.ts) — cross-area Bruch durch
die Umstellung von activateForTenant behoben (Rule 1/3)
- docs/mandantentrennung-zugriffsklassifikation.md: alle fuenf
handgepflegten Stellen nachgezogen (Bestandsaufnahme, Uebersichtszeile
7/10, Summenzeile 108/134, Klassen-Verteilung unveraendert bei 63 Paaren,
Hintergrunddienst-Abschnitt haelt die Abwesenheit eines sechsten Falls
fest) — alle gemessen, nicht abgeschrieben, Befund K haelt exakt
- docs/mandantentrennung-etappe2-fehlerrichtung.md: Nachtrag mit
tatsaechlich umgesetzten Pfaden, beiden Falsifizierungsnachweisen
(Testname+Meldung), und der Feststellung zum unveraenderten
Controller-Kommentar
- .planning/WINDOWS.md: neuer offener Eintrag #23 (deviation) — kein Signal
unterscheidet "keine Freigabe" von "Abfrage fand nichts", mit
Vorabpruefung fuer Etappe 4 und begruendeter Verwerfung einer
Laufzeitwarnung
- 833 Tests gruen (56 Dateien), Typpruefung sauber, Wegwerf-Werkzeug 66/66
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
tender-digest.scheduler.ts: die uebergreifende Kandidatenabfrage
(tenderMatch.findMany mit distinct:['userId']) bleibt bewusst ungebunden
und waehlt zusaetzlich das denormalisierte tenantId der Treffer-Zeile mit
aus; innerhalb der Schleife binden tenderNotificationPref.findUnique,
tenderMatch.findMany/updateMany und user.findUnique an den Mandanten
DIESER Kandidatenzeile.
tender-matching.service.ts: die Profilabfrage (tenderSavedSearch.findMany)
und der Lesezugriff auf den plattformweiten Tender-Katalog (D-03) bleiben
ungebunden; innerhalb der Profilschleife bindet die Treffer-Anlage
(tenderMatch.upsert), im nachgelagerten Instant-Dispatch binden
tenderMatch.findMany/updateMany und user.findUnique — je EIN gebundener
Client pro Profil, nicht neu je Treffer.
Beide Dateien tragen Codekommentare, die die uebergreifenden Abfragen
ausdruecklich als Etappe-3-Uebergabe benennen — die Trennlinie zwischen
Etappe 2 und Etappe 3 wird hier gezogen, nicht verwischt.
Alle drei betroffenen Testdateien (inkl. der gemeinsamen
Integrationsdatei) bekommen den Zwei-Client-Nachweis, Tests fuer
Zwei-Mandanten-Laeufe und die lautlose Fehlerform (kein Versand,
notifiedAt bleibt NULL). Falsifiziert: ein probeweiser Rueckbau der
user.findUnique-Bindung im Instant-Dispatch von tender-matching.service.ts
machte genau den erwarteten Test rot, danach zurueckgenommen.
rls-access-inventory.spec.ts gemessen und docs/mandantentrennung-zugriffsklassifikation.md
nachgezogen: Stand der fuenf betroffenen Paare (tender-digest.scheduler.ts/
tenderMatch,tenderNotificationPref,user; tender-matching.service.ts/
tenderMatch,user) auf gemischt bzw. gebunden; Bereichsuebersicht und
Klassen-Verteilung neu gemessen (tenders jetzt 36 ungebunden/26 gebunden);
"Der Hintergrunddienst als Falle" um den Abschluss beider Dateien ergaenzt.
docs/mandantentrennung-etappe2-fehlerrichtung.md: (t4) um den Nachtrag
ergaenzt, dass die Je-Treffer-Haelften geschlossen sind und die
uebergreifenden Haelften an Etappe 3 uebergeben bleiben.
770 Tests gruen, Typpruefung sauber, Wegwerf-Werkzeug 32/32, kein
Schema-/Migrations-/Compose-/Umgebungsdatei-Diff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
tender-saved-search.service.ts, tender-triage.service.ts,
tender-notification-pref.service.ts und tender-email-config.service.ts
laufen jetzt vollstaendig ueber forTenant() — vier neue Parameter (list,
update, remove, listForUser, favoriteIds, getForUser, getConfigForApi,
testConnection bekommen tenantId), die anwendungsseitige userId-Filterung
bleibt unveraendert (Befund E: die Policies haben keine Benutzerdimension).
tender-rss-feed.service.ts bindet nur createForUser (Zaehler + Anlage,
beide ausschliesslich auf persoenlichen Zeilen); listForUser, createPlatform
und remove bleiben mit Codekommentar bewusst ungebunden (WINDOWS #19 —
eine gebundene plattformweite Zeile waere unter jedem Mandanten unsichtbar,
ein gebundenes Einfuegen ohne Mandant wuerde abgewiesen).
tender-notification-pref.service.ts und tender-email-config.service.ts
uebersetzen eine P2002-Verletzung auf dem tenantlosen upsert-Schluessel
(Befund F) in eine verstaendliche deutsche Meldung statt eines rohen
Fehlers.
tenders.controller.ts reicht tenantId an den acht betroffenen
Aufrufstellen durch extractTriageContext() durch (kein neuer
Aufloesungsweg); die drei RSS-Aufrufstellen bleiben unveraendert, da ihre
Dienstmethoden nicht binden.
Alle sieben angefassten Testdateien bekommen den Zwei-Client-Nachweis
(__makeBoundClient ueber demselben Speicher) und Bindungstests je
umgestellter Methode; tender-rss-feed.service.spec.ts zusaetzlich den
Gegentest, dass die drei unveraendert bleibenden Pfade forTenant() NICHT
aufrufen. Falsifiziert: ein probeweiser Rueckbau der list()-Bindung in
tender-saved-search.service.ts machte genau den erwarteten Bindungstest
rot, danach zurueckgenommen.
docs/mandantentrennung-zugriffsklassifikation.md: Stand der fuenf Paare
auf gebunden bzw. gemischt nachgezogen; tenderRssFeedSource von
muss-mandantengebunden auf beides umklassifiziert (derselbe Praezedenzfall
wie ldapConfig in 260909-ipc).
761 Tests gruen (743 + 18 neue Bindungsnachweise), Typpruefung sauber,
Wegwerf-Werkzeug 32/32, kein Schema-/Migrations-/Compose-/
Umgebungsdatei-Diff.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
- User.email auf optional gestellt (Migration geschrieben, NICHT
ausgefuehrt); Eindeutigkeitsindex unangetastet, NULL bleibt in Postgres
je verschieden
- Neuer Kollisionsentscheider (resolveEmailForWrite) in ldap.service.ts:
eine bereits vergebene Adresse wird nie umgehaengt (T-Q3-01) — das
zuerst angelegte Konto behaelt sie, jedes weitere Konto entsteht ohne
Adresse (gesperrte Nutzerentscheidung 2026-09-09, WINDOWS #15)
- Entscheider in upsertMappedUser (Sync) UND importUsersByDn (Handimport)
verdrahtet, damit der zweite Anlageweg nicht als Luecke bestehen bleibt
- LdapSyncResult um emailConflicts/skippedNoLogin/entryFailures erweitert;
rohe ORM-Ausnahmetexte gehen nur noch an logger.error, nie in den
Bericht (T-Q3-02)
- UserService.create nimmt die Adresse optional entgegen; Tender-Digest
und Instant-Alert ueberspringen Empfaenger ohne Adresse (continue)
- Fuenf neue Testfaelle vorab gegen den unveraenderten Bestand rot
gelaufen (erwartete Ursachen bestaetigt); 651/651 API-Tests gruen,
prisma validate und type-check sauber
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
- TenderEmailConfigService.testConnection(userId, dto) mit Rueckfall auf
gespeicherte, entschluesselte Zugangsdaten bei leeren Feldern
- TendersController: POST email-config/test, userId aus Auth-Kontext,
deklariert vor @Get(':id')
- Beide Provider (ImapProvider/ExchangeInboxProvider) optional angehaengt,
bestehende 2-Arg-Konstruktoraufrufe bleiben typkorrekt
- Reihenfolge-Waechter und IDOR-Testfall ergaenzt
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
Nach der Formelaenderung (v1 -> v2) traegt jede Bestandszeile einen Hash
der alten Formel und wuerde nie wieder auf einen neuen treffen. Die
Produktionsdatenbank wird von Hand nicht angefasst, deshalb rechnet ein
neuer Dienst beim Start alle veralteten Zeilen automatisch nach — an der
Versionsmarke aus tender-fingerprint.ts erkannt, gleiche Bauform wie die
LDAP-Bind-Passwort-Nachverschluesselung.
- TenderFingerprintBackfillService: OnApplicationBootstrap, seitenweise
(500 Zeilen), pro Seite eine Transaktion, Fehler werden geloggt und
geschluckt statt geworfen
- In tenders.module.ts VOR TenderSchedulerService eingetragen, damit die
Nachrechnung vor der Cron-Registrierung laeuft
- Vier Tests: leere DB, alter+leerer Hash werden beide erfasst, zweiter
Lauf schreibt nichts mehr, Datenbankfehler wirft den Haken nicht
Gemessen an der lokalen Datenbank (16.255 Zeilen): Fingerabdruck-Gruppen
mit mehr als einer Zeile vorher=0, nachher=26, veraltete Zeilen
danach=0, davon Gruppen mit widersprechenden Wert-Groessenordnungen=2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Zwei Schaeden aus derselben Wurzel behoben (WINDOWS.md #11, beim Messen
gefunden): der Aktualisierungszweig aendert Titel/Vergabestelle/Frist
einer bestehenden Zeile, schrieb den Fingerabdruck dabei aber nie mit —
der gespeicherte Wert driftete vom Inhalt der Zeile weg und Stufe 3 fand
die Zeile nie wieder (an der Live-DB an drei Gruppen gemessen).
- fingerprint wird einmal in resolve() berechnet und sowohl im
Anlegezweig als auch im Aktualisierungszweig geschrieben
- Neue Stufe-3-Pruefung: valueBucketsContradict() als Veto auf einen
Fingerabdruck-Treffer, exakter Groessenordnungsvergleich, keine
Aehnlichkeitssuche
- toNumberOrNull() haendelt Prisma Decimal und den einfachen
Zahlenwert der Test-Fakes gleichermassen
- Vier neue Tests fuer die Wert-Faelle, bestehender Update-Test um die
Fingerabdruck-Erwartung erweitert, alle vorherigen Tests bleiben gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
Die dritte Dedup-Stufe hashte bisher fuenf Segmente, darunter CPV-Division
und geschaetzten Wert — beide werden von Nicht-DOE-Quellen systematisch
nie geliefert, wodurch dieselbe Ausschreibung aus DOE und aus einem
Scraper nie denselben Fingerabdruck ergab (WINDOWS.md #11).
- tenderFingerprint() nimmt nur noch buyerName, title, deadlineAt entgegen
- Versionsmarke FINGERPRINT_VERSION_PREFIX ("v2:") vor dem Hash, damit
Task 3 veraltete Zeilen erkennen kann
- valueBucketsContradict() als eigene, exportierte Funktion fuer den
Wert-Veto in Task 2 — kein Hash-Bestandteil mehr
- backfill-tender-source.ts an die neue Signatur angepasst
- Testsuite auf das neue Verhalten umgeschrieben inkl. Goldwert-Test
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
- RssAdapter.fetchTenders tags every record from a feed with a tenantId
with the same D-13 ownerTenantId origin marking email-alert records
carry since Phase 14; platform-wide feeds (no tenantId) stay unmarked.
The pure parseFeed mapping is untouched — tagging happens in the
fan-out loop that knows which row a batch came from
- Extracted the service.bund.de seed out of TendersModule.onModuleInit
into seedServiceBundRssFeed() (tenders.seed.ts, same pattern as the
existing seedTendersModule), so the find-then-create idempotency added
in Task 1 is unit-tested directly instead of only via a Nest bootstrap
- New rss-feed-migration-sql.spec.ts: text-only check of the Task 1
migration file (nullable columns, dropped/created indexes, no
existing-row mutation, correct ordering)
- Files modified: apps/api/src/tenders/adapters/rss.adapter.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tenders.seed.ts, apps/api/src/tenders/adapters/rss.adapter.spec.ts, apps/api/src/tenders/tenders.seed.spec.ts, apps/api/src/tenders/rss-feed-migration-sql.spec.ts
- remove(id, {userId, isAdmin}) replaces remove(id): single conditional
deleteMany (id AND (owned-by-caller OR admin-on-platform-feed)) — no
TOCTOU window, ownership check lives in the DB condition. Deletes
nothing -> NotFoundException (never Forbidden, no existence leak)
- createForUser rejects a caller's 21st personal feed with a clear
German message (T-17-10); platform-wide feeds are not counted
- DELETE /rss-feeds/:feedId moves from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar') — ownership check does the gating now
- Tests use a Prisma double that actually evaluates the where condition
(not a double that always "succeeds") for both deleteMany and count
- Files modified: 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
- TenderRssFeedSource.userId/tenantId (nullable): null = platform-wide
(admin-managed, includes the existing service.bund.de default),
set = personal feed owned by exactly one user
- Migration replaces url @unique with @@unique([userId, url]) — two
users can now follow the same address independently; existing rows
keep an empty owner (platform-wide, unchanged behavior)
- Service: listForUser/createForUser/createPlatform replace list/create
- Controller: GET/POST /rss-feeds move from @Roles(ADMIN,SUPER_ADMIN) to
@UseModule('tender-radar'); POST with scope:'platform' still requires
ADMIN/SUPER_ADMIN, checked inline (T-17-08)
- tenders.module.ts seed switched from upsert-on-url to find-then-create
(Rule 3, pulled forward from Task 3): the new compound unique index
requires a non-null userId in Prisma's generated type, so a
platform-wide row can no longer be addressed via upsert
- 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/tender-rss-feed.service.spec.ts, apps/api/src/tenders/tenders.controller.spec.ts
Der mandantenuebergreifende Sammelabruf in email-alert.adapter.ts bleibt
mechanisch unveraendert (findMany({isActive:true}) in einem Zug,
Fehlerbehandlung je Zeile) — geaendert wird nur die Warnmeldung (Zeilen-id
+ Besitzer statt Mandant, T-17-03) und die Klassendoku.
- Neue Tests: zwei aktive Postfaecher DESSELBEN Mandanten werden beide mit
ihren jeweils eigenen Zugangsdaten abgeholt; ein kaputtes Postfach
blockiert das andere nicht und protokolliert eine Warnung ohne
Zugangsdaten/Adresse; die Herkunftsmarkierung folgt dem Mandantenfeld
der jeweiligen Zeile (zwei Mandanten -> zwei Werte). Erwartungswerte von
Hand geschrieben, nicht ueber die Produktivfunktion erzeugt.
- tender-email-config.service.spec.ts (bereits in der Task-2-Migration
mitgeliefert) deckt zusaetzlich: tenantId wird beim Anlegen mitgeschrieben,
zwei Nutzer desselben Mandanten erzeugen zwei Zeilen statt eine zu
ueberschreiben.
- email-config-migration-sql.spec.ts (neu, Vorbild
doe-url-migration-sql.spec.ts): prueft die Reihenfolge der
Hand-Migration textuell — Zuordnung vor Loeschung, Pflicht erst nach
Befuellung, alte Eindeutigkeit runter/neue rauf, gewoehnlicher
tenantId-Index bleibt stehen.
src/tenders: 335/335 gruen. API gesamt: 603/603. Web gesamt: 192/192.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alert-Postfach gehoert jetzt dem einzelnen Nutzer (userId @unique) statt
dem Mandanten (D-01) — ein zweiter Kollege desselben Mandanten kann sein
eigenes Postfach anbinden. tenantId bleibt denormalisiert (SMTP-Aufloesung,
Herkunftsmarkierung), wird auf create UND update mitgeschrieben.
- Handgeschriebene Migration (prisma migrate dev verweigert die
nicht-interaktive Shell): befuellt Bestandszeilen mit dem aeltesten
aktiven Administrator ihres Mandanten, entfernt verwaiste Zeilen ohne
Administrator, ersetzt die tenantId-Eindeutigkeit durch userId.
Lokal getestet (0 Bestandszeilen lokal und auf alpha — Zaehlung im
Task-1-Checkpoint), Index-Ergebnis verifiziert.
- TenderEmailConfigService.getConfigForApi/saveConfig auf userId als
Schluessel umgestellt; saveConfig nimmt {userId, tenantId}.
- TendersController: email-config-Routen von @Roles(ADMIN,SUPER_ADMIN)
auf @UseModule('tender-radar') umgestellt (Postfach ist jetzt
Nutzereinstellung); Route-Reihenfolge vor @Get(':id') unveraendert.
- Neue Seite /modules/tender-radar/my-sources ("Meine Quellen") mit dem
unveraenderten EmailAlertConfigForm; Hinweistext benennt D-05 (Tender
bleibt plattform-global — nur wer Quellen einspeist aendert sich).
- tenders.controller.spec.ts an neue Service-Signatur angepasst (Rule 3,
nicht im Plan gelistet, aber zum Kompilieren/Bestehen erforderlich).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CALENDAR_ENCRYPTION_KEY was named after the calendar module because that
module needed encryption first, in Phase 5. Every feature since has shared the
same key -- SMTP, the DKV and tender mailboxes, and as of today the LDAP bind
password -- so the name has been describing one of five users rather than the
thing itself, and each new feature inherited the confusion.
TESSERA_ENCRYPTION_KEY is the name now. The old one is still read, because
renaming outright would stop every existing installation at the next start:
their .env carries the old name, and compose was just made to fail hard on a
missing key. When only the old name is present the API logs a deprecation
warning naming both, and when both are set the new one wins -- otherwise a
half-migrated .env would encrypt with one key and decrypt with the other.
CalendarCryptoService becomes CryptoService in its own global CryptoModule.
Four modules used to import CalendarModule purely to reach the provider, which
read as a dependency on calendars where there was none; that import is gone.
Compose keeps the hard failure: without either name the stack refuses to
start. Verified in both files for all three cases -- neither name set (abort),
only the old name (starts), only the new name (starts).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doe-opendata adapter stored the OCDS document's own `uri` as sourceUrl.
That is the API address of the record and serves OCDS JSON by design, so
anyone following the link from the results list, the detail view, or an alert
mail landed on raw JSON instead of the notice.
Build the human-readable page from the notice id the row already carries
instead. `/ui/de/search/details?noticeId=...` is the redirect target of
`/ui/de/notices/...`, so it needs no redirect. Verified in a browser for both
id shapes the feed uses -- numeric (25673764 -> "Feuerwehr-Geraetehaus Miehlen
Fliesenarbeiten") and UUID (7085ba12-... -> "Holzfassade"). The page is a
single-page app that answers 200 with an identical shell for any id, so this
had to be checked on rendered content; a status code proves nothing.
The adapter alone only fixes new ingests, so a backfill migration rewrites the
rows already stored -- in Tender and in TenderSource, since the detail view
lists per-source links separately. It touches only rows still pointing at
/api/notices/ and only ids of a shape that was actually verified, which makes
it idempotent and keeps an unexpected id from being pasted into a URL. Counted
read-only against the live database beforehand: 2846 DOE rows affected, none
skipped.
Closes the 2026-08-05 backlog item, which was deliberately held until Phase 16
was done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Manual "Jetzt abrufen" trigger delegates to
TenderIngestionService.pollDueSources() — the same fan-out tick the
scheduler cron runs. Gated to ADMIN/SUPER_ADMIN (T-lvg-01, DoS) and
declared before @Get(':id') per the established route-order convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On a fresh database the DÖE poll cron was never registered: TenderScheduler
read the doe-opendata poll config in its onModuleInit, which raced ahead of
TendersModule.onModuleInit seeding that config. The scheduler saw the config
absent → skipped registering the single global cron that drives pollDueSources
(DÖE + RSS + email-alert) → the platform ingested NOTHING until a second restart.
Observed live on a fresh prod DB (0 tenders, 'doe-opendata config inactive —
cron job not registered', lastIngestedDay null despite isActive=true).
Move the scheduler to onApplicationBootstrap, which runs after every module's
onModuleInit, so the seed is guaranteed complete before the config is read.
Adds a regression test asserting the lifecycle choice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add PORTAL_URLS map (vergabe24, aumass) in source-registry.ts, keyed off
the existing DENYLISTED_PORTALS constant so the portal set is never
re-declared
- Add GET /modules/tender-radar/denylisted-portals, declared before
@Get(':id') (route-order pitfall), mapping over DENYLISTED_PORTALS
- Extend tenders.controller.spec.ts: response shape + route-order guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.
TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).
RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.
EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.
tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GREEN phase (TDD) for Task 1: extractCandidateLinks (cheerio a[href] +
footer-noise filter + MAX_LINKS_PER_EMAIL cap, plaintext regex fallback),
titleFromEmail (subject -> first body line -> fallback), and
sourceNoticeIdFor (sha256 link hash) implement D-04's generic, no-portal-
specific-parser evaluation of alert emails.
SourceType gains 'email-alert'; TenderNormalizerService routes it through
the existing normalizeBag() path (same as ai-netserver/cosinex-dtvp/rss).
EmailAlertAdapter.fetchTenders() is a Task-1 placeholder — Task 2 wires the
real per-tenant fan-out.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RED phase (TDD) for Task 1: pure-function tests for extractCandidateLinks,
titleFromEmail, sourceNoticeIdFor — covers HTML + plaintext bodies,
footer-noise removal, link cap, and D-04 no-portal-specific-parser restraint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GET/POST/DELETE /modules/tender-radar/rss-feeds (Roles-guarded
ADMIN/SUPER_ADMIN), declared before the existing @Get(':id') handler to
avoid NestJS route-order shadowing. Delegates to
TenderRssFeedSourceService; the denylist/SSRF rejection (D-14) surfaces
as a 400 unchanged.
Web: tender-radar-api.ts gains listRssFeeds/createRssFeed/deleteRssFeed
(relaying the backend's specific rejection message via
extractErrorMessage), and a new RssFeedListForm client component renders
an "RSS-Feeds" section on the tender-radar settings page (D-09) — list,
add (with inline denylist error), and remove global feed URLs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.
Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.
Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).
Migration applied locally per project convention (host -> container IP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixture-first RSS parsing (INGEST-04): parses live-captured
service.bund.de (pubDate present, numeric-HTML-entity titles) and
subreport-elvis (pubDate absent, CDATA titles) feed shapes into
RawTenderRecord[] via fast-xml-parser, mirroring the DoeOpenDataAdapter
config. SourceType extended with 'rss'; normalize() dispatches 'rss'
through the existing normalizeBag() path unchanged (D-04/D-05).
Rule 1 fix: fast-xml-parser only decodes the 5 predefined XML entities,
not numeric character references — added an explicit decode step so
service.bund.de titles ("Übermittlung...") render correctly
instead of leaking raw entity syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- bagRecord() helper builds inline RawTenderRecords for the flat ocdsPayload
bag shape (no fixtures exist for NetServer/cosinex-DTVP)
- ai-netserver and cosinex-dtvp full-bag mapping, null/empty-field fallback,
and contentHash-format assertions
- Existing DOE fixture-based assertions untouched, confirming the refactor
didn't change DOE-path behavior
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Extract shared assemble() tail (status/dedupKey/contentHash/publishedAt)
so it is computed identically across all sources, not duplicated
- Move existing DOE eForms/OCDS extraction into normalizeDoe() (byte-identical
behavior, regression guard)
- Add normalizeBag() for the flat ocdsPayload bag shared by the NetServer and
cosinex/DTVP scraper adapters ({title, buyerName, procedureType,
legalFramework, deadlineAt}); legalFramework deliberately not mapped
- normalize() dispatches on raw.sourceType, defaulting to the DOE path so the
additive SourceType union never throws
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds CosinexAdapter to TendersModule's providers and registers it with
SourceRegistry at DI boot, alongside DoeOpenDataAdapter/NetServerAdapter
(cosinex-dtvp is not AGB-denylisted, so registration succeeds). Seeds a
cosinex-dtvp TenderSourcePollConfig row with isActive: false, matching
the ai-netserver "framework ready, activation deferred" stance (D-02).
tsc --noEmit clean; src/tenders slice: 21 files, 221/221 tests pass
(205 pre-existing + 16 new cosinex.adapter.spec.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate HTML adapter for the cosinex Vergabemarktplatz (DTVP) satellite
(sourceType='cosinex-dtvp'), distinct from the NetServer adapter since
cosinex markup differs structurally. Live inspection (2026-07-23) found
the "Aktuelle Bekanntmachungen" results table is fully server-rendered
(not JS-dependent as D-01 anticipated), so selectors are fully populated
rather than falling back to a needs-JS stub — parses publish date,
deadline (or "nv"), title, legal framework/procedure type, buyer name,
and a real per-notice deep link (pid) into RawTenderRecord[].
Rule 1 fix: cosinex serves charset=ISO-8859-1 with raw Latin-1 bytes for
umlauts (not HTML entities); Response.text() always UTF-8-decodes per
the Fetch spec, so the adapter reads arrayBuffer() and decodes explicitly
via TextDecoder('iso-8859-1') to avoid mojibake.
16 spec tests pass against a live-captured fixture (20 rows, transcoded
to UTF-8 on disk): full-fixture parse, deadline/publish date parsing,
nested-<abbr> procedure-type extraction, umlaut decoding, empty/broken
HTML and missing-pid row fallback, fetch-throw/non-2xx fallback, no-axios
and no-input-interpolated-URL guards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /modules/tender-radar/:id now includes the TenderSource relation
(sourcePortal, sourceUrl, sourceNoticeId) so a cross-source-deduped
tender's detail response carries links to all its source portals, not
just the single primary sourceUrl column. Route order unchanged (:id
stays after all static routes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Register SourceRegistry and TenderDedupService as providers.
onModuleInit registers DoeOpenDataAdapter with the registry before the
scheduler's first tick — the DI-boot-time enforcement point for the
INGEST-07 denylist gate (D-06). This is Wave 2's sole writer of
tenders.module.ts; 13-04 (NetServer) and 13-05 (cosinex) add their
own registry.register(...) calls additively in later waves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the DÖE-only findUnique with findMany({isActive:true}) fan-out
(poll-once-fan-out-many, D-01). Each active TenderSourcePollConfig is
resolved through SourceRegistry.get(sourceType) and processed inside
its own try/catch (catch-per-source, D-01) — one broken/blocking source
no longer aborts the tick for the others. dedupActive =
activePortalCount >= 2 (D-05) is computed once per tick and passed to
TenderDedupService.resolve(), which now replaces the direct
tender.upsert call. Delta-only matchDelta boundary (D-07) preserved:
only genuinely-created tender IDs across all sources are collected.
Extended tender-ingestion.service.spec.ts: multi-config fan-out,
catch-per-source isolation, dedupActive gate assertion, adapter-missing
skip, plus the existing SCHEMA-02/D-07/retention/day-cursor suites
updated to the new registry+dedup constructor shape (all green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
resolve(n, {dedupActive}) matches OCID -> source:noticeId -> fingerprint
(fingerprint tier hard-gated by dedupActive, D-05). On any match the
existing Tender gets an additional TenderSource attached (D-03 merge)
instead of a new Tender row; SCHEMA-02 change-detection is preserved
inline (matched Tender's mutable fields refresh when contentHash
differs, exactly as the old direct tender.upsert UPDATE branch did).
No match -> tender.create (with computed fingerprint) + tenderSource.create.
Plain PrismaService, no forTenant()/RLS (T-10-09).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GREEN — SourceRegistry.register() throws DeniedPortalError when any
of an adapter's declared portals is in DENYLISTED_PORTALS
(vergabe24, aumass), enforced at DI-registration time (INGEST-07/
D-06), not just documented. get()/activeAdapters() support the
Plan 13-03 poll-once-fan-out-many scheduler. 6/6 tests pass, no
Prisma/scraping import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RED — proves Erfolgskriterium 4 (INGEST-07): registering an adapter
whose portals include vergabe24 or aumass must throw DeniedPortalError,
including a mixed portals array with one denylisted entry. Also covers
legitimate register/get/activeAdapters happy paths. Fake adapter stub,
no real scraping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TenderSourceAdapter gains a readonly portals: readonly string[] field
so one adapter can serve multiple portals (NetServer: 3, Plan 13-04)
and so SourceRegistry can gate registration per-portal (INGEST-07).
DoeOpenDataAdapter declares portals = ['doe-opendata'] additively,
no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SourceType now covers 'doe-opendata' | 'ai-netserver' | 'cosinex-dtvp'
(13-RESEARCH Pattern 1) so the Plan 13-04/05 adapters can register
without further type-contract changes. NormalizedTenderFields gains an
optional fingerprint field for the SCHEMA-03 dedup resolver (Plan
13-03) to populate later. tsc --noEmit clean; full API suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Additive schema change (SCHEMA-03/D-03/D-04): new model TenderSource
(1:n Tender, @@unique[sourcePortal, sourceNoticeId], onDelete Cascade)
and a nullable Tender.fingerprint column + index. dedupKey stays
unchanged as the SCHEMA-02 upsert target.
Migration 20260723120000_add_tender_source applies in strict order
(Pitfall 5): table+column create, then one TenderSource row per
pre-existing Tender via SQL INSERT/SELECT, then the unique constraint.
Applied locally against the tessera dev DB (container IP, no host
port) — verified via psql: TenderSource count == Tender count == 2851.
backfill-tender-source.ts is a one-time script that computes
Tender.fingerprint via the Task-1 tenderFingerprint() function
(Decimal->number conversion for estimatedValue, T-13-01-03) — run via
the compiled dist/ output (source uses standard extensionless TS
imports for tsc compatibility). Confirmed: 2851/2851 rows backfilled,
idempotent re-run verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GREEN: title+buyer dominant, CPV division (order-independent, dedup'd),
value bucketed by order-of-magnitude, deadline truncated to day-grain.
sha256 hex, deterministic, no I/O — foundation for the Task-2 backfill
and the Plan 13-03 dedup resolver's fingerprint tier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TenderNotificationPrefService: per-user digestInterval CRUD (default
'daily', upsert on @@unique userId, D-01/D-03)
- UpdateNotificationPrefDto: @IsIn(['daily','weekly','off']) validation (V5)
- GET/PUT /modules/tender-radar/notification-pref, declared before
@Get(':id') (route-order pitfall)
- instantAlert passthrough in Create/UpdateSavedSearchDto and
TenderSavedSearchService.create/update (NOTIFY-02, D-04)
- All pref/profile routes scoped strictly via extractTriageContext(req),
never from body/query (T-12-14, IDOR)
- Updated tenders.controller.spec.ts fakes for the new constructor param
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>