Files
tessera-ctl/apps/api/src/tenders/tenders.module.ts
T
schalli ee3b28e505 feat(260907-efh): automatische Fingerabdruck-Nachrechnung beim Start
Nach der Formelaenderung (v1 -> v2) traegt jede Bestandszeile einen Hash
der alten Formel und wuerde nie wieder auf einen neuen treffen. Die
Produktionsdatenbank wird von Hand nicht angefasst, deshalb rechnet ein
neuer Dienst beim Start alle veralteten Zeilen automatisch nach — an der
Versionsmarke aus tender-fingerprint.ts erkannt, gleiche Bauform wie die
LDAP-Bind-Passwort-Nachverschluesselung.

- TenderFingerprintBackfillService: OnApplicationBootstrap, seitenweise
  (500 Zeilen), pro Seite eine Transaktion, Fehler werden geloggt und
  geschluckt statt geworfen
- In tenders.module.ts VOR TenderSchedulerService eingetragen, damit die
  Nachrechnung vor der Cron-Registrierung laeuft
- Vier Tests: leere DB, alter+leerer Hash werden beide erfasst, zweiter
  Lauf schreibt nichts mehr, Datenbankfehler wirft den Haken nicht

Gemessen an der lokalen Datenbank (16.255 Zeilen): Fingerabdruck-Gruppen
mit mehr als einer Zeile vorher=0, nachher=26, veraltete Zeilen
danach=0, davon Gruppen mit widersprechenden Wert-Groessenordnungen=2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq
2026-09-07 10:41:58 +02:00

326 lines
15 KiB
TypeScript

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<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,
// 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);
}
}
}