From 28c6c7fee15edcd67cc93bb1249f0a9a9f180ad6 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 13:58:25 +0200 Subject: [PATCH] feat(14-04): denylisted-portals read endpoint sourced from DENYLISTED_PORTALS - 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 --- apps/api/src/tenders/source-registry.ts | 13 +++++++ .../src/tenders/tenders.controller.spec.ts | 35 +++++++++++++++++++ apps/api/src/tenders/tenders.controller.ts | 24 +++++++++++++ 3 files changed, 72 insertions(+) diff --git a/apps/api/src/tenders/source-registry.ts b/apps/api/src/tenders/source-registry.ts index 59601f2..ba3de62 100644 --- a/apps/api/src/tenders/source-registry.ts +++ b/apps/api/src/tenders/source-registry.ts @@ -11,6 +11,19 @@ import type { TenderSourceAdapter } from './adapters/tender-source-adapter.inter */ export const DENYLISTED_PORTALS = ['vergabe24', 'aumass'] as const; +/** + * Canonical direct-link URL per denylisted portal (UI-06/D-12). Keyed by + * the same portal slugs as `DENYLISTED_PORTALS` — the portal SET is never + * re-declared here, only the URL each already-listed portal maps to. + * `TendersController.getDenylistedPortals()` maps over `DENYLISTED_PORTALS` + * and looks up each entry's URL here, so a future denylist addition needs + * only a URL added to this map (not a second hardcoded list anywhere else). + */ +export const PORTAL_URLS: Record<(typeof DENYLISTED_PORTALS)[number], string> = { + vergabe24: 'https://www.vergabe24.de', + aumass: 'https://www.aumass.de', +}; + /** * Thrown by `SourceRegistry.register()` when an adapter declares a * denylisted portal. Proves Erfolgskriterium 4 (13-CONTEXT.md D-06) — diff --git a/apps/api/src/tenders/tenders.controller.spec.ts b/apps/api/src/tenders/tenders.controller.spec.ts index e6fee1f..be5dd21 100644 --- a/apps/api/src/tenders/tenders.controller.spec.ts +++ b/apps/api/src/tenders/tenders.controller.spec.ts @@ -315,6 +315,16 @@ describe('TendersController — route declaration order (static route before :id expect(coverageIdx).toBeLessThan(idIdx); }); + it('declares getDenylistedPortals before getTender so GET /:id cannot shadow "denylisted-portals" (Plan 14-04, Pitfall 5)', () => { + const methods = Object.getOwnPropertyNames(TendersController.prototype); + const denylistIdx = methods.indexOf('getDenylistedPortals'); + const idIdx = methods.indexOf('getTender'); + + expect(denylistIdx).toBeGreaterThanOrEqual(0); + expect(idIdx).toBeGreaterThanOrEqual(0); + expect(denylistIdx).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'); @@ -624,6 +634,31 @@ describe('TendersController — GET /coverage', () => { }); }); +describe('TendersController — GET /denylisted-portals (Plan 14-04, UI-06/D-12)', () => { + it('returns vergabe24 and aumass each with their canonical URL, derived from DENYLISTED_PORTALS', async () => { + const prisma = makeFakePrisma(); + const scheduler = { setInterval: vi.fn(), stopJob: vi.fn() } as any; + const controller = new TendersController( + prisma as any, + scheduler, + makeFakeTriageService() as any, + makeFakeSavedSearchService() as any, + makeFakeNotificationPrefService() as any, + makeFakeRssFeedService() as any, + makeFakeEmailConfigService() as any, + ); + + const result = await controller.getDenylistedPortals(); + + expect(result).toEqual({ + portals: [ + { portal: 'vergabe24', url: 'https://www.vergabe24.de' }, + { portal: 'aumass', url: 'https://www.aumass.de' }, + ], + }); + }); +}); + describe('TendersController — GET /triage (batch, per-user, T-11-10/11)', () => { it('parses the comma-separated ids param and delegates to tenderTriage.listForUser(userId, ids)', async () => { const prisma = makeFakePrisma(); diff --git a/apps/api/src/tenders/tenders.controller.ts b/apps/api/src/tenders/tenders.controller.ts index 26fe343..6009bc2 100644 --- a/apps/api/src/tenders/tenders.controller.ts +++ b/apps/api/src/tenders/tenders.controller.ts @@ -24,6 +24,7 @@ import { TenderEmailConfigDto } from './dto/tender-email-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 { DENYLISTED_PORTALS, PORTAL_URLS } from './source-registry'; import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderNotificationPrefService } from './tender-notification-pref.service'; import { TenderRssFeedSourceService } from './tender-rss-feed.service'; @@ -295,6 +296,29 @@ export class TendersController { }; } + /** + * GET /modules/tender-radar/denylisted-portals — the AGB-prohibited + * portals (vergabe24, aumass) with their canonical direct-link URLs + * (UI-06/D-12). Maps over `DENYLISTED_PORTALS` (source-registry.ts) — + * the portal SET is never re-declared here, so a future denylist entry + * (with a URL added to `PORTAL_URLS`) flows through automatically. + * + * MUST be declared before `@Get(':id')` below — same route-order + * pitfall as `source-config`/`coverage`/... above (Pitfall 5). Read- + * surface, gated by @UseModule (not admin-only) — same stance as + * `getCoverage`. + */ + @Get('denylisted-portals') + @UseModule('tender-radar') + async getDenylistedPortals() { + return { + portals: DENYLISTED_PORTALS.map((portal) => ({ + portal, + url: PORTAL_URLS[portal], + })), + }; + } + /** * GET /modules/tender-radar/triage?ids= — batch-fetch the current * user's triage state (gelesen/ungelesen, Favorit) for the given