feat(14-03): add per-tenant encrypted TenderEmailConfig + ownerTenantId write-side (D-13)
Prisma: new TenderEmailConfig model (per-tenant, tenantId @unique, mirrors
DkvModuleConfig) + Tender.ownerTenantId nullable column + index (D-13:
null = global/platform-wide, unchanged for all existing rows and every
public source; set = visible only to that tenant). Migration
20260723113917_tender_email_config_owner_tenant_id applied locally.
TenderEmailConfigService: safe-select admin CRUD (GET never returns the
password, only hasPassword — T-07-12) with DkvService's encrypt-preserve-
empty semantics, via CalendarCryptoService (AES-256-GCM).
RawTenderRecord/NormalizedTenderFields gain optional ownerTenantId,
threaded through TenderNormalizerService.assemble() unchanged.
TenderDedupService's CREATE branch writes ownerTenantId (defaulting to
null); the UPDATE branch deliberately never references it, so a tender
later also seen on a public source is never retroactively hidden.
EmailAlertAdapter.fetchTenders() now does the real per-tenant fan-out:
findMany({isActive:true}) across ALL tenants (deliberate, documented
cross-tenant platform-scheduler read, never forTenant()/RLS), decrypts
each tenant's credentials, picks imap/exchange provider, and tags every
extracted candidate with ownerTenantId — catch-per-tenant so one broken
mailbox never blocks the others.
tenders.module.ts: imports CalendarModule/InboxModule, registers
EmailAlertAdapter + TenderEmailConfigService, seeds an 'email-alert'
TenderSourcePollConfig row (pollGranularity='tick', isActive=false —
no default mailbox to activate yet, D-02 framework-ready stance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { CalendarModule } from '../calendar/calendar.module';
|
||||
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 { seedTendersModule } from './tenders.seed';
|
||||
import { TenderDedupService } from './tender-dedup.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';
|
||||
@@ -104,15 +108,29 @@ import { TendersController } from './tenders.controller';
|
||||
* 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). `CalendarModule`/`InboxModule` are imported so the
|
||||
* adapter/service can inject `CalendarCryptoService` (credential encryption)
|
||||
* 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],
|
||||
imports: [ModuleRegistryModule, SettingsModule, CalendarModule, InboxModule],
|
||||
controllers: [TendersController],
|
||||
providers: [
|
||||
DoeOpenDataAdapter,
|
||||
NetServerAdapter,
|
||||
CosinexAdapter,
|
||||
RssAdapter,
|
||||
EmailAlertAdapter,
|
||||
SourceRegistry,
|
||||
TenderNormalizerService,
|
||||
TenderDedupService,
|
||||
@@ -125,6 +143,7 @@ import { TendersController } from './tenders.controller';
|
||||
TenderDigestScheduler,
|
||||
TenderNotificationPrefService,
|
||||
TenderRssFeedSourceService,
|
||||
TenderEmailConfigService,
|
||||
],
|
||||
})
|
||||
export class TendersModule implements OnModuleInit {
|
||||
@@ -138,6 +157,7 @@ export class TendersModule implements OnModuleInit {
|
||||
private readonly netServerAdapter: NetServerAdapter,
|
||||
private readonly cosinexAdapter: CosinexAdapter,
|
||||
private readonly rssAdapter: RssAdapter,
|
||||
private readonly emailAlertAdapter: EmailAlertAdapter,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -145,16 +165,20 @@ export class TendersModule implements OnModuleInit {
|
||||
// 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, 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).
|
||||
// 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()
|
||||
@@ -276,5 +300,30 @@ export class TendersModule implements OnModuleInit {
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user