From 48a2dc1026511754a8983625374cb5e23a18f42e Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 13:29:07 +0200 Subject: [PATCH] feat(14-02): add RSS feed admin routes, API client, and settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/tenders/tenders.controller.spec.ts | 144 ++++++++++++- apps/api/src/tenders/tenders.controller.ts | 46 ++++ .../components/RssFeedListForm.test.tsx | 165 ++++++++++++++ .../settings/components/RssFeedListForm.tsx | 203 ++++++++++++++++++ .../modules/tender-radar/settings/page.tsx | 18 ++ apps/web/src/lib/tender-radar-api.ts | 93 ++++++++ 6 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index 3c7e9fb..79bd6a6 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi } from 'vitest'; import { TendersController } from './tenders.controller'; @@ -85,6 +85,21 @@ function makeFakeRequest(userId = 'u1', tenantId = 'tenant1') { return { user: { id: userId, tenantId }, tenantId } as any; } +/** + * 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. + */ +function makeFakeRssFeedService() { + return { + list: vi.fn(async () => [] as any[]), + create: vi.fn(async (dto: any) => ({ id: 'feed-1', isActive: true, ...dto })), + remove: vi.fn(async (_id: string) => ({ success: true })), + }; +} + /** * Fake TenderSavedSearchService for controller-level wiring tests (Plan * 11-06, Task 2). Default stubs return empty/echo results — individual @@ -131,6 +146,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({}); @@ -150,6 +166,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); const result = await controller.getTender('t1'); @@ -169,6 +186,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); const result = await controller.getTender('t1'); @@ -203,6 +221,7 @@ describe('TendersController — global read (not tenant-scoped)', () => { triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await expect(controller.getTender('missing')).rejects.toBeInstanceOf(NotFoundException); @@ -220,6 +239,7 @@ describe('TendersController — admin source-config applies live to the schedule triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.saveSourceConfig({ isActive: true, pollIntervalMin: 30 }); @@ -239,6 +259,7 @@ describe('TendersController — admin source-config applies live to the schedule triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.saveSourceConfig({ isActive: false }); @@ -276,6 +297,22 @@ describe('TendersController — route declaration order (static route before :id expect(coverageIdx).toBeLessThan(idIdx); }); + it('declares listRssFeeds/createRssFeed/removeRssFeed before getTender so GET /:id cannot shadow "rss-feeds" (Plan 14-02, Pitfall 5)', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const listIdx = methods.indexOf('listRssFeeds'); + const createIdx = methods.indexOf('createRssFeed'); + const removeIdx = methods.indexOf('removeRssFeed'); + const idIdx = methods.indexOf('getTender'); + + expect(listIdx).toBeGreaterThanOrEqual(0); + expect(createIdx).toBeGreaterThanOrEqual(0); + expect(removeIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(listIdx).toBeLessThan(idIdx); + expect(createIdx).toBeLessThan(idIdx); + expect(removeIdx).toBeLessThan(idIdx); + }); + it('declares listTriage and setTriage before getTender so GET /:id cannot shadow "triage" (Plan 11-05, Pitfall 5)', () => { const methods = Object.getOwnPropertyNames(TendersController.prototype); const listTriageIdx = methods.indexOf('listTriage'); @@ -325,6 +362,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, triageService as any, makeFakeSavedSearchService() as any, prefService as any, + makeFakeRssFeedService() as any, ); await controller.getNotificationPref(makeFakeRequest('u-real')); @@ -343,6 +381,7 @@ describe('TendersController — GET/PUT notification-pref (NOTIFY-01, per-user, triageService as any, makeFakeSavedSearchService() as any, prefService as any, + makeFakeRssFeedService() as any, ); await controller.setNotificationPref( @@ -366,6 +405,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listSavedSearches(makeFakeRequest('u-real')); @@ -384,6 +424,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.createSavedSearch( @@ -408,6 +449,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.updateSavedSearch( @@ -432,6 +474,7 @@ describe('TendersController — saved-searches CRUD (FILTER-06, per-user, T-11-1 triageService as any, savedSearchService as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); const result = await controller.removeSavedSearch('ss-1', makeFakeRequest('u1')); @@ -452,6 +495,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({ sort: 'deadline', q: 'Bau' } as any); @@ -480,6 +524,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({ sort: 'not-whitelisted' } as any); @@ -498,6 +543,7 @@ describe('TendersController — listTenders uses the query builder (sort whiteli triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({ page: 3, limit: 10 } as any); @@ -519,6 +565,7 @@ describe('TendersController — GET /coverage', () => { triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); const result = await controller.getCoverage(); @@ -547,6 +594,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTriage('t1, t2 ,t3', makeFakeRequest('u1')); @@ -564,6 +612,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTriage('t1', makeFakeRequest('u-real')); @@ -581,6 +630,7 @@ describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () = triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); const manyIds = Array.from({ length: 300 }, (_, i) => `t${i}`).join(','); @@ -602,6 +652,7 @@ describe('TendersController — PUT /triage (upsert, per-user, T-11-10)', () => triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.setTriage( @@ -628,6 +679,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -650,6 +702,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({ favOnly: true } as any, makeFakeRequest('u1')); @@ -670,6 +723,7 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', triageService as any, makeFakeSavedSearchService() as any, makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, ); await controller.listTenders({} as any, makeFakeRequest('u1')); @@ -677,3 +731,91 @@ describe('TendersController — listTenders favOnly wiring (UI-04, T-11-10/11)', expect(triageService.favoriteIds).not.toHaveBeenCalled(); }); }); + +describe('TendersController — RSS-feeds admin CRUD (Plan 14-02, D-14/D-08)', () => { + it('GET /rss-feeds delegates to tenderRssFeedSource.list()', 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' }, + ]); + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + rssFeedService as any, + ); + + const result = await controller.listRssFeeds(); + + expect(rssFeedService.list).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { id: 'f1', url: 'https://service.bund.de/rss.xml', label: 'service-bund' }, + ]); + }); + + it('POST /rss-feeds delegates to tenderRssFeedSource.create(dto)', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const rssFeedService = makeFakeRssFeedService(); + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + rssFeedService as any, + ); + + const dto = { url: 'https://service.bund.de/rss.xml', label: 'service-bund' } as any; + await controller.createRssFeed(dto); + + expect(rssFeedService.create).toHaveBeenCalledWith(dto); + }); + + 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( + new BadRequestException("Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt"), + ); + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + rssFeedService as any, + ); + + await expect( + controller.createRssFeed({ + url: 'https://www.vergabe24.de/rss.xml', + label: 'vergabe24', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('DELETE /rss-feeds/:feedId delegates to tenderRssFeedSource.remove(feedId)', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const rssFeedService = makeFakeRssFeedService(); + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + rssFeedService as any, + ); + + const result = await controller.removeRssFeed('feed-1'); + + expect(rssFeedService.remove).toHaveBeenCalledWith('feed-1'); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 8416ce3..fce0b9d 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -21,8 +21,10 @@ import { UpdateNotificationPrefDto } from './dto/notification-pref.dto'; import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto'; import { SourceConfigDto } from './dto/source-config.dto'; import { TenderQueryDto } from './dto/tender-query.dto'; +import { TenderRssFeedDto } from './dto/tender-rss-feed.dto'; import { TenderTriageDto } from './dto/tender-triage.dto'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; +import { TenderRssFeedSourceService } from './tender-rss-feed.service'; import { TenderSavedSearchService } from './tender-saved-search.service'; import { TenderSchedulerService } from './tender-scheduler.service'; import { TenderTriageService } from './tender-triage.service'; @@ -61,6 +63,7 @@ export class TendersController { private readonly tenderTriage: TenderTriageService, private readonly tenderSavedSearch: TenderSavedSearchService, private readonly tenderNotificationPref: TenderNotificationPrefService, + private readonly tenderRssFeedSource: TenderRssFeedSourceService, ) {} /** @@ -156,6 +159,49 @@ export class TendersController { return config; } + // ─── RSS-Feeds admin CRUD (Roles-guarded, GLOBAL, D-08/D-14) ─────────────── + + /** + * 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. + * + * 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. + */ + @Get('rss-feeds') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async listRssFeeds() { + return this.tenderRssFeedSource.list(); + } + + /** + * 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('rss-feeds') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async createRssFeed(@Body() dto: TenderRssFeedDto) { + return this.tenderRssFeedSource.create(dto); + } + + /** + * DELETE /modules/tender-radar/rss-feeds/:feedId — remove a global RSS + * feed. Uses `:feedId` (not `:id`) so this route can never be confused + * with the Tender `:id` route below (Pitfall 5, same convention as + * `saved-searches/:searchId`). + */ + @Delete('rss-feeds/:feedId') + @Roles(Role.ADMIN, Role.SUPER_ADMIN) + async removeRssFeed(@Param('feedId') feedId: string) { + return this.tenderRssFeedSource.remove(feedId); + } + /** * GET /modules/tender-radar/coverage — distribution of active tenders * by sourcePortal (D-12, UI-05). Feeds the frontend CoverageBanner so a diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx new file mode 100644 index 0000000..768abeb --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx @@ -0,0 +1,165 @@ +import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock @/lib/tender-radar-api +const mockListRssFeeds = vi.fn(); +const mockCreateRssFeed = vi.fn(); +const mockDeleteRssFeed = vi.fn(); + +vi.mock('@/lib/tender-radar-api', () => ({ + listRssFeeds: (...args: unknown[]) => mockListRssFeeds(...args), + createRssFeed: (...args: unknown[]) => mockCreateRssFeed(...args), + deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args), +})); + +afterEach(() => { + cleanup(); + mockListRssFeeds.mockReset(); + mockCreateRssFeed.mockReset(); + mockDeleteRssFeed.mockReset(); + vi.restoreAllMocks(); +}); + +const FEED_SERVICE_BUND = { + id: 'feed-1', + url: 'https://www.service.bund.de/rss.xml', + label: 'service-bund', + isActive: true, + createdAt: '2026-07-23T00:00:00.000Z', + updatedAt: '2026-07-23T00:00:00.000Z', +}; + +describe('RssFeedListForm', () => { + it('loads feeds on mount and renders each one with its label and URL', async () => { + mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument(); + }); + expect(screen.getByText(FEED_SERVICE_BUND.url)).toBeInTheDocument(); + expect(mockListRssFeeds).toHaveBeenCalledTimes(1); + }); + + it('renders an empty-state message when there are no feeds', async () => { + mockListRssFeeds.mockResolvedValue([]); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect( + screen.getByText(/Noch keine RSS-Feeds hinterlegt/i), + ).toBeInTheDocument(); + }); + }); + + it('filling the add form and clicking "Feed hinzufügen" calls createRssFeed and appends the new feed to the list', async () => { + mockListRssFeeds.mockResolvedValue([]); + mockCreateRssFeed.mockResolvedValue({ + id: 'feed-2', + url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615', + label: 'subreport-neuss', + isActive: true, + createdAt: '2026-07-23T00:00:00.000Z', + updatedAt: '2026-07-23T00:00:00.000Z', + }); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/Feed-URL/i), { + target: { value: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615' }, + }); + fireEvent.change(screen.getByLabelText(/Bezeichnung/i), { + target: { value: 'subreport-neuss' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i })); + + await waitFor(() => { + expect(mockCreateRssFeed).toHaveBeenCalledWith({ + url: 'https://www.subreport-elvis.de/elvis/secure/rss.pl?id=4615', + label: 'subreport-neuss', + }); + }); + await waitFor(() => { + expect(screen.getByText('subreport-neuss', { exact: false })).toBeInTheDocument(); + }); + }); + + it('surfaces the backend denylist rejection message inline without adding a row', async () => { + mockListRssFeeds.mockResolvedValue([]); + mockCreateRssFeed.mockRejectedValue( + new Error( + "Der Host 'www.vergabe24.de' ist AGB-seitig für automatisierten Zugriff gesperrt (Denylist) und darf nicht als RSS-Feed hinterlegt werden.", + ), + ); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText(/Feed-URL/i), { + target: { value: 'https://www.vergabe24.de/rss.xml' }, + }); + fireEvent.change(screen.getByLabelText(/Bezeichnung/i), { + target: { value: 'vergabe24' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i })); + + await waitFor(() => { + expect(screen.getByText(/AGB-seitig für automatisierten Zugriff gesperrt/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument(); + }); + + it('rejects an empty URL/label client-side without calling createRssFeed', async () => { + mockListRssFeeds.mockResolvedValue([]); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /Feed hinzufügen/i })); + + await waitFor(() => { + expect( + screen.getByText(/URL und Bezeichnung sind erforderlich/i), + ).toBeInTheDocument(); + }); + expect(mockCreateRssFeed).not.toHaveBeenCalled(); + }); + + it('clicking "Entfernen" calls deleteRssFeed and removes the row from the list', async () => { + mockListRssFeeds.mockResolvedValue([FEED_SERVICE_BUND]); + mockDeleteRssFeed.mockResolvedValue(undefined); + + const { RssFeedListForm } = await import('./RssFeedListForm'); + render(); + + await waitFor(() => { + expect(screen.getByText('service-bund', { exact: false })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /entfernen/i })); + + await waitFor(() => { + expect(mockDeleteRssFeed).toHaveBeenCalledWith('feed-1'); + }); + await waitFor(() => { + expect(screen.getByText(/Noch keine RSS-Feeds hinterlegt/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx new file mode 100644 index 0000000..ff52068 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + type RssFeedSource, + createRssFeed, + deleteRssFeed, + listRssFeeds, +} from '@/lib/tender-radar-api'; + +/** + * Admin CRUD UI for the GLOBAL RSS feed list (Plan 14-02, INGEST-04, + * D-08/D-14). Unlike SourceConfigForm (a single platform-wide singleton), + * this is a list: an admin adds one row per RSS feed URL (service.bund.de, + * a subreport-elvis municipality feed, ...), each independently + * activatable/deletable. + * + * The save-time hostname/SSRF guard (T-14-02-01) lives entirely on the + * backend (TenderRssFeedSourceService) — this form does NOT duplicate that + * validation client-side; a rejected URL surfaces the backend's specific + * error message inline (e.g. "Der Host 'www.vergabe24.de' ist AGB-seitig + * für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed + * error message. + * + * Hardcoded German strings for now — full i18n is CONFIG-03 (Plan 14-05), + * matching the same intentional MVP-stub convention as SourceConfigForm. + */ +export function RssFeedListForm() { + const [feeds, setFeeds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [url, setUrl] = useState(''); + const [label, setLabel] = useState(''); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + + const [removingId, setRemovingId] = useState(null); + const [removeError, setRemoveError] = useState(null); + + const loadFeeds = () => { + setIsLoading(true); + listRssFeeds() + .then((result) => { + setFeeds(result); + setLoadError(null); + }) + .catch((err) => { + setLoadError( + err instanceof Error + ? err.message + : 'RSS-Feeds konnten nicht geladen werden', + ); + }) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => { + loadFeeds(); + }, []); + + const handleAdd = async () => { + setAddError(null); + + if (!url.trim() || !label.trim()) { + setAddError('URL und Bezeichnung sind erforderlich.'); + return; + } + + setIsAdding(true); + try { + const created = await createRssFeed({ url: url.trim(), label: label.trim() }); + setFeeds((prev) => [...prev, created]); + setUrl(''); + setLabel(''); + } catch (err) { + setAddError( + err instanceof Error + ? err.message + : 'RSS-Feed konnte nicht gespeichert werden', + ); + } finally { + setIsAdding(false); + } + }; + + const handleRemove = async (id: string) => { + setRemoveError(null); + setRemovingId(id); + try { + await deleteRssFeed(id); + setFeeds((prev) => prev.filter((feed) => feed.id !== id)); + } catch (err) { + setRemoveError( + err instanceof Error + ? err.message + : 'RSS-Feed konnte nicht entfernt werden', + ); + } finally { + setRemovingId(null); + } + }; + + const inputCls = + 'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground'; + const labelCls = 'mb-1 block text-sm text-foreground'; + + return ( +
+ {isLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
    + {feeds.map((feed) => ( +
  • +
    +

    + {feed.label}{' '} + + ({feed.isActive ? 'aktiv' : 'inaktiv'}) + +

    +

    + {feed.url} +

    +
    + +
  • + ))} + {feeds.length === 0 && ( +
  • + Noch keine RSS-Feeds hinterlegt. +
  • + )} +
+ )} + + {loadError &&

{loadError}

} + {removeError &&

{removeError}

} + +
+
+ + { + setUrl(e.target.value); + setAddError(null); + }} + /> +
+
+ + { + setLabel(e.target.value); + setAddError(null); + }} + /> +
+ + {addError &&

{addError}

} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx index 28e692a..4138772 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx @@ -6,6 +6,7 @@ import { saveNotificationPref, type NotificationPref, } from '@/lib/tender-radar-api'; +import { RssFeedListForm } from './components/RssFeedListForm'; import { SourceConfigForm } from './components/SourceConfigForm'; /** @@ -24,6 +25,11 @@ import { SourceConfigForm } from './components/SourceConfigForm'; * than split into a separate component — this is a single select, no * standalone unit-test coverage was called for in this plan. * + * Plan 14-02 (INGEST-04, D-08/D-09/D-14) adds an "RSS-Feeds" section below + * SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL + * (not per-tenant) RSS feed sources. Extends this existing settings page + * rather than building a new one (D-09). + * * No module-loader whitelist change is needed here: this is a standard * Next.js App Router route nested under the already-whitelisted * `tender-radar` module page (Plan 10-02). @@ -82,6 +88,18 @@ export default function TenderRadarSettingsPage() { +
+

+ RSS-Feeds +

+

+ Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder + eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten + gleich, nicht pro Mandant konfigurierbar. +

+ +
+

Benachrichtigungen diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts index b11b962..8dec559 100644 --- a/apps/web/src/lib/tender-radar-api.ts +++ b/apps/web/src/lib/tender-radar-api.ts @@ -353,3 +353,96 @@ export async function saveNotificationPref( if (!res.ok) throw new Error('Failed to save notification preference'); return res.json(); } + +/** + * A single admin-managed, GLOBAL RSS feed source (Plan 14-02, D-08/D-14). + * Unlike every other resource in this file, this is NOT per-tenant/per-user + * data — the list is shared platform-wide, mirroring `SourceConfig`'s + * global stance. + */ +export interface RssFeedSource { + id: string; + url: string; + label: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +/** Payload accepted by POST /modules/tender-radar/rss-feeds. */ +export interface CreateRssFeedPayload { + url: string; + label: string; + isActive?: boolean; +} + +/** + * Extracts the backend's error message from a non-2xx JSON error body + * (Nest's default exception filter shape: `{ statusCode, message, error }`) + * so the save-time denylist/SSRF rejection (D-14, T-14-02-01) surfaces its + * specific reason inline instead of a generic "failed to save" string. + */ +async function extractErrorMessage(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as { message?: unknown }; + if (typeof body.message === 'string' && body.message) return body.message; + if (Array.isArray(body.message) && body.message.length) { + return body.message.join(', '); + } + } catch { + /* body wasn't JSON — fall through to the generic message */ + } + return fallback; +} + +/** + * List every admin-managed RSS feed (global, D-08). + * GET /modules/tender-radar/rss-feeds + */ +export async function listRssFeeds(): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, { + credentials: 'include', + }); + if (!res.ok) { + throw new Error( + await extractErrorMessage(res, 'Failed to fetch RSS feeds'), + ); + } + return res.json(); +} + +/** + * Add a new global RSS feed URL. Rejected with the backend's specific + * hostname/SSRF-guard message (D-14) when the URL is denylisted/private/ + * loopback — the rejection message is relayed as-is via `extractErrorMessage` + * so the admin sees WHY, not just that the save failed. + * POST /modules/tender-radar/rss-feeds + */ +export async function createRssFeed( + payload: CreateRssFeedPayload, +): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) { + throw new Error(await extractErrorMessage(res, 'Failed to create RSS feed')); + } + return res.json(); +} + +/** + * Remove a global RSS feed. + * DELETE /modules/tender-radar/rss-feeds/:feedId + */ +export async function deleteRssFeed(id: string): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) { + throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed')); + } +}