- 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
- 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
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>
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>
- 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>
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>
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>
- 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>
Adds GET/POST /saved-searches and PATCH/DELETE /saved-searches/:searchId,
registers TenderSavedSearchService as a module provider, and wires it into
the controller via extractTriageContext (userId/tenantId from the auth
context, never the body/query — T-11-14/V4 IDOR). Static saved-searches
routes are declared before @Get(':id') (Pitfall 5/T-11-16); mutation routes
use :searchId to avoid ambiguity with the Tender :id param.
Also fixes a Prisma InputJsonValue type mismatch in
TenderSavedSearchService (Rule 1 — caught by tsc --noEmit, same cast
convention as dashboard.service.ts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the batch-triage read/write routes (declared before @Get(':id') per
the route-order pitfall, T-11-13) and wires them through
TenderTriageService with userId/tenantId always derived from the request
context, never the body (T-11-10 / V4 IDOR). Extends TenderQueryDto/
buildTenderWhere with favOnly (UI-04): the controller resolves the
current user's favorited tenderIds server-side before building the
where-clause, and an empty favorites list yields zero matches instead of
the unfiltered catalog. Both batch-ids and favIds in-lists are bounded
(T-11-11 DoS). tenders.controller.spec.ts constructor calls updated for
the new TenderTriageService dependency (Rule 3 — required to keep the
existing suite compiling/passing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
listTenders now delegates where/orderBy composition to
tender-query.builder.ts (buildTenderWhere/buildOrderBy) instead of the
fixed status/publishedAt clause; pagination bounds unchanged (T-10-15).
New GET /modules/tender-radar/coverage handler returns active-tender
counts grouped by sourcePortal (D-12, UI-05 coverage banner data
source). Declared before @Get(':id') — same static-route-before-:id
convention as source-config (Pitfall 5, Phase-10 regression guard).
Extends tenders.controller.spec.ts: sort whitelist pass-through,
unknown-sort fallback, pagination skip/take, coverage response shape,
and a declaration-order regression test for getCoverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin source-config settings form failed to load with "Failed to
fetch tender-radar source config". Network trace showed
GET /modules/tender-radar/source-config returning 404.
Root cause: NestJS RouterExplorer maps routes in method-declaration
order. `@Get(':id')` was declared before `@Get('source-config')`, so
the param route captured "source-config" as an id and shadowed the
static handler (401 unauthenticated, 404 past the guard — no Tender
with id "source-config").
Fix: declare `@Get('source-config')` before `@Get(':id')`. Add a
declaration-order regression test — unit tests call controller methods
directly, bypass routing, and could never catch route shadowing.
Verified live: settings form now loads real config, interval save
persists and live-re-registers the scheduler (INGEST-06). Phase 10
verification raised human_needed -> passed after full browser UAT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET / and GET /🆔 paginated global Tender catalog, @UseModule('tender-radar')-gated, never row-scoped by tenant id
- GET/PUT /source-config: @Roles(ADMIN, SUPER_ADMIN)-guarded singleton doe-opendata config
- PUT /source-config live-applies pollIntervalMin/isActive to TenderSchedulerService (setInterval/stopJob, no tenant arg) — INGEST-06
- Registered TendersController in TendersModule.controllers