feat(14-02): add TenderRssFeedSource CRUD, tick poll gate, and wire RssAdapter
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.
Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.
Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).
Migration applied locally per project convention (host -> container IP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CalendarSource" ADD COLUMN "domain" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TenderSourcePollConfig" ADD COLUMN "pollGranularity" TEXT NOT NULL DEFAULT 'day';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TenderRssFeedSource" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TenderRssFeedSource_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TenderRssFeedSource_url_key" ON "TenderRssFeedSource"("url");
|
||||
@@ -419,6 +419,31 @@ model TenderSourcePollConfig {
|
||||
pollIntervalMin Int @default(60) // D-04 default hourly
|
||||
isActive Boolean @default(false)
|
||||
lastIngestedDay DateTime? // day-cursor, NOT a timestamp (RESEARCH Pattern 1)
|
||||
// Phase 14, Plan 02 (D-15/Pitfall 1): 'day' | 'tick'. 'day' sources
|
||||
// (doe-opendata/ai-netserver/cosinex-dtvp) keep the lastIngestedDay-gated
|
||||
// once-per-calendar-day fetch, unchanged. 'tick' sources (rss) are
|
||||
// fetched on every active scheduler tick, ignoring lastIngestedDay
|
||||
// entirely — the day-cursor gate was built for a genuine daily
|
||||
// batch-export API and would otherwise silently cap RSS to one fetch
|
||||
// per day regardless of pollIntervalMin.
|
||||
pollGranularity String @default("day")
|
||||
createdAt DateTime @default(now())
|
||||
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
|
||||
// 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.
|
||||
model TenderRssFeedSource {
|
||||
id String @id @default(uuid())
|
||||
url String @unique
|
||||
label String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RssAdapter } from './rss.adapter';
|
||||
|
||||
/**
|
||||
@@ -20,16 +20,46 @@ const SUBREPORT_ELVIS_FIXTURE = readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
|
||||
/** Minimal prisma-shaped fake — only `tenderRssFeedSource.findMany` is used by RssAdapter. */
|
||||
function makeFakePrisma(feeds: any[] = []) {
|
||||
return {
|
||||
tenderRssFeedSource: {
|
||||
findMany: vi.fn(async () => feeds),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
/** Stubs global `fetch` to resolve with `xml` for every call (fan-out tests). */
|
||||
function stubFetchResolvingXml(xmlByUrl: Record<string, string>): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string) => {
|
||||
const xml = xmlByUrl[url];
|
||||
if (xml === undefined) {
|
||||
return { ok: false, status: 404 } as Response;
|
||||
}
|
||||
const bytes = Buffer.from(xml, 'utf8');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-length': String(bytes.byteLength) }),
|
||||
arrayBuffer: async () =>
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe('RssAdapter', () => {
|
||||
it('declares sourceType rss and the symbolic rss portal', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
expect(adapter.sourceType).toBe('rss');
|
||||
expect(adapter.portals).toEqual(['rss']);
|
||||
});
|
||||
|
||||
describe('parseFeed (pure, fixture-driven)', () => {
|
||||
it('parses service.bund.de items: sourceType/sourcePortal set, pubDate present, numeric HTML entities decoded in title', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
|
||||
const records = adapter.parseFeed(SERVICE_BUND_FIXTURE, 'service-bund');
|
||||
|
||||
@@ -54,7 +84,7 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('uses the item guid as sourceNoticeId for service.bund.de (plain-text guid, no isPermaLink attribute)', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const records = adapter.parseFeed(SERVICE_BUND_FIXTURE, 'service-bund');
|
||||
|
||||
const ids = records.map((r) => r.sourceNoticeId);
|
||||
@@ -63,7 +93,7 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('parses subreport-elvis items: publishedAt null (no item pubDate), title from CDATA, guid isPermaLink=false extracted as plain text', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
|
||||
const records = adapter.parseFeed(
|
||||
SUBREPORT_ELVIS_FIXTURE,
|
||||
@@ -92,7 +122,7 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('never carries description HTML into the record (V5 — no stored-XSS surface)', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const records = adapter.parseFeed(
|
||||
SUBREPORT_ELVIS_FIXTURE,
|
||||
'subreport-neuss',
|
||||
@@ -106,7 +136,7 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('bare-minimum bag: buyerName/procedureType/deadlineAt always null (baseline mapping only, D-04/D-05)', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const records = adapter.parseFeed(SERVICE_BUND_FIXTURE, 'service-bund');
|
||||
|
||||
for (const record of records) {
|
||||
@@ -124,26 +154,26 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('returns [] for empty XML instead of throwing', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
expect(adapter.parseFeed('', 'empty-feed')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for malformed XML instead of throwing', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
expect(
|
||||
adapter.parseFeed('<rss><channel><item><title>', 'broken-feed'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for well-formed XML with no <item> at all', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const xml =
|
||||
'<?xml version="1.0"?><rss version="2.0"><channel><title>Empty</title></channel></rss>';
|
||||
expect(adapter.parseFeed(xml, 'no-items-feed')).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a single item missing <link> without aborting the rest', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Ohne Link</title><guid>no-link-guid</guid></item>
|
||||
@@ -157,7 +187,7 @@ describe('RssAdapter', () => {
|
||||
});
|
||||
|
||||
it('falls back to sha256(link) for sourceNoticeId when guid is absent', () => {
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>No Guid</title><link>https://example.invalid/no-guid</link></item>
|
||||
@@ -177,11 +207,107 @@ describe('RssAdapter', () => {
|
||||
// only proves RssAdapter's OWN output shape (parseFeed), not the
|
||||
// normalizer dispatch itself (kept in the normalizer's own spec file
|
||||
// to avoid duplicating TenderNormalizerService test infrastructure).
|
||||
const adapter = new RssAdapter();
|
||||
const adapter = new RssAdapter(makeFakePrisma());
|
||||
expect(adapter.sourceType).toBe('rss');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTenders (internal fan-out over active TenderRssFeedSource rows, Plan 14-02 Task 2)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('fetches and parses every active feed, fanning out over findMany({isActive:true})', async () => {
|
||||
const feeds = [
|
||||
{ id: 'f1', url: 'https://service-bund.invalid/rss.xml', label: 'service-bund', isActive: true },
|
||||
{ id: 'f2', url: 'https://subreport.invalid/rss.xml', label: 'subreport-neuss', isActive: true },
|
||||
];
|
||||
const prisma = makeFakePrisma(feeds);
|
||||
stubFetchResolvingXml({
|
||||
'https://service-bund.invalid/rss.xml': SERVICE_BUND_FIXTURE,
|
||||
'https://subreport.invalid/rss.xml': SUBREPORT_ELVIS_FIXTURE,
|
||||
});
|
||||
const adapter = new RssAdapter(prisma);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(prisma.tenderRssFeedSource.findMany).toHaveBeenCalledWith({
|
||||
where: { isActive: true },
|
||||
});
|
||||
const portals = new Set(records.map((r) => r.sourcePortal));
|
||||
expect(portals).toEqual(new Set(['service-bund', 'subreport-neuss']));
|
||||
expect(records.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('catch-per-feed: a feed that fails to fetch is skipped, the other feed still resolves (D-01)', async () => {
|
||||
const feeds = [
|
||||
{ id: 'f1', url: 'https://broken.invalid/rss.xml', label: 'broken', isActive: true },
|
||||
{ id: 'f2', url: 'https://ok.invalid/rss.xml', label: 'ok-feed', isActive: true },
|
||||
];
|
||||
const prisma = makeFakePrisma(feeds);
|
||||
stubFetchResolvingXml({
|
||||
'https://ok.invalid/rss.xml': SERVICE_BUND_FIXTURE,
|
||||
// 'broken.invalid' deliberately absent -> stub resolves 404 -> throws
|
||||
});
|
||||
const adapter = new RssAdapter(prisma);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records.every((r) => r.sourcePortal === 'ok-feed')).toBe(true);
|
||||
expect(records.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('returns [] when there are no active feeds', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const adapter = new RssAdapter(prisma);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] without throwing when a feed fetch throws (network error)', async () => {
|
||||
const feeds = [
|
||||
{ id: 'f1', url: 'https://unreachable.invalid/rss.xml', label: 'unreachable', isActive: true },
|
||||
];
|
||||
const prisma = makeFakePrisma(feeds);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new Error('network unreachable');
|
||||
}),
|
||||
);
|
||||
const adapter = new RssAdapter(prisma);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a feed response exceeding the size ceiling (DoS mitigation, T-14-02-02)', async () => {
|
||||
const feeds = [
|
||||
{ id: 'f1', url: 'https://huge.invalid/rss.xml', label: 'huge', isActive: true },
|
||||
];
|
||||
const prisma = makeFakePrisma(feeds);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-length': String(11 * 1024 * 1024) }),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
} as unknown as Response;
|
||||
}),
|
||||
);
|
||||
const adapter = new RssAdapter(prisma);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(records).toEqual([]); // oversized feed skipped, catch-per-feed (D-01)
|
||||
});
|
||||
});
|
||||
|
||||
it('never imports or uses axios (native fetch is the sole HTTP client convention)', () => {
|
||||
const source = readFileSync(join(__dirname, 'rss.adapter.ts'), 'utf8');
|
||||
expect(source).not.toMatch(/from ['"]axios['"]/);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'crypto';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { RawTenderRecord, SourceType } from '../tender.types';
|
||||
import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||
|
||||
@@ -39,7 +40,21 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||
* read by this adapter, so no raw HTML fragment is ever carried into a
|
||||
* RawTenderRecord (V5 — no stored-XSS surface, same text-only discipline
|
||||
* as NetServerAdapter/CosinexAdapter).
|
||||
*
|
||||
* `fetchTenders()` (Plan 14-02 Task 2) is an internal-fan-out adapter
|
||||
* (14-RESEARCH.md Pattern 1, same shape as `NetServerAdapter`'s
|
||||
* multi-portal loop): reads every `isActive` `TenderRssFeedSource` row and
|
||||
* fetches+parses each feed independently, catch-per-feed (D-01 discipline)
|
||||
* — one broken/unreachable feed never blocks the others in the same tick.
|
||||
* `_dayCursor` is accepted for interface conformance but NOT used as a
|
||||
* filter — RSS has no day-batch concept; it is polled every scheduler
|
||||
* tick via `pollGranularity: 'tick'` (D-15, wired in
|
||||
* `tender-ingestion.service.ts`), not gated by the day-cursor at all.
|
||||
*/
|
||||
const RSS_FETCH_TIMEOUT_MS = 15_000;
|
||||
/** RSS feeds are normally small; an admin-supplied URL is less trusted than a hardcoded portal (RESEARCH.md Security Domain, DoS mitigation). */
|
||||
const RSS_RESPONSE_SIZE_CEILING_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class RssAdapter implements TenderSourceAdapter {
|
||||
readonly sourceType: SourceType = 'rss';
|
||||
@@ -53,14 +68,78 @@ export class RssAdapter implements TenderSourceAdapter {
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Placeholder — wired to the real `TenderRssFeedSource` internal fan-out
|
||||
* (native fetch + AbortController per admin-added feed URL) in Plan
|
||||
* 14-02 Task 2. Kept here only so the class satisfies
|
||||
* `TenderSourceAdapter` for this task's fixture-driven `parseFeed` proof.
|
||||
* Internal fan-out over every active admin-managed feed (D-14/D-08,
|
||||
* global — no tenantId). Catch-per-feed (D-01): a single feed that fails
|
||||
* to fetch/parse is skipped (logger.warn), never aborting the rest.
|
||||
*/
|
||||
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
||||
return [];
|
||||
const feeds = await this.prisma.tenderRssFeedSource.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
|
||||
const records: RawTenderRecord[] = [];
|
||||
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const xml = await this.fetchFeedXml(feed.url);
|
||||
records.push(...this.parseFeed(xml, feed.label));
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`RSS feed '${feed.label}' (${feed.url}) fetch failed, skipping this feed for this tick: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native fetch + AbortController 15s timeout (project-wide convention,
|
||||
* DoeOpenDataAdapter/NetServerAdapter/CosinexAdapter idiom) plus a
|
||||
* response-size ceiling (T-14-02-02, DoS mitigation) — checked both via
|
||||
* the `Content-Length` header (fast-path, may be absent/wrong) and the
|
||||
* actually-received byte count (authoritative).
|
||||
*/
|
||||
private async fetchFeedXml(url: string): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
RSS_FETCH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`RSS feed fetch failed: HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const contentLength = res.headers.get('content-length');
|
||||
if (
|
||||
contentLength &&
|
||||
Number(contentLength) > RSS_RESPONSE_SIZE_CEILING_BYTES
|
||||
) {
|
||||
throw new Error(
|
||||
`RSS feed exceeds size ceiling (Content-Length: ${contentLength} bytes)`,
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
if (buffer.byteLength > RSS_RESPONSE_SIZE_CEILING_BYTES) {
|
||||
throw new Error(
|
||||
`RSS feed exceeds size ceiling (${buffer.byteLength} bytes received)`,
|
||||
);
|
||||
}
|
||||
|
||||
return new TextDecoder('utf-8').decode(buffer);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for admin-managed RSS feed sources (`TenderRssFeedSource`, D-14/D-08).
|
||||
*
|
||||
* `@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
|
||||
* 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
|
||||
* lets IP-literal URLs pass THIS validation layer so the service-layer
|
||||
* check can reject them with a clear, domain-specific SSRF error message
|
||||
* instead of a generic "invalid URL" one.
|
||||
*/
|
||||
export class TenderRssFeedDto {
|
||||
@IsUrl({
|
||||
protocols: ['http', 'https'],
|
||||
require_protocol: true,
|
||||
require_tld: false,
|
||||
})
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -299,6 +299,59 @@ describe('TenderIngestionService.pollDueSources — catch-per-source error isola
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenderIngestionService.pollDueSources — pollGranularity 'tick' gate (D-15, Phase 14 Plan 02)", () => {
|
||||
it("fetches a pollGranularity='tick' source on every tick, while a 'day' source with today's next-day gated-out lastIngestedDay is skipped in the SAME tick", async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
'doe-opendata': { lastIngestedDay: dayDate(-1) }, // -> next day is today, gated out ('day' path unchanged)
|
||||
rss: { pollGranularity: 'tick', lastIngestedDay: null }, // 'tick' — no day-cursor gate at all
|
||||
});
|
||||
const doeAdapter = { fetchTenders: vi.fn() };
|
||||
const rssAdapter = {
|
||||
fetchTenders: vi.fn().mockResolvedValue([
|
||||
{ ...BASE_RECORD, sourcePortal: 'rss', sourceNoticeId: 'rss-1', ocid: null, dedupKey: 'rss:rss-1' },
|
||||
]),
|
||||
};
|
||||
const registry = makeFakeRegistry({ 'doe-opendata': doeAdapter, rss: rssAdapter });
|
||||
const normalizer = { normalize: vi.fn((raw: any) => raw) };
|
||||
const service = makeService(prisma, registry, normalizer);
|
||||
|
||||
await service.pollDueSources();
|
||||
|
||||
expect(doeAdapter.fetchTenders).not.toHaveBeenCalled(); // 'day' gate unchanged
|
||||
expect(rssAdapter.fetchTenders).toHaveBeenCalledTimes(1); // 'tick' — fetched regardless
|
||||
expect(prisma.__store.tenders.size).toBe(1);
|
||||
});
|
||||
|
||||
it("does NOT advance or read lastIngestedDay for a 'tick' source — it is fetched again next tick even with lastIngestedDay already set to today", async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
rss: { pollGranularity: 'tick', lastIngestedDay: dayDate(0) }, // today — would gate a 'day' source out entirely
|
||||
});
|
||||
const rssAdapter = { fetchTenders: vi.fn().mockResolvedValue([]) };
|
||||
const registry = makeFakeRegistry({ rss: rssAdapter });
|
||||
const normalizer = { normalize: vi.fn() };
|
||||
const service = makeService(prisma, registry, normalizer);
|
||||
|
||||
await service.pollDueSources();
|
||||
await service.pollDueSources();
|
||||
|
||||
expect(rssAdapter.fetchTenders).toHaveBeenCalledTimes(2);
|
||||
// lastIngestedDay is never touched by the 'tick' path.
|
||||
expect(prisma.__store.configs.get('rss').lastIngestedDay).toEqual(dayDate(0));
|
||||
});
|
||||
|
||||
it("passes berlinToday (not a day-cursor loop) as the single argument to a 'tick' adapter's fetchTenders", async () => {
|
||||
const prisma = makeFakePrisma({ rss: { pollGranularity: 'tick' } });
|
||||
const rssAdapter = { fetchTenders: vi.fn().mockResolvedValue([]) };
|
||||
const registry = makeFakeRegistry({ rss: rssAdapter });
|
||||
const normalizer = { normalize: vi.fn() };
|
||||
const service = makeService(prisma, registry, normalizer);
|
||||
|
||||
await service.pollDueSources();
|
||||
|
||||
expect(rssAdapter.fetchTenders).toHaveBeenCalledWith(berlinTodayStr());
|
||||
});
|
||||
});
|
||||
|
||||
describe('TenderIngestionService.pollDueSources — dedupActive gate (D-05)', () => {
|
||||
it('passes dedupActive=false to resolve() when exactly one config is active', async () => {
|
||||
const prisma = makeFakePrisma({ 'doe-opendata': { lastIngestedDay: dayDate(-2) } });
|
||||
|
||||
@@ -70,6 +70,15 @@ function addOneDay(day: string): string {
|
||||
* structurally prevents a backfill flood. Rows that merely changed
|
||||
* (SCHEMA-02 contentHash update) or were merged as an additional source
|
||||
* (`created: false`) are NOT included.
|
||||
*
|
||||
* Phase 14, Plan 02 (D-15/RESEARCH.md Pitfall 1): each config now branches
|
||||
* on `pollGranularity` ('day' | 'tick'). 'day' sources (doe-opendata/
|
||||
* ai-netserver/cosinex-dtvp) keep the exact pre-existing nextDayToFetch
|
||||
* gate — zero regression. 'tick' sources (rss) skip the day-cursor gate
|
||||
* entirely and fetch on every active tick, since the day-cursor mechanism
|
||||
* was built for a genuine daily batch-export API and would otherwise
|
||||
* silently cap a finer-grained source to one fetch per calendar day
|
||||
* regardless of its configured `pollIntervalMin`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderIngestionService {
|
||||
@@ -123,6 +132,32 @@ export class TenderIngestionService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.pollGranularity === 'tick') {
|
||||
// D-15/Pitfall 1: 'tick' sources (rss) are NOT gated on
|
||||
// lastIngestedDay at all — the day-cursor gate was built for a
|
||||
// genuine daily batch-export API (DÖE) and would otherwise
|
||||
// silently cap these sources to one fetch per calendar day
|
||||
// regardless of pollIntervalMin. Fetched unconditionally on
|
||||
// every tick this config is active; lastIngestedDay is never
|
||||
// read or advanced for 'tick' sources.
|
||||
const rawRecords = await adapter.fetchTenders(berlinToday);
|
||||
const normalized = rawRecords.map((r) =>
|
||||
this.normalizer.normalize(r),
|
||||
);
|
||||
|
||||
for (const tender of normalized) {
|
||||
const { tenderId, created } = await this.dedup.resolve(tender, {
|
||||
dedupActive,
|
||||
});
|
||||
if (created) newTenderIds.push(tenderId);
|
||||
}
|
||||
|
||||
anyDayFetched = true; // also triggers pruneExpiredTenders below, same as 'day' sources
|
||||
continue;
|
||||
}
|
||||
|
||||
// 'day' path — UNCHANGED from before this plan (zero regression
|
||||
// for doe-opendata/ai-netserver/cosinex-dtvp, D-15).
|
||||
let cursorDay = nextDayToFetch(config.lastIngestedDay);
|
||||
if (!cursorDay) {
|
||||
this.logger.debug(
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
function makeFakePrisma() {
|
||||
const rows = new Map<string, any>();
|
||||
let seq = 0;
|
||||
|
||||
return {
|
||||
tenderRssFeedSource: {
|
||||
findMany: async ({ orderBy }: any) => {
|
||||
const all = [...rows.values()];
|
||||
if (orderBy?.createdAt === 'asc') {
|
||||
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
}
|
||||
return all;
|
||||
},
|
||||
create: async ({ data }: any) => {
|
||||
seq += 1;
|
||||
const row = {
|
||||
id: `feed-${seq}`,
|
||||
createdAt: new Date(Date.now() + seq),
|
||||
updatedAt: new Date(Date.now() + seq),
|
||||
...data,
|
||||
};
|
||||
rows.set(row.id, row);
|
||||
return row;
|
||||
},
|
||||
delete: async ({ where }: any) => {
|
||||
const existing = rows.get(where.id);
|
||||
rows.delete(where.id);
|
||||
return existing;
|
||||
},
|
||||
},
|
||||
__rows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenderRssFeedSourceService', () => {
|
||||
describe('create() — 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({
|
||||
url: 'https://www.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an aumass.de feed URL (denylisted portal, D-14)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'https://aumass.de/feed', label: 'aumass' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a subdomain of a denylisted host (substring match on hostname)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
url: 'https://feeds.vergabe24.de/rss.xml',
|
||||
label: 'vergabe24-sub',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('accepts a valid service.bund.de feed URL', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
url: 'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||
label: 'service-bund',
|
||||
});
|
||||
|
||||
expect(created.url).toBe(
|
||||
'https://www.service.bund.de/Content/Globals/Functions/RSSFeed/RSSGenerator_Ausschreibungen.xml',
|
||||
);
|
||||
expect(created.isActive).toBe(true); // default when omitted
|
||||
expect(prisma.__rows.size).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects a non-http(s) scheme (e.g. file://)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'file:///etc/passwd', label: 'local-file' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a malformed URL', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'not-a-url', label: 'broken' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a loopback host (127.0.0.1, SSRF)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
url: 'http://127.0.0.1:8080/internal-feed.xml',
|
||||
label: 'internal',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects "localhost" (SSRF)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://localhost:3000/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a private 10.x.x.x host (SSRF)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://10.0.0.5/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a private 192.168.x.x host (SSRF)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://192.168.1.1/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects an IPv6 loopback host ([::1], SSRF)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await expect(
|
||||
service.create({ url: 'http://[::1]/feed', label: 'x' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('creates isActive=false when explicitly supplied', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
url: 'https://example-tenders.invalid/rss.xml',
|
||||
label: 'inactive-feed',
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
expect(created.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list()/remove()', () => {
|
||||
it('list() returns created feeds ordered by createdAt asc', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
await service.create({
|
||||
url: 'https://a.example-tenders.invalid/rss.xml',
|
||||
label: 'a',
|
||||
});
|
||||
await service.create({
|
||||
url: 'https://b.example-tenders.invalid/rss.xml',
|
||||
label: 'b',
|
||||
});
|
||||
|
||||
const list = await service.list();
|
||||
expect(list.map((f: any) => f.label)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('remove() deletes the feed by id', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new TenderRssFeedSourceService(prisma as any);
|
||||
|
||||
const created = await service.create({
|
||||
url: 'https://c.example-tenders.invalid/rss.xml',
|
||||
label: 'c',
|
||||
});
|
||||
|
||||
const result = await service.remove(created.id);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(prisma.__rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
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.
|
||||
*
|
||||
* Save-time hostname/SSRF guard (T-14-02-01, RESEARCH.md Pitfall 3): RSS
|
||||
* feed URLs are RUNTIME admin 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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TenderRssFeedSourceService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list() {
|
||||
return this.prisma.tenderRssFeedSource.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: TenderRssFeedDto) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
|
||||
return this.prisma.tenderRssFeedSource.create({
|
||||
data: {
|
||||
url: dto.url,
|
||||
label: dto.label,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.prisma.tenderRssFeedSource.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects (BadRequestException, HTTP 400):
|
||||
* - non-http(s) schemes (e.g. `file://`, `ftp://`, `javascript:`)
|
||||
* - hostnames containing a DENYLISTED_PORTALS entry (vergabe24/aumass)
|
||||
* - private/loopback/link-local IP-literal hosts (SSRF)
|
||||
*
|
||||
* Deliberately string/IP-literal-based, not DNS-resolution-based — same
|
||||
* scope as the existing SSRF-guard pattern documented for
|
||||
* NETSERVER_PORTALS/COSINEX_BASE_URL (T-10-06); resolving a hostname to
|
||||
* check its IP at save-time would itself be an SSRF-adjacent action and
|
||||
* is out of scope for this task.
|
||||
*/
|
||||
private assertUrlAllowed(rawUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new BadRequestException('Ungültige URL.');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new BadRequestException(
|
||||
'Nur http(s)-URLs sind für RSS-Feeds erlaubt.',
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
if (
|
||||
(DENYLISTED_PORTALS as readonly string[]).some((portal) =>
|
||||
hostname.includes(portal),
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Der Host '${hostname}' ist AGB-seitig für automatisierten Zugriff gesperrt (Denylist) und darf nicht als RSS-Feed hinterlegt werden.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isPrivateOrLoopbackHost(hostname)) {
|
||||
throw new BadRequestException(
|
||||
`Der Host '${hostname}' ist ein privater/loopback-Host und darf nicht als RSS-Feed hinterlegt werden (SSRF-Schutz).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, literal-only private/loopback/link-local host check (SSRF
|
||||
* guard, T-14-02-01) — matches on the hostname string as supplied, no DNS
|
||||
* resolution (see assertUrlAllowed docstring).
|
||||
*/
|
||||
function isPrivateOrLoopbackHost(rawHostname: string): boolean {
|
||||
// WHATWG URL keeps IPv6 literals bracketed (e.g. `new URL('http://[::1]/').hostname === '[::1]'`) — strip for the checks below.
|
||||
const hostname = rawHostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
||||
return true;
|
||||
}
|
||||
if (hostname === '::1' || hostname === '0.0.0.0') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4Match) {
|
||||
const [a, b] = ipv4Match.slice(1, 3).map(Number);
|
||||
if (a === 127) return true; // loopback (127.0.0.0/8)
|
||||
if (a === 10) return true; // private (10.0.0.0/8)
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // private (172.16.0.0/12)
|
||||
if (a === 192 && b === 168) return true; // private (192.168.0.0/16)
|
||||
if (a === 169 && b === 254) return true; // link-local (169.254.0.0/16)
|
||||
if (a === 0) return true; // "this network"
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bare IPv6 literal ranges (unique-local fc00::/7, link-local fe80::/10).
|
||||
if (hostname.startsWith('fc') || hostname.startsWith('fd')) return true;
|
||||
if (hostname.startsWith('fe80:')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SettingsModule } from '../settings/settings.module';
|
||||
import { CosinexAdapter } from './adapters/cosinex.adapter';
|
||||
import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter';
|
||||
import { NetServerAdapter } from './adapters/netserver.adapter';
|
||||
import { RssAdapter } from './adapters/rss.adapter';
|
||||
import { SourceRegistry } from './source-registry';
|
||||
import { seedTendersModule } from './tenders.seed';
|
||||
import { TenderDedupService } from './tender-dedup.service';
|
||||
@@ -15,6 +16,7 @@ import { TenderMailService } from './tender-mail.service';
|
||||
import { TenderMatchingService } from './tender-matching.service';
|
||||
import { TenderNormalizerService } from './tender-normalizer.service';
|
||||
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';
|
||||
@@ -93,6 +95,15 @@ import { TendersController } from './tenders.controller';
|
||||
* final Wave-4 additive `registry.register(...)` call. Its
|
||||
* `TenderSourcePollConfig` row is likewise seeded with `isActive: false`
|
||||
* (D-02: activation is a later admin/seed decision, Phase 14 UI).
|
||||
*
|
||||
* Phase 14, Plan 02 (INGEST-04): adds `RssAdapter` (registered alongside
|
||||
* the existing adapters — `rss` is not denylisted) and
|
||||
* `TenderRssFeedSourceService` (admin CRUD for the global feed list,
|
||||
* D-14). Unlike ai-netserver/cosinex-dtvp, the `rss` `TenderSourcePollConfig`
|
||||
* seed is `isActive: true` with `pollGranularity: 'tick'` (D-15) —
|
||||
* service.bund.de is seeded as a default-active `TenderRssFeedSource` row
|
||||
* (RESEARCH.md Open Question 3), so RSS ingestion is live out of the box,
|
||||
* not "framework ready, activation deferred" like the Phase 13 sources.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule, SettingsModule],
|
||||
@@ -101,6 +112,7 @@ import { TendersController } from './tenders.controller';
|
||||
DoeOpenDataAdapter,
|
||||
NetServerAdapter,
|
||||
CosinexAdapter,
|
||||
RssAdapter,
|
||||
SourceRegistry,
|
||||
TenderNormalizerService,
|
||||
TenderDedupService,
|
||||
@@ -112,6 +124,7 @@ import { TendersController } from './tenders.controller';
|
||||
TenderMailService,
|
||||
TenderDigestScheduler,
|
||||
TenderNotificationPrefService,
|
||||
TenderRssFeedSourceService,
|
||||
],
|
||||
})
|
||||
export class TendersModule implements OnModuleInit {
|
||||
@@ -124,18 +137,24 @@ export class TendersModule implements OnModuleInit {
|
||||
private readonly doeAdapter: DoeOpenDataAdapter,
|
||||
private readonly netServerAdapter: NetServerAdapter,
|
||||
private readonly cosinexAdapter: CosinexAdapter,
|
||||
private readonly rssAdapter: RssAdapter,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
// D-06/INGEST-07: registration itself is the denylist-gate enforcement
|
||||
// point — SourceRegistry.register() throws for any adapter serving a
|
||||
// denylisted portal. DoeOpenDataAdapter, NetServerAdapter, and
|
||||
// CosinexAdapter are all legitimate (none of tender24/lhs-vpbw/
|
||||
// vergabe.landbw/cosinex-dtvp are on the denylist).
|
||||
// denylisted portal. DoeOpenDataAdapter, NetServerAdapter,
|
||||
// CosinexAdapter, and RssAdapter are all legitimate (none of
|
||||
// tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/rss are on the
|
||||
// denylist). Note: RssAdapter's `portals: ['rss']` is a SYMBOLIC
|
||||
// placeholder — the actual admin-supplied feed hostnames are NOT
|
||||
// covered by this gate at all (RESEARCH.md Pitfall 3); that runtime
|
||||
// check lives in TenderRssFeedSourceService instead (D-14).
|
||||
this.sourceRegistry.register(this.doeAdapter);
|
||||
this.sourceRegistry.register(this.netServerAdapter);
|
||||
this.sourceRegistry.register(this.cosinexAdapter);
|
||||
this.sourceRegistry.register(this.rssAdapter);
|
||||
this.logger.log(
|
||||
`Registered source adapters: ${this.sourceRegistry
|
||||
.activeAdapters()
|
||||
@@ -211,5 +230,51 @@ export class TendersModule implements OnModuleInit {
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed cosinex-dtvp poll config', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 14, Plan 02 (INGEST-04, D-15): seed the rss poll config with
|
||||
// pollGranularity: 'tick' (fetched every active scheduler tick, NOT
|
||||
// gated by lastIngestedDay — see tender-ingestion.service.ts) and
|
||||
// isActive: true — unlike ai-netserver/cosinex-dtvp, RSS is live by
|
||||
// default (RESEARCH.md Open Question 3: service.bund.de is a safe,
|
||||
// genuinely national default feed, seeded below).
|
||||
await this.prisma.tenderSourcePollConfig.upsert({
|
||||
where: { sourceType: 'rss' },
|
||||
update: {},
|
||||
create: {
|
||||
sourceType: 'rss',
|
||||
pollIntervalMin: 60,
|
||||
isActive: true,
|
||||
pollGranularity: 'tick',
|
||||
},
|
||||
});
|
||||
this.logger.log("rss poll config seeded (active, pollGranularity='tick')");
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to seed rss poll config', error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 14, Plan 02 (D-14, RESEARCH.md Open Question 3): seed a
|
||||
// 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
|
||||
// (Plan 14-02 Task 3).
|
||||
await this.prisma.tenderRssFeedSource.upsert({
|
||||
where: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user