import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { InboxModule } from '../inbox/inbox.module'; import { ModuleRegistryModule } from '../module-registry/module-registry.module'; import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { PrismaService } from '../prisma/prisma.service'; import { SettingsModule } from '../settings/settings.module'; import { CosinexAdapter } from './adapters/cosinex.adapter'; import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter'; import { EmailAlertAdapter } from './adapters/email-alert.adapter'; import { NetServerAdapter } from './adapters/netserver.adapter'; import { RssAdapter } from './adapters/rss.adapter'; import { SourceRegistry } from './source-registry'; import { seedServiceBundRssFeed, seedTendersModule } from './tenders.seed'; import { TenderDedupService } from './tender-dedup.service'; import { TenderFingerprintBackfillService } from './tender-fingerprint-backfill.service'; import { TenderDigestScheduler } from './tender-digest.scheduler'; import { TenderEmailConfigService } from './tender-email-config.service'; import { TenderIngestionService } from './tender-ingestion.service'; 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'; import { TendersController } from './tenders.controller'; /** * NestJS module for the Ausschreibungs-Radar feature. * * Wave 2 registered the module in the marketplace and seeded the * singleton DÖE poll config so the shared poll is admin-drivable. Plan 03 * added the DÖE source adapter + normalizer (parse+map core of * INGEST-01/SCHEMA-01). Plan 04 added ingestion orchestration * (TenderIngestionService: day-cursor gate, SCHEMA-02 change detection, * D-05 retention) and the shared global scheduler. This plan (05) wires * TendersController: the global (ModuleGuard-gated, not tenant-scoped) * read surface plus the Roles-guarded admin source-config routes that * live-apply to TenderSchedulerService. Plan 05 adds TenderTriageService * (per-user gelesen/ungelesen + Favorit, UI-03/04) as a further provider. * * PrismaModule is global (no explicit import needed). * * Plan 06 adds TenderSavedSearchService (per-user Suchprofile, FILTER-06) * as a further provider — same userId-scoping convention as * TenderTriageService, no forTenant()/RLS (Pitfall 4). * * Phase 12, Plan 01 (NOTIFY-03) adds TenderMatchingService: injected into * TenderIngestionService and called at the end of pollDueSources with only * the genuinely-new tender IDs of the current tick (delta-only matching, * D-07) — reuses buildTenderWhere against every active TenderSavedSearch * profile and upserts TenderMatch rows (matched-vs-notified state, D-06). * * Phase 12, Plan 02 (NOTIFY-01/04) adds the digest delivery channel: * TenderMailService (a structural DkvMailService clone — fresh nodemailer * transport per send via SettingsService.getDecryptedSmtpConfig(tenantId), * never a cached/global mailer, D-08) and TenderDigestScheduler (a SINGLE * global @nestjs/schedule cron, mirroring TenderSchedulerService's * poll-once-fan-out-many pattern rather than DkvSchedulerService's * single-tenant pattern — Pitfall 1). SettingsModule is imported so * TenderMailService can inject SettingsService. ScheduleModule.forRoot() * is already registered globally in AppModule — not re-imported here. * * Phase 12, Plan 04 (NOTIFY-01/02) adds TenderNotificationPrefService: a * per-user digestInterval CRUD service (same userId-scoping convention as * TenderSavedSearchService, no forTenant()/RLS) backing the new GET/PUT * notification-pref controller routes. instantAlert (NOTIFY-02) needed no * new provider — it rides the existing TenderSavedSearchService create/ * update path via the extended saved-search DTOs. * * Seeds itself into the module registry on application startup via * OnModuleInit lifecycle hook — same pattern as DkvModule. * * Phase 13, Plan 03 (SCHEMA-03): adds `SourceRegistry` (fan-out lookup + * INGEST-07 denylist gate, Plan 13-02) and `TenderDedupService` * (three-tier cross-source dedup resolver, Plan 13-03 Task 1) as * providers. `onModuleInit` registers every known source adapter with the * registry via `SourceRegistry.register()` BEFORE the scheduler's first * tick can run — this is the DI-boot-time hook where the denylist gate * (D-06) structurally applies. `DoeOpenDataAdapter` is registered here; * this is deliberately the ONE place that grows an additional * `registry.register(...)` call as 13-04 (NetServer) and 13-05 (cosinex) * add their adapters in later waves — this plan is Wave 2's sole writer * of tenders.module.ts, so those additions are purely additive follow-ups. * * Phase 13, Plan 04 (INGEST-02): adds `NetServerAdapter` (config-driven, * serves tender24/lhs-vpbw/vergabe.landbw — none denylisted) as a provider * and registers it alongside `DoeOpenDataAdapter` in `onModuleInit`. A * `TenderSourcePollConfig` row is seeded with `isActive: false` — this * plan does not force-activate the new source; activation is an * admin/seed decision (Phase 14 UI), matching D-02's "framework ready, * activation deferred" stance. * * Phase 13, Plan 05 (INGEST-03): adds `CosinexAdapter` — a SEPARATE * single-portal HTML adapter for the cosinex/DTVP satellite (cosinex-dtvp * is not denylisted) — as a provider and registers it alongside * `DoeOpenDataAdapter`/`NetServerAdapter` in `onModuleInit`, the third and * 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. * * Phase 14, Plan 03 (INGEST-05): adds `EmailAlertAdapter` (registered * alongside the existing adapters — `email-alert` is not denylisted) and * `TenderEmailConfigService` (per-tenant admin CRUD for the alert mailbox * config, D-06/D-07). `InboxModule` is imported so the * adapter/service can reach the mailbox providers (credential encryption comes * from the global CryptoModule) * and `ImapProvider`/`ExchangeInboxProvider` (shared connection mechanics, * D-01) — the same imports DkvModule already uses for its own, separate * mailbox config (D-03). Unlike `rss`, the `email-alert` * `TenderSourcePollConfig` seed is `isActive: false` with * `pollGranularity: 'tick'` (D-15): the framework is ready, but activation * requires an admin to actually configure a mailbox first — there is no * safe default mailbox to seed (unlike RSS's service.bund.de default). */ @Module({ imports: [ModuleRegistryModule, SettingsModule, InboxModule], controllers: [TendersController], providers: [ DoeOpenDataAdapter, NetServerAdapter, CosinexAdapter, RssAdapter, EmailAlertAdapter, SourceRegistry, TenderNormalizerService, TenderDedupService, TenderIngestionService, // WINDOWS.md #11, Task 3: MUSS vor TenderSchedulerService stehen — Nest // ruft die OnApplicationBootstrap-Haken eines Moduls in der Reihenfolge // der Anbieterliste auf, und die Fingerabdruck-Nachrechnung soll fertig // sein, bevor der Poll-Cron zu laufen beginnt. Beim Umsortieren dieser // Liste bitte diese Reihenfolge erhalten. TenderFingerprintBackfillService, TenderSchedulerService, TenderTriageService, TenderSavedSearchService, TenderMatchingService, TenderMailService, TenderDigestScheduler, TenderNotificationPrefService, TenderRssFeedSourceService, TenderEmailConfigService, ], }) export class TendersModule implements OnModuleInit { private readonly logger = new Logger(TendersModule.name); constructor( private readonly moduleRegistryService: ModuleRegistryService, private readonly prisma: PrismaService, private readonly sourceRegistry: SourceRegistry, private readonly doeAdapter: DoeOpenDataAdapter, private readonly netServerAdapter: NetServerAdapter, private readonly cosinexAdapter: CosinexAdapter, private readonly rssAdapter: RssAdapter, private readonly emailAlertAdapter: EmailAlertAdapter, ) {} async onModuleInit(): Promise { 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, // CosinexAdapter, RssAdapter, and EmailAlertAdapter are all // legitimate (none of tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/ // rss/email-alert 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). EmailAlertAdapter's // `portals: ['email-alert']` is likewise symbolic — mailbox hosts // are per-tenant admin input, not a portal the denylist gate models. this.sourceRegistry.register(this.doeAdapter); this.sourceRegistry.register(this.netServerAdapter); this.sourceRegistry.register(this.cosinexAdapter); this.sourceRegistry.register(this.rssAdapter); this.sourceRegistry.register(this.emailAlertAdapter); this.logger.log( `Registered source adapters: ${this.sourceRegistry .activeAdapters() .map((a) => a.sourceType) .join(', ')}`, ); } catch (error) { this.logger.error('Failed to register tender source adapters', error); } try { await seedTendersModule(this.moduleRegistryService); this.logger.log('Ausschreibungs-Radar module seeded in registry'); } catch (error) { this.logger.error('Failed to seed Ausschreibungs-Radar module', error); } try { // Singleton poll config — global, RLS-exempt (D-03). Do NOT use // forTenant() here: this row is shared platform-wide, not per-tenant. // isActive defaults true so the platform-global DÖE poll (D-04 // hourly) runs regardless of tenant activation; the day-cursor // gate (Plan 04) makes repeated ticks idempotent. await this.prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'doe-opendata' }, update: {}, create: { sourceType: 'doe-opendata', pollIntervalMin: 60, isActive: true, }, }); this.logger.log('doe-opendata poll config seeded'); } catch (error) { this.logger.error('Failed to seed doe-opendata poll config', error); } try { // Phase 13, Plan 04 (INGEST-02): seed the ai-netserver poll config // row so it is admin-drivable, but isActive defaults false — this // plan builds and tests the adapter, it does not force-activate live // polling of the 3 NetServer portals (D-02: framework ready, // activation is a later admin/seed decision, Phase 14 UI). await this.prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'ai-netserver' }, update: {}, create: { sourceType: 'ai-netserver', pollIntervalMin: 60, isActive: false, }, }); this.logger.log('ai-netserver poll config seeded (inactive)'); } catch (error) { this.logger.error('Failed to seed ai-netserver poll config', error); } try { // Phase 13, Plan 05 (INGEST-03): seed the cosinex-dtvp poll config // row so it is admin-drivable, but isActive defaults false — same // "framework ready, activation deferred" stance as ai-netserver // (D-02, Phase 14 UI). await this.prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'cosinex-dtvp' }, update: {}, create: { sourceType: 'cosinex-dtvp', pollIntervalMin: 60, isActive: false, }, }); this.logger.log('cosinex-dtvp poll config seeded (inactive)'); } 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 17, Plan 02 (Task 3): the actual find-then-create logic (and // why "upsert on url" stopped working, D-02) lives in // seedServiceBundRssFeed (tenders.seed.ts) now — extracted the same // way seedTendersModule already is, so it is a plain testable // function and not only reachable via a full Nest bootstrap // (idempotency proven in tenders.seed.spec.ts). await seedServiceBundRssFeed(this.prisma); 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); } try { // Phase 14, Plan 03 (INGEST-05, D-15): seed the email-alert poll // config with pollGranularity: 'tick' (same tick-driven mechanism as // rss — no lastIngestedDay day-cursor gate) and isActive: false — no // tenant has configured a mailbox yet, so there is nothing to poll // until an admin saves a TenderEmailConfig row via the settings UI // (D-02: framework ready, activation deferred, same stance as // ai-netserver/cosinex-dtvp). await this.prisma.tenderSourcePollConfig.upsert({ where: { sourceType: 'email-alert' }, update: {}, create: { sourceType: 'email-alert', pollIntervalMin: 60, isActive: false, pollGranularity: 'tick', }, }); this.logger.log( "email-alert poll config seeded (inactive, pollGranularity='tick')", ); } catch (error) { this.logger.error('Failed to seed email-alert poll config', error); } } }