feat(17-02): RSS feeds get an owner — platform-wide vs personal (D-02)

- 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
This commit is contained in:
2026-08-12 11:37:56 +02:00
parent 71dcb302a3
commit adb72f611f
8 changed files with 346 additions and 91 deletions
@@ -0,0 +1,33 @@
-- Phase 17 (D-02): RSS-Feeds bekommen einen Besitzer. Bisher galt
-- "TenderRssFeedSource.url" plattformweit eindeutig -- ab dieser Migration
-- gibt es zwei Sorten: plattformweite Feeds (userId = NULL, von der
-- Administration gepflegt, gilt fuer alle -- dazu gehoert der seit Phase 14
-- gesetzte service.bund.de-Feed) und persoenliche Feeds (userId gesetzt,
-- gehoeren genau einem Nutzer). Bestandszeilen werden NICHT angefasst --
-- sie behalten einen leeren Besitzer und sind damit plattformweit, also
-- genau der heutige Zustand (17-CONTEXT.md, offener Punkt 2).
--
-- tenantId ist -- wie bei TenderEmailConfig aus Plan 17-01 -- ein
-- denormalisiertes Feld des Besitzers, leer bei plattformweiten Feeds;
-- es traegt spaeter die D-13-Herkunftsmarkierung fuer Ausschreibungen aus
-- persoenlichen Feeds (D-06, 17-02-PLAN.md).
-- 1. Beide Spalten ohne Pflichtwert -- Bestandszeilen bleiben unangetastet
-- (leerer Besitzer = plattformweit, entspricht dem Ist-Zustand).
ALTER TABLE "TenderRssFeedSource" ADD COLUMN "userId" TEXT;
ALTER TABLE "TenderRssFeedSource" ADD COLUMN "tenantId" TEXT;
-- 2. Alte Eindeutigkeitsregel "eine Adresse plattformweit" aufheben --
-- zwei Nutzer sollen dieselbe Adresse unabhaengig voneinander verfolgen
-- koennen.
DROP INDEX "TenderRssFeedSource_url_key";
-- 3. Neue Eindeutigkeitsregel: eine Adresse ist je Besitzer eindeutig.
-- Leere Werte gelten in PostgreSQL in einer Eindeutigkeitsregel als
-- jeweils verschieden -- das deckt daher nur persoenliche Feeds ab,
-- nicht doppelte plattformweite Feeds (siehe Begruendung 17-02-PLAN.md
-- Objective, T-17-13, bewusst hingenommen).
CREATE UNIQUE INDEX "TenderRssFeedSource_userId_url_key" ON "TenderRssFeedSource"("userId", "url");
-- CreateIndex
CREATE INDEX "TenderRssFeedSource_userId_idx" ON "TenderRssFeedSource"("userId");
+26 -5
View File
@@ -546,19 +546,40 @@ model TenderSourcePollConfig {
updatedAt DateTime @updatedAt
}
// Phase 14, Plan 02 (INGEST-04, D-14) — admin-managed GLOBAL RSS feed list.
// Deliberately NO tenantId (mirrors TenderSourcePollConfig's global/
// RLS-exempt stance, D-08: RSS feeds are public and identical for every
// tenant). Feed URLs are RUNTIME admin input — unlike the hardcoded
// Phase 14, Plan 02 (INGEST-04, D-14) — admin-managed RSS feed list.
// Feed URLs are RUNTIME admin/user input — unlike the hardcoded
// NETSERVER_PORTALS/COSINEX_BASE_URL constants, the code-level
// SourceRegistry denylist gate does NOT cover this data (RESEARCH.md
// Pitfall 3); TenderRssFeedSourceService enforces a SEPARATE save-time
// hostname/SSRF guard (T-14-02-01) on create/update.
//
// Phase 17, Plan 02 (D-02): the list is now two-part. `userId = null` is a
// PLATFORM-WIDE feed — admin-managed, active for every tenant (mirrors
// TenderSourcePollConfig's global/RLS-exempt stance, D-08); this is the
// bucket the service.bund.de default (seeded since Phase 14) lives in, and
// stays there unmigrated (17-CONTEXT.md, offener Punkt 2). `userId` set is
// a PERSONAL feed owned by exactly one user. `tenantId` is denormalized
// from the owner (same role as `TenderEmailConfig.tenantId`, Phase 17 Plan
// 01) — null for platform-wide feeds, set for personal feeds so
// `RssAdapter` can tag their ingested `Tender` rows with the existing D-13
// `ownerTenantId` origin marking (D-06, this plan).
//
// `@@unique([userId, url])` replaces the old `url @unique`: two different
// users may now follow the same address. NULL is distinct per-row in a
// PostgreSQL unique index, so this does NOT prevent the same URL being
// registered twice platform-wide (both userId NULL) — deliberately
// accepted, see 17-02-PLAN.md Objective (T-17-13): cross-source dedup
// absorbs the duplicate, only costing one extra fetch.
model TenderRssFeedSource {
id String @id @default(uuid())
url String @unique
url String
label String
isActive Boolean @default(true)
userId String? // null = platform-wide (D-02); set = personal feed owner
tenantId String? // denormalized owner's tenant, null for platform-wide feeds (D-06)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, url])
@@index([userId])
}