Files
tessera-ctl/apps/api/src/tenders/source-registry.ts
T
schalli 28c6c7fee1 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 <noreply@anthropic.com>
2026-07-23 13:58:25 +02:00

76 lines
2.9 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import type { SourceType } from './tender.types';
import type { TenderSourceAdapter } from './adapters/tender-source-adapter.interface';
/**
* Portals whose AGB (Nutzungsbedingungen) prohibit automated/scraping
* access (INGEST-07/D-06). This is the hard code boundary — NOT just
* documentation: `SourceRegistry.register()` checks every adapter's
* `portals` against this list and refuses registration outright if any
* entry matches, at DI-boot time, before any poll can run.
*/
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) —
* see source-registry.spec.ts.
*/
export class DeniedPortalError extends Error {
constructor(portal: string) {
super(
`Portal '${portal}' ist AGB-seitig für automatisierten Zugriff gesperrt und darf nicht registriert werden.`,
);
this.name = 'DeniedPortalError';
}
}
/**
* Central registry mapping `SourceType` -> `TenderSourceAdapter`, gated by
* the AGB denylist (T-13-02-01/T-13-02-02). `pollDueSources` (Plan 13-03)
* uses `get()`/`activeAdapters()` to fan out over all registered sources
* (poll-once-fan-out-many) instead of hardwiring a single adapter.
*/
@Injectable()
export class SourceRegistry {
private readonly adapters = new Map<SourceType, TenderSourceAdapter>();
/**
* Registers an adapter. Iterates ALL of the adapter's declared `portals`
* — a single denylisted entry rejects the whole adapter, even if the
* rest of its portals are legitimate (T-13-02-02: no partial/mixed
* registration).
*/
register(adapter: TenderSourceAdapter): void {
for (const portal of adapter.portals) {
if ((DENYLISTED_PORTALS as readonly string[]).includes(portal)) {
throw new DeniedPortalError(portal);
}
}
this.adapters.set(adapter.sourceType, adapter);
}
/** Returns the adapter for a sourceType, or undefined if none is registered — never throws. */
get(type: SourceType): TenderSourceAdapter | undefined {
return this.adapters.get(type);
}
/** All currently registered adapters, for fan-out scheduling. */
activeAdapters(): TenderSourceAdapter[] {
return [...this.adapters.values()];
}
}