diff --git a/apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql b/apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql new file mode 100644 index 0000000..f9e7bd9 --- /dev/null +++ b/apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4cbb475..4c0e2bc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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]) } diff --git a/apps/api/src/tenders/dto/tender-rss-feed.dto.ts b/apps/api/src/tenders/dto/tender-rss-feed.dto.ts index ec38df9..5d19c7c 100644 --- a/apps/api/src/tenders/dto/tender-rss-feed.dto.ts +++ b/apps/api/src/tenders/dto/tender-rss-feed.dto.ts @@ -1,5 +1,6 @@ import { IsBoolean, + IsIn, IsOptional, IsString, IsUrl, @@ -8,13 +9,14 @@ import { } from 'class-validator'; /** - * DTO for admin-managed RSS feed sources (`TenderRssFeedSource`, D-14/D-08). + * DTO for RSS feed sources (`TenderRssFeedSource`, D-14/D-08; ownership + * split personal/platform-wide since Phase 17, Plan 02, D-02). * * `@IsUrl` here is only a COARSE well-formedness check (http/https, * protocol required). The SUBSTANTIVE SSRF/denylist guard — rejecting * DENYLISTED_PORTALS hostnames and private/loopback hosts — is enforced in * `TenderRssFeedSourceService.assertUrlAllowed()`, NOT here (RESEARCH.md - * Pitfall 3: an admin-supplied RSS feed URL is runtime data, added long + * Pitfall 3: a user-supplied RSS feed URL is runtime data, added long * after `SourceRegistry.register()`'s DI-boot-time denylist check runs — * this DTO alone provides zero protection against a feed URL pointing at * vergabe24/aumass or an internal host). `require_tld: false` deliberately @@ -38,4 +40,15 @@ export class TenderRssFeedDto { @IsOptional() @IsBoolean() isActive?: boolean; + + /** + * A WISH only, never trusted as authorization by itself (D-02): 'platform' + * additionally requires the caller to hold ADMIN/SUPER_ADMIN, enforced + * server-side in `TendersController.createRssFeed` — never here or in the + * service. Default 'personal' so an ordinary module user's POST creates a + * feed they own without needing to know this field exists. + */ + @IsOptional() + @IsIn(['personal', 'platform']) + scope?: 'personal' | 'platform'; } diff --git a/apps/api/src/tenders/tender-rss-feed.service.spec.ts b/apps/api/src/tenders/tender-rss-feed.service.spec.ts index 7a715a8..511f1ee 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.spec.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.spec.ts @@ -4,24 +4,40 @@ import { TenderRssFeedSourceService } from './tender-rss-feed.service'; /** * TenderRssFeedSourceService.spec — proves the save-time hostname/SSRF - * guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-admin-supplied + * guard (T-14-02-01, D-14/RESEARCH.md Pitfall 3): a runtime-user-supplied * RSS feed URL is NOT covered by the code-level SourceRegistry denylist * gate (that only checks an adapter's statically-declared `portals` array * at DI-boot time) — this service is the separate, independent - * enforcement point. + * enforcement point. Also proves the ownership split introduced in Phase + * 17, Plan 02 (D-02): `listForUser` returns platform-wide feeds plus the + * caller's own, `createForUser` stamps an owner, `createPlatform` leaves + * ownership empty. * * Uses the same hand-rolled prisma-shaped fake convention as * tender-notification-pref.service.spec.ts / tender-saved-search.service.spec.ts - * (in-memory Map, no live DB connection). + * (in-memory Map, no live DB connection). Unlike the pre-Phase-17 fake, this + * one EVALUATES the `where` clause (OR/AND/equality) so `listForUser`'s + * ownership scoping is actually proven, not just assumed. */ +function matchesWhere(row: any, where: any): boolean { + if (!where) return true; + if ('OR' in where) { + return (where.OR as any[]).some((clause) => matchesWhere(row, clause)); + } + if ('AND' in where) { + return (where.AND as any[]).every((clause) => matchesWhere(row, clause)); + } + return Object.entries(where).every(([key, value]) => row[key] === value); +} + function makeFakePrisma() { const rows = new Map(); let seq = 0; return { tenderRssFeedSource: { - findMany: async ({ orderBy }: any) => { - const all = [...rows.values()]; + findMany: async ({ where, orderBy }: any = {}) => { + let all = [...rows.values()].filter((row) => matchesWhere(row, where)); if (orderBy?.createdAt === 'asc') { all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); } @@ -31,6 +47,8 @@ function makeFakePrisma() { seq += 1; const row = { id: `feed-${seq}`, + userId: null, + tenantId: null, createdAt: new Date(Date.now() + seq), updatedAt: new Date(Date.now() + seq), ...data, @@ -49,13 +67,13 @@ function makeFakePrisma() { } describe('TenderRssFeedSourceService', () => { - describe('create() — save-time hostname/SSRF guard (T-14-02-01)', () => { + describe('createPlatform() — save-time hostname/SSRF guard (T-14-02-01)', () => { it('rejects a vergabe24.de feed URL (denylisted portal, D-14)', async () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ + service.createPlatform({ url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24', }), @@ -68,7 +86,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'https://aumass.de/feed', label: 'aumass' }), + service.createPlatform({ url: 'https://aumass.de/feed', label: 'aumass' }), ).rejects.toThrow(BadRequestException); expect(prisma.__rows.size).toBe(0); }); @@ -78,7 +96,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ + service.createPlatform({ url: 'https://feeds.vergabe24.de/rss.xml', label: 'vergabe24-sub', }), @@ -89,7 +107,7 @@ describe('TenderRssFeedSourceService', () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); - const created = await service.create({ + const created = await service.createPlatform({ url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', label: 'service-bund', }); @@ -106,7 +124,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'file:///etc/passwd', label: 'local-file' }), + service.createPlatform({ url: 'file:///etc/passwd', label: 'local-file' }), ).rejects.toThrow(BadRequestException); }); @@ -115,7 +133,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'not-a-url', label: 'broken' }), + service.createPlatform({ url: 'not-a-url', label: 'broken' }), ).rejects.toThrow(BadRequestException); }); @@ -124,7 +142,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ + service.createPlatform({ url: 'http://127.0.0.1:8080/internal-feed.xml', label: 'internal', }), @@ -136,7 +154,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'http://localhost:3000/feed', label: 'x' }), + service.createPlatform({ url: 'http://localhost:3000/feed', label: 'x' }), ).rejects.toThrow(BadRequestException); }); @@ -145,7 +163,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'http://10.0.0.5/feed', label: 'x' }), + service.createPlatform({ url: 'http://10.0.0.5/feed', label: 'x' }), ).rejects.toThrow(BadRequestException); }); @@ -154,7 +172,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'http://192.168.1.1/feed', label: 'x' }), + service.createPlatform({ url: 'http://192.168.1.1/feed', label: 'x' }), ).rejects.toThrow(BadRequestException); }); @@ -163,7 +181,7 @@ describe('TenderRssFeedSourceService', () => { const service = new TenderRssFeedSourceService(prisma as any); await expect( - service.create({ url: 'http://[::1]/feed', label: 'x' }), + service.createPlatform({ url: 'http://[::1]/feed', label: 'x' }), ).rejects.toThrow(BadRequestException); }); @@ -171,7 +189,7 @@ describe('TenderRssFeedSourceService', () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); - const created = await service.create({ + const created = await service.createPlatform({ url: 'https://example-tenders.invalid/rss.xml', label: 'inactive-feed', isActive: false, @@ -181,29 +199,83 @@ describe('TenderRssFeedSourceService', () => { }); }); - describe('list()/remove()', () => { - it('list() returns created feeds ordered by createdAt asc', async () => { + describe('listForUser()/createForUser()/remove() — ownership split (Phase 17, Plan 02, D-02)', () => { + it('listForUser() returns platform-wide feeds ordered by createdAt asc when the caller has none of their own', async () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); - await service.create({ + await service.createPlatform({ url: 'https://a.example-tenders.invalid/rss.xml', label: 'a', }); - await service.create({ + await service.createPlatform({ url: 'https://b.example-tenders.invalid/rss.xml', label: 'b', }); - const list = await service.list(); + const list = await service.listForUser('u-anyone'); expect(list.map((f: any) => f.label)).toEqual(['a', 'b']); }); + it('listForUser(userId) includes platform-wide feeds plus this user\'s own personal feeds, never another user\'s', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + + await service.createPlatform({ + url: 'https://platform.example-tenders.invalid/rss.xml', + label: 'platform-feed', + }); + await service.createForUser( + { userId: 'user-a', tenantId: 'tenant-a' }, + { url: 'https://a-only.example-tenders.invalid/rss.xml', label: 'a-only' }, + ); + await service.createForUser( + { userId: 'user-b', tenantId: 'tenant-b' }, + { url: 'https://b-only.example-tenders.invalid/rss.xml', label: 'b-only' }, + ); + + const listForA = await service.listForUser('user-a'); + expect(listForA.map((f: any) => f.label).sort()).toEqual(['a-only', 'platform-feed']); + + const listForB = await service.listForUser('user-b'); + expect(listForB.map((f: any) => f.label).sort()).toEqual(['b-only', 'platform-feed']); + }); + + it('createForUser() stamps the owner\'s userId/tenantId; createPlatform() leaves both null', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + + const personal = await service.createForUser( + { userId: 'user-a', tenantId: 'tenant-a' }, + { url: 'https://mine.example-tenders.invalid/rss.xml', label: 'mine' }, + ); + const platform = await service.createPlatform({ + url: 'https://platform-only.example-tenders.invalid/rss.xml', + label: 'platform-only', + }); + + expect(personal.userId).toBe('user-a'); + expect(personal.tenantId).toBe('tenant-a'); + expect(platform.userId).toBeNull(); + expect(platform.tenantId).toBeNull(); + }); + + it('two different users may each register the same URL independently (D-02)', async () => { + const prisma = makeFakePrisma(); + const service = new TenderRssFeedSourceService(prisma as any); + const sameUrl = 'https://shared.example-tenders.invalid/rss.xml'; + + await service.createForUser({ userId: 'user-a', tenantId: 'tenant-a' }, { url: sameUrl, label: 'a-copy' }); + await service.createForUser({ userId: 'user-b', tenantId: 'tenant-b' }, { url: sameUrl, label: 'b-copy' }); + + expect(prisma.__rows.size).toBe(2); + }); + it('remove() deletes the feed by id', async () => { const prisma = makeFakePrisma(); const service = new TenderRssFeedSourceService(prisma as any); - const created = await service.create({ + const created = await service.createPlatform({ url: 'https://c.example-tenders.invalid/rss.xml', label: 'c', }); diff --git a/apps/api/src/tenders/tender-rss-feed.service.ts b/apps/api/src/tenders/tender-rss-feed.service.ts index 0f97055..cf6e675 100644 --- a/apps/api/src/tenders/tender-rss-feed.service.ts +++ b/apps/api/src/tenders/tender-rss-feed.service.ts @@ -4,31 +4,71 @@ import type { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import { DENYLISTED_PORTALS } from './source-registry'; /** - * TenderRssFeedSourceService — admin CRUD for the GLOBAL RSS feed list - * (`TenderRssFeedSource`, D-14/D-08). No `forTenant()`/RLS — this is - * platform-wide config, mirroring `TenderSourcePollConfig`'s stance. + * TenderRssFeedSourceService — CRUD for the RSS feed list + * (`TenderRssFeedSource`, D-14/D-08). No `forTenant()`/RLS — ownership is + * expressed via the nullable `userId`/`tenantId` columns, not tenant + * middleware (mirrors `TenderEmailConfig`'s per-user stance since Phase + * 17, Plan 01). + * + * Phase 17, Plan 02 (D-02): the feed list is two-part now — platform-wide + * feeds (`userId = null`, admin-managed, active for every tenant — this is + * where the service.bund.de default seeded since Phase 14 lives) and + * personal feeds (`userId` set, owned by exactly one user). Every module + * user can create a personal feed via `createForUser`; only ADMIN/ + * SUPER_ADMIN may create a platform-wide one via `createPlatform` — that + * role check happens in the CONTROLLER (T-17-08), not here, mirroring + * `saveEmailConfig`'s "auth context resolved by the caller" convention. * * Save-time hostname/SSRF guard (T-14-02-01, RESEARCH.md Pitfall 3): RSS - * feed URLs are RUNTIME admin input, added long after + * feed URLs are RUNTIME user input, added long after * `SourceRegistry.register()`'s DI-boot-time `DENYLISTED_PORTALS` check * runs — that gate provides ZERO protection here. This service is the * SEPARATE, independent enforcement point: reject non-http(s) schemes, * reject any hostname containing a `DENYLISTED_PORTALS` entry (same * constant as the code-level gate — single source of truth, D-14), and * reject private/loopback hosts (SSRF guard, analog to T-10-06). Runs on - * BOTH create and update paths. + * EVERY write path (T-17-09) — it matters more now that ordinary users, + * not just admins, can reach it. */ @Injectable() export class TenderRssFeedSourceService { constructor(private readonly prisma: PrismaService) {} - async list() { + /** + * Every platform-wide feed (`userId = null`) plus this user's own + * personal feeds, oldest first (D-02). + */ + async listForUser(userId: string) { return this.prisma.tenderRssFeedSource.findMany({ + where: { OR: [{ userId: null }, { userId }] }, orderBy: { createdAt: 'asc' }, }); } - async create(dto: TenderRssFeedDto) { + /** Creates a personal feed owned by `ctx.userId` (D-02). */ + async createForUser( + ctx: { userId: string; tenantId: string }, + dto: TenderRssFeedDto, + ) { + this.assertUrlAllowed(dto.url); + + return this.prisma.tenderRssFeedSource.create({ + data: { + url: dto.url, + label: dto.label, + isActive: dto.isActive ?? true, + userId: ctx.userId, + tenantId: ctx.tenantId, + }, + }); + } + + /** + * Creates a platform-wide feed (`userId`/`tenantId` stay null). Callers + * MUST verify ADMIN/SUPER_ADMIN before calling this — this method itself + * enforces no authorization (T-17-08, done in TendersController). + */ + async createPlatform(dto: TenderRssFeedDto) { this.assertUrlAllowed(dto.url); return this.prisma.tenderRssFeedSource.create({ diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 979fe84..33c1d39 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -89,15 +89,31 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') { /** * Fake TenderRssFeedSourceService for controller-level wiring tests (Plan - * 14-02, Task 3). Default stubs echo/list-nothing; individual tests - * override via `.mockResolvedValueOnce`/reassigning the mock — including - * `create` rejecting with BadRequestException to prove the denylist error - * surfaces through the controller unchanged. + * 14-02, Task 3; ownership split since Phase 17, Plan 02, D-02). Default + * stubs echo/list-nothing; individual tests override via + * `.mockResolvedValueOnce`/reassigning the mock — including `createForUser`/ + * `createPlatform` rejecting with BadRequestException to prove the + * denylist error surfaces through the controller unchanged. */ function makeFakeRssFeedService() { return { - list: vi.fn(async () => [] as any[]), - create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })), + listForUser: vi.fn(async (_userId: string) => [] as any[]), + createForUser: vi.fn( + async (ctx: { userId: string; tenantId: string }, dto: any) => ({ + id: 'feed-1', + isActive: true, + userId: ctx.userId, + tenantId: ctx.tenantId, + ...dto, + }), + ), + createPlatform: vi.fn(async (dto: any) => ({ + id: 'feed-1', + isActive: true, + userId: null, + tenantId: null, + ...dto, + })), remove: vi.fn(async (_id: string) => ({ success: true })), }; } @@ -871,13 +887,14 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', }); }); -describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => { - it('GET /rss-feeds delegates to tenderRssFeedSource.list()', async () => { +describe('TendersController — RSS-feeds personal + platform-wide (Plan 14-02 D-14/D-08, ownership split Phase 17 Plan 02 D-02)', () => { + it('GET /rss-feeds delegates to tenderRssFeedSource.listForUser(userId) and maps isPlatformWide, stripping userId', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); - rssFeedService.list.mockResolvedValueOnce([ - { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' }, + rssFeedService.listForUser.mockResolvedValueOnce([ + { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', userId: null }, + { id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', userId: 'u1' }, ]); const controller = new TendersController( prisma as any, @@ -889,15 +906,17 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', ( makeFakeEmailConfigService() as any, ); - const result = await controller.listRssFeeds(); + const result = await controller.listRssFeeds(makeFakeRequest('u1', 'tenant1')); - expect(rssFeedService.list).toHaveBeenCalledTimes(1); + expect(rssFeedService.listForUser).toHaveBeenCalledWith('u1'); expect(result).toEqual([ - { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' }, + { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund', isPlatformWide: true }, + { id: 'f2', url: 'https://mine.invalid/rss.xml', label: 'mine', isPlatformWide: false }, ]); + expect(result.every((f: any) => !('userId' in f))).toBe(true); }); - it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => { + it('POST /rss-feeds with scope omitted creates a personal feed via createForUser({userId,tenantId}, dto)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); @@ -911,17 +930,18 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', ( makeFakeEmailConfigService() as any, ); - const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any; - await controller.createRssFeed(dto); + const dto = { url: 'https://mine.invalid/rss.xml', label: 'mine' } as any; + await controller.createRssFeed(dto, makeFakeRequest('u1', 'tenant1')); - expect(rssFeedService.create).toHaveBeenCalledWith(dto); + expect(rssFeedService.createForUser).toHaveBeenCalledWith({ userId: 'u1', tenantId: 'tenant1' }, dto); + expect(rssFeedService.createPlatform).not.toHaveBeenCalled(); }); it('POST /rss-feeds surfaces a BadRequestException from the service when the URL is denylisted (D-14)', async () => { const prisma = makeFakePrisma(); const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; const rssFeedService = makeFakeRssFeedService(); - rssFeedService.create.mockRejectedValueOnce( + rssFeedService.createForUser.mockRejectedValueOnce( new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"), ); const controller = new TendersController( @@ -935,10 +955,10 @@ describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', ( ); await expect( - controller.createRssFeed({ - url: 'https://www.vergabe24.de/rss.xml', - label: 'vergabe24', - } as any), + controller.createRssFeed( + { url: 'https://www.vergabe24.de/rss.xml', label: 'vergabe24' } as any, + makeFakeRequest('u1', 'tenant1'), + ), ).rejects.toBeInstanceOf(BadRequestException); }); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 23fb63c..bc11b58 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -70,6 +70,17 @@ const DOE_SOURCE_TYPE = 'doe-opendata'; * apply the D-13 OR[global, mine] visibility filter for PRIVATE * (email-alert) tenders — public tenders (D-03) remain visible to every * tenant exactly as before; that part of D-13 is unaffected by D-01. + * + * Phase 14, Plan 02 (INGEST-04, D-14) originally made `GET`/`POST`/`DELETE + * /rss-feeds` per-handler `@Roles(ADMIN, SUPER_ADMIN)`-guarded, GLOBAL-only. + * Phase 17, Plan 02 (D-02) changed this: the feed list is now two-part — + * platform-wide feeds (unchanged, admin-only to create/delete) and personal + * feeds any module user may create and delete for themselves. `GET`/`POST + * /rss-feeds` are `@UseModule('tender-radar')`-gated; `POST`'s + * `scope: 'platform'` path re-checks ADMIN/SUPER_ADMIN inline + * (`extractTriageContext`'s `role`, T-17-08) since the route itself is no + * longer admin-only. `DELETE /rss-feeds/:feedId` ownership enforcement is + * described at that handler. */ @Controller('modules/tender-radar') export class TendersController { @@ -93,14 +104,21 @@ export class TendersController { ) {} /** - * Extracts (userId, tenantId) for the per-user Triage routes — same + * Extracts (userId, tenantId, role) for the per-user routes — same * pattern as FavoritesController.extractContext (T-08-06): userId/ - * tenantId are ALWAYS read from the authenticated request context, never - * from a client-supplied body/query field (T-11-10 / V4 — IDOR). + * tenantId/role are ALWAYS read from the authenticated request context, + * never from a client-supplied body/query field (T-11-10 / V4 — IDOR). + * + * `role` was added in Phase 17, Plan 02 (D-02): `createRssFeed` needs the + * caller's role to decide whether a `scope: 'platform'` request is + * allowed (T-17-08), read from the SAME place `RolesGuard` reads it + * (`roles.guard.ts`) — one single spot in this controller resolves + * account data from the request. */ private extractTriageContext(req: Request) { const userId = (req as any).user?.id; const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; + const role = (req as any).user?.role; if (!tenantId) { throw new ForbiddenException('No tenant context'); @@ -109,7 +127,7 @@ export class TendersController { throw new ForbiddenException('No user context'); } - return { userId, tenantId }; + return { userId, tenantId, role }; } /** @@ -227,35 +245,60 @@ export class TendersController { return { ok: true }; } - // ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ─────────────── + // ─── RSS-Feeds (ModuleGuard-gated, personal + platform-wide, Phase 17 D-02) ─ /** - * GET /modules/tender-radar/rss-feeds — list every admin-managed RSS feed - * (global, no tenantId — D-08). `@Roles`-guarded: this is a - * platform-admin action, not a per-tenant module feature, same stance as - * `source-config` above. + * GET /modules/tender-radar/rss-feeds — every platform-wide feed plus the + * requesting user's own personal feeds (D-02). Phase 17: replaced + * `@Roles(ADMIN, SUPER_ADMIN)` with `@UseModule('tender-radar')` — every + * module user can see (and add) their own feeds now, not just admins. + * Each entry gets a derived `isPlatformWide` flag; the raw `userId` + * ownership field is stripped from the response (T-17-12) — the UI has + * no need for it. * * MUST be declared before `@Get(':id')` below — same route-order pitfall - * as `source-config`/`coverage`/`triage`/... above (Pitfall 5): NestJS - * matches routes in declaration order, so a `@Get(':id')` placed first - * would capture "rss-feeds" as an id and shadow this handler. + * as `source-config`/`coverage`/`triage`/... above (Pitfall 5). Route + * position is UNCHANGED from before Phase 17 — no new route was added, + * only the guard and the handler body. */ @Get('rss-feeds') - @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async listRssFeeds() { - return this.tenderRssFeedSource.list(); + @UseModule('tender-radar') + async listRssFeeds(@Req() req: Request) { + const { userId } = this.extractTriageContext(req); + const feeds = await this.tenderRssFeedSource.listForUser(userId); + + return feeds.map(({ userId: ownerUserId, ...rest }) => ({ + ...rest, + isPlatformWide: ownerUserId === null, + })); } /** - * POST /modules/tender-radar/rss-feeds — add a new global RSS feed URL. - * The save-time hostname/SSRF guard (D-14, T-14-02-01) lives in - * `TenderRssFeedSourceService.create()` — a denylisted/private-host URL - * surfaces as a 400 (BadRequestException) here, unchanged. + * POST /modules/tender-radar/rss-feeds — add a feed. `dto.scope` is a + * WISH, never trusted by itself: `scope: 'platform'` additionally + * requires the caller to hold ADMIN/SUPER_ADMIN (T-17-08), checked here + * against the SAME `role` source `RolesGuard` reads + * (`extractTriageContext`); any other/omitted scope creates a personal + * feed owned by the caller (D-02). The save-time hostname/SSRF guard + * (D-14, T-14-02-01) lives in `TenderRssFeedSourceService` and runs + * unchanged on both paths — a denylisted/private-host URL still surfaces + * as a 400 (BadRequestException) here. */ @Post('rss-feeds') - @Roles(Role.ADMIN, Role.SUPER_ADMIN) - async createRssFeed(@Body() dto: TenderRssFeedDto) { - return this.tenderRssFeedSource.create(dto); + @UseModule('tender-radar') + async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) { + const { userId, tenantId, role } = this.extractTriageContext(req); + + if (dto.scope === 'platform') { + if (role !== Role.ADMIN && role !== Role.SUPER_ADMIN) { + throw new ForbiddenException( + 'Nur Administratoren dürfen plattformweite RSS-Feeds anlegen.', + ); + } + return this.tenderRssFeedSource.createPlatform(dto); + } + + return this.tenderRssFeedSource.createForUser({ userId, tenantId }, dto); } /** diff --git a/apps/api/src/tenders/tenders.module.ts b/apps/api/src/tenders/tenders.module.ts index 1318ddf..4c7e521 100644 --- a/apps/api/src/tenders/tenders.module.ts +++ b/apps/api/src/tenders/tenders.module.ts @@ -282,20 +282,33 @@ export class TendersModule implements OnModuleInit { // single default-active service.bund.de feed row — the one genuinely // national/global RSS source. subreport-elvis has no single // canonical URL (per-municipality instances, RESEARCH.md Pitfall 2) - // — deliberately ZERO subreport-elvis rows seeded; admins add the - // municipality feeds relevant to them via the RSS-Feeds admin UI + // — deliberately ZERO subreport-elvis rows seeded; admins/users add + // the municipality feeds relevant to them via the RSS-Feeds UI // (Plan 14-02 Task 3). - await this.prisma.tenderRssFeedSource.upsert({ + // + // Phase 17, Plan 02 (D-02): "upsert on url" no longer works — the + // unique index moved to (userId, url), and Prisma's generated + // compound-unique input type REQUIRES userId as a plain `string` + // (not `string | null | undefined`), so a platform-wide row + // (userId = null) can never be addressed through it. Switched to + // "find a platform-wide row with this url first, only create if + // none exists" — an ordinary equality condition has no such + // requirement. Still idempotent across repeated app starts. + const existingServiceBundFeed = await this.prisma.tenderRssFeedSource.findFirst({ where: { + userId: null, url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', }, - update: {}, - create: { - url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', - label: 'service-bund', - isActive: true, - }, }); + if (!existingServiceBundFeed) { + await this.prisma.tenderRssFeedSource.create({ + data: { + url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml', + label: 'service-bund', + isActive: true, + }, + }); + } this.logger.log('service.bund.de default RSS feed seeded (active)'); } catch (error) { this.logger.error('Failed to seed service.bund.de RSS feed', error);