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:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Tender" ADD COLUMN "ownerTenantId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TenderEmailConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"protocol" TEXT NOT NULL DEFAULT 'imap',
|
||||||
|
"host" TEXT,
|
||||||
|
"port" INTEGER,
|
||||||
|
"encryption" TEXT NOT NULL DEFAULT 'ssl-tls',
|
||||||
|
"folder" TEXT NOT NULL DEFAULT 'INBOX',
|
||||||
|
"senderFilter" TEXT,
|
||||||
|
"domain" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"encryptedInboxCreds" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TenderEmailConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TenderEmailConfig_tenantId_key" ON "TenderEmailConfig"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TenderEmailConfig_tenantId_idx" ON "TenderEmailConfig"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Tender_ownerTenantId_idx" ON "Tender"("ownerTenantId");
|
||||||
@@ -197,6 +197,31 @@ model DkvModuleConfig {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
||||||
|
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
||||||
|
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
||||||
|
// its own independent mailbox config, this is a SEPARATE mailbox from the
|
||||||
|
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
|
||||||
|
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
|
||||||
|
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12).
|
||||||
|
model TenderEmailConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
protocol String @default("imap") // 'imap' | 'exchange'
|
||||||
|
host String?
|
||||||
|
port Int?
|
||||||
|
encryption String @default("ssl-tls") // 'none' | 'starttls' | 'ssl-tls'
|
||||||
|
folder String @default("INBOX")
|
||||||
|
senderFilter String?
|
||||||
|
domain String? // Exchange only: Windows domain (optional)
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
encryptedInboxCreds String? // AES-256-GCM: JSON { username, password } encrypted
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
model DkvVehicleMaster {
|
model DkvVehicleMaster {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
@@ -285,6 +310,15 @@ model Tender {
|
|||||||
// for pre-existing rows by backfill-tender-source.ts (Plan 13-01 Task 2).
|
// for pre-existing rows by backfill-tender-source.ts (Plan 13-01 Task 2).
|
||||||
// dedupKey above stays the SCHEMA-02 upsert target — NOT replaced here.
|
// dedupKey above stays the SCHEMA-02 upsert target — NOT replaced here.
|
||||||
fingerprint String?
|
fingerprint String?
|
||||||
|
// Phase 14, Plan 03 (INGEST-05, D-13) — per-tenant visibility for PRIVATE
|
||||||
|
// sources only. null = global/platform-wide (D-03, unchanged for all
|
||||||
|
// public sources: DÖE/NetServer/cosinex/RSS — existing rows stay null,
|
||||||
|
// no backfill). Set = visible ONLY to that tenant (email-alert tenders,
|
||||||
|
// since an alert mailbox reflects one tenant's private subscription).
|
||||||
|
// Read filter: buildTenderWhere OR[{ownerTenantId:null},{ownerTenantId:tenant}].
|
||||||
|
// Write: dedup CREATE only sets this — the UPDATE branch never touches it,
|
||||||
|
// so a source later also seen globally is never retroactively hidden.
|
||||||
|
ownerTenantId String?
|
||||||
publishedAt DateTime
|
publishedAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -295,7 +329,10 @@ model Tender {
|
|||||||
@@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance
|
@@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance
|
||||||
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
|
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
|
||||||
@@index([fingerprint]) // SCHEMA-03: dedup resolver fingerprint-tier lookup
|
@@index([fingerprint]) // SCHEMA-03: dedup resolver fingerprint-tier lookup
|
||||||
// Deliberately NO tenant column and NO tenant index — this is global data (D-03)
|
@@index([ownerTenantId]) // D-13: read-filter lookup for private (email-alert) tenders
|
||||||
|
// Deliberately NO tenant column/index for the platform-wide default (D-03)
|
||||||
|
// — ownerTenantId above is the sole, additive, nullable exception for
|
||||||
|
// privately-sourced tenders (D-13).
|
||||||
triage TenderTriage[]
|
triage TenderTriage[]
|
||||||
matches TenderMatch[]
|
matches TenderMatch[]
|
||||||
sources TenderSource[] // SCHEMA-03/D-03 — all source portals this tender was seen on
|
sources TenderSource[] // SCHEMA-03/D-03 — all source portals this tender was seen on
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
EmailAlertAdapter,
|
EmailAlertAdapter,
|
||||||
extractCandidateLinks,
|
extractCandidateLinks,
|
||||||
@@ -11,11 +11,49 @@ import {
|
|||||||
/**
|
/**
|
||||||
* email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic
|
* email-alert.adapter.spec — Task 1 (test-first, TDD) proof for the generic
|
||||||
* link/subject extraction (D-04): pure functions only, no I/O, mirroring
|
* link/subject extraction (D-04): pure functions only, no I/O, mirroring
|
||||||
* cosinex.adapter.spec.ts's pure-function spec style.
|
* cosinex.adapter.spec.ts's pure-function spec style. Task 2 adds
|
||||||
|
* fetchTenders() fan-out coverage (mocked PrismaService/CalendarCryptoService/
|
||||||
|
* inbox providers — no live DB/network I/O).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Fake CalendarCryptoService — deterministic reversible encode, not real AES. */
|
||||||
|
function makeFakeCrypto() {
|
||||||
|
return {
|
||||||
|
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
|
||||||
|
decrypt: vi.fn((stored: string) => {
|
||||||
|
if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format');
|
||||||
|
return Buffer.from(stored.slice(4), 'base64').toString('utf8');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAdapter(overrides: {
|
||||||
|
configs?: any[];
|
||||||
|
imapFetchMessages?: ReturnType<typeof vi.fn>;
|
||||||
|
exchangeFetchMessages?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {}) {
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const prisma = {
|
||||||
|
tenderEmailConfig: {
|
||||||
|
findMany: vi.fn(async () => overrides.configs ?? []),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const imapProvider = { fetchMessages: overrides.imapFetchMessages ?? vi.fn(async () => []) };
|
||||||
|
const exchangeProvider = {
|
||||||
|
fetchMessages: overrides.exchangeFetchMessages ?? vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const adapter = new EmailAlertAdapter(
|
||||||
|
prisma as any,
|
||||||
|
crypto as any,
|
||||||
|
imapProvider as any,
|
||||||
|
exchangeProvider as any,
|
||||||
|
);
|
||||||
|
return { adapter, prisma, crypto, imapProvider, exchangeProvider };
|
||||||
|
}
|
||||||
|
|
||||||
describe('EmailAlertAdapter', () => {
|
describe('EmailAlertAdapter', () => {
|
||||||
it('declares sourceType email-alert and the single email-alert portal', () => {
|
it('declares sourceType email-alert and the single email-alert portal', () => {
|
||||||
const adapter = new EmailAlertAdapter();
|
const { adapter } = makeAdapter();
|
||||||
expect(adapter.sourceType).toBe('email-alert');
|
expect(adapter.sourceType).toBe('email-alert');
|
||||||
expect(adapter.portals).toEqual(['email-alert']);
|
expect(adapter.portals).toEqual(['email-alert']);
|
||||||
});
|
});
|
||||||
@@ -131,4 +169,211 @@ describe('EmailAlertAdapter', () => {
|
|||||||
// extraction only, mirroring the RESEARCH.md Anti-Pattern guard.
|
// extraction only, mirroring the RESEARCH.md Anti-Pattern guard.
|
||||||
expect(source).not.toMatch(/senderDomain|from\.includes\(|sender\.includes\(/);
|
expect(source).not.toMatch(/senderDomain|from\.includes\(|sender\.includes\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchTenders (per-tenant fan-out, Task 2, INGEST-05/D-13)', () => {
|
||||||
|
const MSG = {
|
||||||
|
uid: 1,
|
||||||
|
messageId: '<msg-1>',
|
||||||
|
subject: 'Neue Ausschreibung Nr. 42',
|
||||||
|
from: 'alerts@portal.example',
|
||||||
|
date: new Date('2026-07-23T09:00:00.000Z'),
|
||||||
|
bodyHtml: '<a href="https://portal.example/detail/42">Details</a>',
|
||||||
|
bodyText: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('only queries isActive TenderEmailConfig rows (cross-tenant read, deliberate)', async () => {
|
||||||
|
const { adapter, prisma } = makeAdapter({ configs: [] });
|
||||||
|
|
||||||
|
await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(prisma.tenderEmailConfig.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags each extracted record with ownerTenantId = the configuring tenant (D-13)', async () => {
|
||||||
|
const imapFetchMessages = vi.fn(async () => [MSG]);
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
protocol: 'imap',
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
encryptedInboxCreds: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imapFetchMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]?.ownerTenantId).toBe('tenant-a');
|
||||||
|
expect(records[0]?.sourceType).toBe('email-alert');
|
||||||
|
expect(records[0]?.sourcePortal).toBe('email-alert');
|
||||||
|
expect(records[0]?.sourceUrl).toBe('https://portal.example/detail/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes protocol=exchange configs to the ExchangeInboxProvider, imap to ImapProvider', async () => {
|
||||||
|
const imapFetchMessages = vi.fn(async () => []);
|
||||||
|
const exchangeFetchMessages = vi.fn(async () => [MSG]);
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-ews',
|
||||||
|
protocol: 'exchange',
|
||||||
|
host: 'https://mail.example.test/EWS/Exchange.asmx',
|
||||||
|
port: null,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: 'CONTOSO',
|
||||||
|
encryptedInboxCreds: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imapFetchMessages,
|
||||||
|
exchangeFetchMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(imapFetchMessages).not.toHaveBeenCalled();
|
||||||
|
expect(exchangeFetchMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]?.ownerTenantId).toBe('tenant-ews');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypts encryptedInboxCreds and passes username/password into InboxConfig', async () => {
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const encrypted = crypto.encrypt(
|
||||||
|
JSON.stringify({ username: 'alerts@example.test', password: 'secret' }),
|
||||||
|
);
|
||||||
|
const imapFetchMessages = vi.fn(async () => []);
|
||||||
|
const prisma = {
|
||||||
|
tenderEmailConfig: {
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
protocol: 'imap',
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
encryptedInboxCreds: encrypted,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const adapter = new EmailAlertAdapter(
|
||||||
|
prisma as any,
|
||||||
|
crypto as any,
|
||||||
|
{ fetchMessages: imapFetchMessages } as any,
|
||||||
|
{ fetchMessages: vi.fn(async () => []) } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(imapFetchMessages).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ username: 'alerts@example.test', password: 'secret' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => {
|
||||||
|
const throwingFetch = vi.fn(async () => {
|
||||||
|
throw new Error('connection refused');
|
||||||
|
});
|
||||||
|
const workingFetch = vi.fn(async () => [MSG]);
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const prisma = {
|
||||||
|
tenderEmailConfig: {
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-broken',
|
||||||
|
protocol: 'imap',
|
||||||
|
host: 'imap.broken.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
encryptedInboxCreds: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-ok',
|
||||||
|
protocol: 'imap',
|
||||||
|
host: 'imap.ok.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
encryptedInboxCreds: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// First mailbox throws, second succeeds — same imapProvider instance,
|
||||||
|
// called twice (once per tenant), mockImplementationOnce per call.
|
||||||
|
const imapProvider = {
|
||||||
|
fetchMessages: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(throwingFetch)
|
||||||
|
.mockImplementationOnce(workingFetch),
|
||||||
|
};
|
||||||
|
const adapter = new EmailAlertAdapter(
|
||||||
|
prisma as any,
|
||||||
|
crypto as any,
|
||||||
|
imapProvider as any,
|
||||||
|
{ fetchMessages: vi.fn(async () => []) } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const records = await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2);
|
||||||
|
expect(records).toHaveLength(1);
|
||||||
|
expect(records[0]?.ownerTenantId).toBe('tenant-ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when there are no active configs', async () => {
|
||||||
|
const { adapter } = makeAdapter({ configs: [] });
|
||||||
|
const records = await adapter.fetchTenders('2026-07-23');
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces one RawTenderRecord per extracted candidate link (multiple links in one message)', async () => {
|
||||||
|
const multiLinkMsg = {
|
||||||
|
...MSG,
|
||||||
|
bodyHtml:
|
||||||
|
'<a href="https://portal.example/detail/1">A</a><a href="https://portal.example/detail/2">B</a>',
|
||||||
|
};
|
||||||
|
const { adapter } = makeAdapter({
|
||||||
|
configs: [
|
||||||
|
{
|
||||||
|
tenantId: 'tenant-a',
|
||||||
|
protocol: 'imap',
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
folder: 'INBOX',
|
||||||
|
senderFilter: null,
|
||||||
|
domain: null,
|
||||||
|
encryptedInboxCreds: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
imapFetchMessages: vi.fn(async () => [multiLinkMsg]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = await adapter.fetchTenders('2026-07-23');
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
expect(records.every((r) => r.ownerTenantId === 'tenant-a')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
import { CalendarCryptoService } from '../../calendar/crypto.service';
|
||||||
|
import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider';
|
||||||
|
import { ImapProvider } from '../../inbox/imap.provider';
|
||||||
|
import type { InboxConfig, InboxMessage } from '../../inbox/inbox-provider.interface';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import type { RawTenderRecord, SourceType } from '../tender.types';
|
import type { RawTenderRecord, SourceType } from '../tender.types';
|
||||||
import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||||
|
|
||||||
@@ -12,16 +17,26 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
|||||||
* deliberate, documented consequence (D-05) — the same deferral already
|
* deliberate, documented consequence (D-05) — the same deferral already
|
||||||
* accepted for the NetServer/cosinex/RSS scraper adapters.
|
* accepted for the NetServer/cosinex/RSS scraper adapters.
|
||||||
*
|
*
|
||||||
* Task 1 (this file, generic extraction + normalizer dispatch) ships the
|
* `fetchTenders()` (Plan 14-03 Task 2) is an internal per-tenant fan-out
|
||||||
* three pure helpers below plus a contract-complete but not-yet-wired
|
* (RESEARCH.md Pattern 1, same shape as NetServerAdapter's per-portal loop
|
||||||
* `fetchTenders()`. Task 2 replaces the constructor/fetchTenders body with
|
* and RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig`
|
||||||
* the real per-tenant fan-out over `TenderEmailConfig` rows, consuming
|
* row across ALL tenants in ONE query — this is a DELIBERATE, audited
|
||||||
* `InboxProvider.fetchMessages` from the shared `inbox/` module (Plan 14-01).
|
* cross-tenant read at the platform-scheduler level (mirrors the same
|
||||||
|
* documented exception for RssAdapter/TenderIngestionService itself) and
|
||||||
|
* must NEVER be wrapped in `forTenant()`/RLS. Each tenant's mailbox is
|
||||||
|
* fetched independently via the shared `inbox/` module's
|
||||||
|
* `InboxProvider.fetchMessages` (Plan 14-01) — catch-per-tenant, so one
|
||||||
|
* tenant's broken/unreachable mailbox never blocks the others in the same
|
||||||
|
* tick. Every extracted candidate is tagged with `ownerTenantId` = the
|
||||||
|
* configuring tenant's id (D-13) so the resulting Tender rows stay private
|
||||||
|
* to that tenant on the read side (tender-query.builder.ts).
|
||||||
*
|
*
|
||||||
* Security (T-14-03-03, stored-XSS guard): only plain-text `title` and raw
|
* Security (T-14-03-03, stored-XSS guard): only plain-text `title` and raw
|
||||||
* `href` URL strings are ever extracted from an alert email body — cheerio
|
* `href` URL strings are ever extracted from an alert email body — cheerio
|
||||||
* `.attr('href')`/plaintext regex only, NEVER `.html()` — so no raw email
|
* `.attr('href')`/plaintext regex only, NEVER `.html()` — so no raw email
|
||||||
* markup ever crosses into a RawTenderRecord/Tender row.
|
* markup ever crosses into a RawTenderRecord/Tender row.
|
||||||
|
* Security (T-05-13): decrypted mailbox credentials only ever exist within
|
||||||
|
* `resolveDecryptedConfig`'s return value scope — never logged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,19 +102,149 @@ export function sourceNoticeIdFor(link: string): string {
|
|||||||
return createHash('sha256').update(link).digest('hex').slice(0, 40);
|
return createHash('sha256').update(link).digest('hex').slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal shape read from a TenderEmailConfig row (decrypt input). */
|
||||||
|
interface DecryptableEmailConfig {
|
||||||
|
protocol: string;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
encryption: string;
|
||||||
|
folder: string;
|
||||||
|
senderFilter: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
encryptedInboxCreds: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailAlertAdapter implements TenderSourceAdapter {
|
export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||||
readonly sourceType: SourceType = 'email-alert';
|
readonly sourceType: SourceType = 'email-alert';
|
||||||
readonly portals = ['email-alert'] as const;
|
readonly portals = ['email-alert'] as const;
|
||||||
|
|
||||||
protected readonly logger = new Logger(EmailAlertAdapter.name);
|
private readonly logger = new Logger(EmailAlertAdapter.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CalendarCryptoService,
|
||||||
|
private readonly imapProvider: ImapProvider,
|
||||||
|
private readonly exchangeProvider: ExchangeInboxProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task 1 placeholder — keeps the class contract-complete (TenderSourceAdapter)
|
* `_dayCursor` is accepted for interface conformance but NOT used as a
|
||||||
* for this task's generic-extraction-only scope. Task 2 replaces this with
|
* filter — email-alert is a 'tick'-granularity source (D-15, seeded in
|
||||||
* the real per-tenant fan-out over TenderEmailConfig + InboxProvider.
|
* tenders.module.ts), polled every active scheduler tick; idempotency
|
||||||
|
* comes from IMAP `\Seen`/EWS `IsRead` marking inside the inbox providers,
|
||||||
|
* not a day-cursor.
|
||||||
*/
|
*/
|
||||||
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
||||||
return [];
|
// Deliberate cross-tenant read (see class docstring) — NEVER wrap in
|
||||||
|
// forTenant()/RLS. This is the platform scheduler resolving every
|
||||||
|
// tenant's active mailbox in one tick, not a per-tenant request.
|
||||||
|
const configs = await this.prisma.tenderEmailConfig.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchedAt = new Date();
|
||||||
|
const records: RawTenderRecord[] = [];
|
||||||
|
|
||||||
|
for (const cfg of configs) {
|
||||||
|
try {
|
||||||
|
const inboxConfig = this.resolveDecryptedConfig(cfg);
|
||||||
|
const provider =
|
||||||
|
cfg.protocol === 'exchange' ? this.exchangeProvider : this.imapProvider;
|
||||||
|
const messages = await provider.fetchMessages(inboxConfig);
|
||||||
|
records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt));
|
||||||
|
} catch (error) {
|
||||||
|
// Catch-per-tenant (D-01 discipline, mirrors NetServer/RssAdapter):
|
||||||
|
// one tenant's broken/unreachable mailbox never blocks the others.
|
||||||
|
this.logger.warn(
|
||||||
|
`Email-alert mailbox for tenant ${cfg.tenantId} failed, skipping: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a TenderEmailConfig row's credentials into an InboxConfig for
|
||||||
|
* the shared inbox providers. T-05-13: the decrypted password only ever
|
||||||
|
* exists within this method's return value — never logged. A decrypt
|
||||||
|
* failure resolves to no credentials (the provider's own auth failure is
|
||||||
|
* then caught by fetchTenders' per-tenant try/catch above), matching the
|
||||||
|
* "ignore decrypt errors" convention already used by
|
||||||
|
* DkvService/TenderEmailConfigService.
|
||||||
|
*/
|
||||||
|
private resolveDecryptedConfig(cfg: DecryptableEmailConfig): InboxConfig {
|
||||||
|
let username: string | undefined;
|
||||||
|
let password: string | undefined;
|
||||||
|
|
||||||
|
if (cfg.encryptedInboxCreds) {
|
||||||
|
try {
|
||||||
|
const creds = JSON.parse(this.crypto.decrypt(cfg.encryptedInboxCreds)) as {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
username = creds.username;
|
||||||
|
password = creds.password;
|
||||||
|
} catch {
|
||||||
|
// Ignore — proceed with no credentials, see method doc above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: cfg.protocol,
|
||||||
|
host: cfg.host ?? '',
|
||||||
|
port: cfg.port ?? 993,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
encryption: cfg.encryption,
|
||||||
|
folder: cfg.folder,
|
||||||
|
senderFilter: cfg.senderFilter ?? undefined,
|
||||||
|
domain: cfg.domain ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps one tenant's fetched InboxMessages into RawTenderRecord[] — one
|
||||||
|
* record per extracted candidate link (D-04), every record tagged with
|
||||||
|
* `ownerTenantId` (D-13) so the resulting Tender rows stay private to
|
||||||
|
* this tenant on the read side.
|
||||||
|
*/
|
||||||
|
private extractCandidates(
|
||||||
|
messages: InboxMessage[],
|
||||||
|
ownerTenantId: string,
|
||||||
|
fetchedAt: Date,
|
||||||
|
): RawTenderRecord[] {
|
||||||
|
const records: RawTenderRecord[] = [];
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const links = extractCandidateLinks(message.bodyHtml, message.bodyText);
|
||||||
|
const title = titleFromEmail(message.subject, message.bodyText);
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
records.push({
|
||||||
|
sourceType: this.sourceType,
|
||||||
|
sourcePortal: 'email-alert',
|
||||||
|
sourceNoticeId: sourceNoticeIdFor(link),
|
||||||
|
sourceUrl: link,
|
||||||
|
fetchedAt,
|
||||||
|
publishedAt: message.date ?? null,
|
||||||
|
eformsPayload: null,
|
||||||
|
// Same generic bag shape as RssAdapter/NetServerAdapter —
|
||||||
|
// buyerName/procedureType/deadlineAt stay null (D-04/D-05 thin
|
||||||
|
// field deferral); normalizeBag() maps this identically.
|
||||||
|
ocdsPayload: {
|
||||||
|
title,
|
||||||
|
buyerName: null,
|
||||||
|
procedureType: null,
|
||||||
|
legalFramework: null,
|
||||||
|
deadlineAt: null,
|
||||||
|
},
|
||||||
|
ownerTenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating or updating the per-tenant TenderEmailConfig (Phase 14,
|
||||||
|
* Plan 03, INGEST-05/CONFIG-02, D-06/D-07). Mirrors DkvConfigDto's mailbox
|
||||||
|
* fields exactly — this is a SEPARATE, tenant-scoped alert mailbox, not the
|
||||||
|
* DKV invoice inbox (D-03).
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - T-14-03-02: senderFilter validated as email address (injection mitigation)
|
||||||
|
* - T-14-03-02: port constrained to 1-65535
|
||||||
|
* - T-14-03-02: protocol/encryption constrained with @IsIn
|
||||||
|
*/
|
||||||
|
export class TenderEmailConfigDto {
|
||||||
|
/** Inbox protocol — 'imap' for IMAP, 'exchange' for Exchange (EWS). */
|
||||||
|
@IsIn(['imap', 'exchange'])
|
||||||
|
protocol!: string;
|
||||||
|
|
||||||
|
/** Mail server hostname/IP (IMAP) or full EWS endpoint URL (Exchange). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
host?: string;
|
||||||
|
|
||||||
|
/** TCP port. Standard values: 993 (IMAP SSL/TLS), 143 (IMAP STARTTLS), 443 (EWS). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
port?: number;
|
||||||
|
|
||||||
|
/** TLS mode: 'none' | 'starttls' | 'ssl-tls'. */
|
||||||
|
@IsIn(['none', 'starttls', 'ssl-tls'])
|
||||||
|
encryption!: string;
|
||||||
|
|
||||||
|
/** IMAP folder to monitor (e.g. "INBOX"). Exchange resolves via display name. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
folder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender email address to filter by. Validated as email address to
|
||||||
|
* prevent header injection (mirrors DkvConfigDto / T-07-04).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
senderFilter?: string;
|
||||||
|
|
||||||
|
/** Exchange only: Windows domain (optional). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
domain?: string;
|
||||||
|
|
||||||
|
/** Inbox username (stored encrypted; blank on load, T-07-12). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox password (stored encrypted; blank on load).
|
||||||
|
* T-07-12: never returned to the frontend in responses.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
/** Whether the email-alert poll is active for this tenant's mailbox. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -284,3 +284,54 @@ describe('TenderDedupService.resolve — SCHEMA-02 change-detection preservation
|
|||||||
expect(prisma.tender.update).not.toHaveBeenCalled();
|
expect(prisma.tender.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TenderDedupService.resolve — D-13 ownerTenantId (Phase 14, Plan 03, INGEST-05)', () => {
|
||||||
|
it('CREATE writes ownerTenantId from the normalized record (email-alert, private)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const emailRecord: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
ocid: null,
|
||||||
|
dedupKey: 'email-alert:link-hash-1',
|
||||||
|
sourcePortal: 'email-alert',
|
||||||
|
sourceNoticeId: 'link-hash-1',
|
||||||
|
ownerTenantId: 'tenant-a',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.resolve(emailRecord, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(result.created).toBe(true);
|
||||||
|
const row = prisma.__store.tenders.get(result.tenderId);
|
||||||
|
expect(row.ownerTenantId).toBe('tenant-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CREATE defaults ownerTenantId to null when the record has none (every public source, D-03)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.resolve(BASE, { dedupActive: true });
|
||||||
|
|
||||||
|
const row = prisma.__store.tenders.get(result.tenderId);
|
||||||
|
expect(row.ownerTenantId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPDATE branch (contentHash changed on a match) never references ownerTenantId — a globally-reseen tender is never retroactively hidden', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new TenderDedupService(prisma as any);
|
||||||
|
|
||||||
|
await service.resolve(BASE, { dedupActive: true });
|
||||||
|
prisma.tender.update.mockClear();
|
||||||
|
|
||||||
|
const rePoll: NormalizedTenderFields = {
|
||||||
|
...BASE,
|
||||||
|
title: 'Sanierung Stadtbrücke (Frist verlängert)',
|
||||||
|
contentHash: 'hash-2',
|
||||||
|
};
|
||||||
|
await service.resolve(rePoll, { dedupActive: true });
|
||||||
|
|
||||||
|
expect(prisma.tender.update).toHaveBeenCalledTimes(1);
|
||||||
|
const updateData = prisma.tender.update.mock.calls[0][0].data;
|
||||||
|
expect(updateData).not.toHaveProperty('ownerTenantId');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ export class TenderDedupService {
|
|||||||
contentHash: n.contentHash,
|
contentHash: n.contentHash,
|
||||||
publishedAt: n.publishedAt,
|
publishedAt: n.publishedAt,
|
||||||
fingerprint,
|
fingerprint,
|
||||||
|
// D-13: CREATE-only. undefined -> Prisma omits the field -> DB
|
||||||
|
// default null (global) for every source except email-alert. The
|
||||||
|
// UPDATE branch above deliberately never sets this — a tender first
|
||||||
|
// seen privately that is later ALSO seen on a public source must
|
||||||
|
// never be retroactively re-hidden by a re-poll of the private one.
|
||||||
|
ownerTenantId: n.ownerTenantId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenderEmailConfigService.spec — Phase 14, Plan 03 (CONFIG-02, D-06/D-07).
|
||||||
|
* Hand-rolled fake PrismaService (Map) + a fake CalendarCryptoService
|
||||||
|
* (deterministic reversible encode, NOT real AES) — same convention as
|
||||||
|
* tender-dedup.service.spec.ts: no live DB/crypto dependency, just proving
|
||||||
|
* this service's own encrypt-preserve-empty / safe-select contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeFakeCrypto() {
|
||||||
|
return {
|
||||||
|
encrypt: vi.fn((plaintext: string) => `enc:${Buffer.from(plaintext).toString('base64')}`),
|
||||||
|
decrypt: vi.fn((stored: string) => {
|
||||||
|
if (!stored.startsWith('enc:')) throw new Error('Invalid encrypted value format');
|
||||||
|
return Buffer.from(stored.slice(4), 'base64').toString('utf8');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const configs = new Map<string, any>();
|
||||||
|
return {
|
||||||
|
tenderEmailConfig: {
|
||||||
|
findUnique: vi.fn(async ({ where, select }: any) => {
|
||||||
|
const row = configs.get(where.tenantId);
|
||||||
|
if (!row) return null;
|
||||||
|
if (!select) return row;
|
||||||
|
const out: any = {};
|
||||||
|
for (const k of Object.keys(select)) out[k] = row[k];
|
||||||
|
return out;
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(async ({ where, update, create, select }: any) => {
|
||||||
|
const existing = configs.get(where.tenantId);
|
||||||
|
const row = existing ? { ...existing, ...update } : { id: 'cfg-1', ...create };
|
||||||
|
configs.set(where.tenantId, row);
|
||||||
|
if (!select) return row;
|
||||||
|
const out: any = {};
|
||||||
|
for (const k of Object.keys(select)) out[k] = row[k];
|
||||||
|
return out;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
__store: configs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TenderEmailConfigService', () => {
|
||||||
|
it('getConfigForApi returns null when no config exists for the tenant', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.getConfigForApi('tenant-missing');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveConfig encrypts {username,password} and getConfigForApi round-trips username, NEVER returns the password field (T-07-12)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig('tenant-a', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
folder: 'INBOX',
|
||||||
|
username: 'alerts@example.test',
|
||||||
|
password: 'super-secret',
|
||||||
|
isActive: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const apiResult = await service.getConfigForApi('tenant-a');
|
||||||
|
|
||||||
|
expect(apiResult).not.toBeNull();
|
||||||
|
expect(apiResult).not.toHaveProperty('password');
|
||||||
|
expect(apiResult).not.toHaveProperty('encryptedInboxCreds');
|
||||||
|
expect(apiResult!.username).toBe('alerts@example.test');
|
||||||
|
expect(apiResult!.hasPassword).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveConfig with no username/password leaves hasPassword false and username null (fresh config)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig('tenant-b', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
host: 'imap.example.test',
|
||||||
|
port: 993,
|
||||||
|
folder: 'INBOX',
|
||||||
|
isActive: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const apiResult = await service.getConfigForApi('tenant-b');
|
||||||
|
|
||||||
|
expect(apiResult!.hasPassword).toBe(false);
|
||||||
|
expect(apiResult!.username).toBeNull();
|
||||||
|
expect(crypto.encrypt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveConfig preserves the existing password when only username changes on a re-save', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig('tenant-c', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
username: 'old@example.test',
|
||||||
|
password: 'original-secret',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Re-save with a new username, password left blank (T-07-12 UI convention)
|
||||||
|
await service.saveConfig('tenant-c', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
username: 'new@example.test',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const raw = prisma.__store.get('tenant-c');
|
||||||
|
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||||
|
expect(decrypted.username).toBe('new@example.test');
|
||||||
|
expect(decrypted.password).toBe('original-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveConfig preserves the existing username when only password changes on a re-save', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
await service.saveConfig('tenant-d', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
username: 'stable@example.test',
|
||||||
|
password: 'first-secret',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await service.saveConfig('tenant-d', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
password: 'rotated-secret',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const raw = prisma.__store.get('tenant-d');
|
||||||
|
const decrypted = JSON.parse(crypto.decrypt(raw.encryptedInboxCreds));
|
||||||
|
expect(decrypted.username).toBe('stable@example.test');
|
||||||
|
expect(decrypted.password).toBe('rotated-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the safe select never includes encryptedInboxCreds in the upsert return value (T-07-12)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const crypto = makeFakeCrypto();
|
||||||
|
const service = new TenderEmailConfigService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.saveConfig('tenant-e', {
|
||||||
|
protocol: 'imap',
|
||||||
|
encryption: 'ssl-tls',
|
||||||
|
username: 'x@example.test',
|
||||||
|
password: 'y',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty('encryptedInboxCreds');
|
||||||
|
expect(result).not.toHaveProperty('password');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CalendarCryptoService } from '../calendar/crypto.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma select for TenderEmailConfig — never includes encryptedInboxCreds.
|
||||||
|
* T-07-12: Encrypted credential blob is excluded from all API responses.
|
||||||
|
*/
|
||||||
|
const EMAIL_CONFIG_SAFE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
protocol: true,
|
||||||
|
host: true,
|
||||||
|
port: true,
|
||||||
|
encryption: true,
|
||||||
|
folder: true,
|
||||||
|
senderFilter: true,
|
||||||
|
domain: true,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
// encryptedInboxCreds: NEVER included — T-07-12
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TenderEmailConfigService — per-tenant admin CRUD for the portal-alert
|
||||||
|
* mailbox config (Phase 14, Plan 03, INGEST-05/CONFIG-02, D-06/D-07).
|
||||||
|
* Structural clone of DkvService's config half (safe-select + encrypt-
|
||||||
|
* preserve-empty semantics), mirroring the exact same pattern already
|
||||||
|
* proven for DKV's own (separate, D-03) mailbox config.
|
||||||
|
*
|
||||||
|
* Security:
|
||||||
|
* - T-07-12: encryptedInboxCreds is excluded from every read-path select;
|
||||||
|
* getConfigForApi returns `hasPassword: boolean` instead of the password.
|
||||||
|
* - T-05-13: decrypted credentials only ever exist within a method's local
|
||||||
|
* scope — never logged.
|
||||||
|
*
|
||||||
|
* This service is used ONLY by the admin GET/PUT /email-config routes
|
||||||
|
* (TendersController). EmailAlertAdapter's own per-tenant poll-time fan-out
|
||||||
|
* decrypts credentials independently via a direct CalendarCryptoService
|
||||||
|
* injection (RESEARCH.md Pattern 1) — it does NOT go through this service,
|
||||||
|
* since the adapter's cross-tenant `findMany({where:{isActive:true}})` read
|
||||||
|
* is a deliberate platform-scheduler exception (see EmailAlertAdapter's
|
||||||
|
* docstring), structurally different from this service's tenant-scoped CRUD.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TenderEmailConfigService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly crypto: CalendarCryptoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load config for API response: safe fields + decrypted username +
|
||||||
|
* hasPassword flag. T-07-12: password is NEVER returned.
|
||||||
|
*/
|
||||||
|
async getConfigForApi(tenantId: string) {
|
||||||
|
const safe = await this.prisma.tenderEmailConfig.findUnique({
|
||||||
|
where: { tenantId },
|
||||||
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||||
|
});
|
||||||
|
if (!safe) return null;
|
||||||
|
|
||||||
|
let username: string | null = null;
|
||||||
|
let hasPassword = false;
|
||||||
|
try {
|
||||||
|
const raw = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||||
|
if (raw?.encryptedInboxCreds) {
|
||||||
|
const creds = JSON.parse(this.crypto.decrypt(raw.encryptedInboxCreds)) as {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
username = creds.username ?? null;
|
||||||
|
hasPassword = Boolean(creds.password);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore decrypt errors — return empty username, matches DkvService.getConfigForApi */
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...safe, username, hasPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert TenderEmailConfig for a tenant.
|
||||||
|
*
|
||||||
|
* Credential handling (identical semantics to DkvService.saveConfig):
|
||||||
|
* - dto.password non-empty: re-encrypt {username, password} together.
|
||||||
|
* - dto.username non-empty but dto.password empty: preserve existing
|
||||||
|
* password, re-encrypt with the new username.
|
||||||
|
* - both empty/undefined: preserve existing encryptedInboxCreds entirely.
|
||||||
|
*
|
||||||
|
* T-07-12: Returns safe select (no encryptedInboxCreds).
|
||||||
|
* T-05-13: Never logs decrypted credentials.
|
||||||
|
*/
|
||||||
|
async saveConfig(tenantId: string, dto: TenderEmailConfigDto) {
|
||||||
|
let encryptedInboxCreds: string | undefined;
|
||||||
|
|
||||||
|
const credChanged =
|
||||||
|
(dto.password && dto.password.length > 0) ||
|
||||||
|
(dto.username !== undefined && dto.username !== null);
|
||||||
|
|
||||||
|
if (credChanged) {
|
||||||
|
let username: string = dto.username ?? '';
|
||||||
|
let password: string = dto.password ?? '';
|
||||||
|
|
||||||
|
if (!dto.password || !dto.username) {
|
||||||
|
try {
|
||||||
|
const existing = await this.prisma.tenderEmailConfig.findUnique({ where: { tenantId } });
|
||||||
|
if (existing?.encryptedInboxCreds) {
|
||||||
|
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
if (!dto.username) username = stored.username ?? '';
|
||||||
|
if (!dto.password) password = stored.password ?? '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore decrypt errors — will overwrite with whatever was provided
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptedInboxCreds = this.crypto.encrypt(JSON.stringify({ username, password }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
protocol: dto.protocol,
|
||||||
|
encryption: dto.encryption,
|
||||||
|
...(dto.host !== undefined && { host: dto.host }),
|
||||||
|
...(dto.port !== undefined && { port: dto.port }),
|
||||||
|
...(dto.folder !== undefined && { folder: dto.folder }),
|
||||||
|
...(dto.senderFilter !== undefined && { senderFilter: dto.senderFilter }),
|
||||||
|
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
||||||
|
...(dto.domain !== undefined && { domain: dto.domain }),
|
||||||
|
...(encryptedInboxCreds !== undefined && { encryptedInboxCreds }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.prisma.tenderEmailConfig.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
update: data,
|
||||||
|
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,9 @@ export class TenderNormalizerService {
|
|||||||
sourceUrl: raw.sourceUrl ?? null,
|
sourceUrl: raw.sourceUrl ?? null,
|
||||||
contentHash,
|
contentHash,
|
||||||
publishedAt: raw.publishedAt ?? new Date(),
|
publishedAt: raw.publishedAt ?? new Date(),
|
||||||
|
// D-13: passed through unchanged — undefined for every source except
|
||||||
|
// EmailAlertAdapter, which sets it to the configuring tenant's id.
|
||||||
|
ownerTenantId: raw.ownerTenantId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export interface RawTenderRecord {
|
|||||||
eformsPayload: unknown;
|
eformsPayload: unknown;
|
||||||
/** Parsed OCDS release object (the single `releases[0]` entry) for this notice. */
|
/** Parsed OCDS release object (the single `releases[0]` entry) for this notice. */
|
||||||
ocdsPayload: unknown;
|
ocdsPayload: unknown;
|
||||||
|
/**
|
||||||
|
* D-13 (Phase 14, Plan 03) — set ONLY by EmailAlertAdapter, to the
|
||||||
|
* configuring tenant's id. Every other source leaves this undefined so
|
||||||
|
* the resulting Tender row stays global (null). Threaded through
|
||||||
|
* unchanged by TenderNormalizerService.assemble() and consumed by
|
||||||
|
* TenderDedupService's CREATE branch only (never the UPDATE branch).
|
||||||
|
*/
|
||||||
|
ownerTenantId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,4 +114,10 @@ export interface NormalizedTenderFields {
|
|||||||
* before the resolver exists.
|
* before the resolver exists.
|
||||||
*/
|
*/
|
||||||
fingerprint?: string;
|
fingerprint?: string;
|
||||||
|
/**
|
||||||
|
* D-13 (Phase 14, Plan 03) — passed through unchanged from
|
||||||
|
* RawTenderRecord.ownerTenantId by assemble(). null/undefined = global
|
||||||
|
* (every source except email-alert); set = visible only to that tenant.
|
||||||
|
*/
|
||||||
|
ownerTenantId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
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 { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SettingsModule } from '../settings/settings.module';
|
import { SettingsModule } from '../settings/settings.module';
|
||||||
import { CosinexAdapter } from './adapters/cosinex.adapter';
|
import { CosinexAdapter } from './adapters/cosinex.adapter';
|
||||||
import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter';
|
import { DoeOpenDataAdapter } from './adapters/doe-opendata.adapter';
|
||||||
|
import { EmailAlertAdapter } from './adapters/email-alert.adapter';
|
||||||
import { NetServerAdapter } from './adapters/netserver.adapter';
|
import { NetServerAdapter } from './adapters/netserver.adapter';
|
||||||
import { RssAdapter } from './adapters/rss.adapter';
|
import { RssAdapter } from './adapters/rss.adapter';
|
||||||
import { SourceRegistry } from './source-registry';
|
import { SourceRegistry } from './source-registry';
|
||||||
import { seedTendersModule } from './tenders.seed';
|
import { seedTendersModule } from './tenders.seed';
|
||||||
import { TenderDedupService } from './tender-dedup.service';
|
import { TenderDedupService } from './tender-dedup.service';
|
||||||
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
import { TenderDigestScheduler } from './tender-digest.scheduler';
|
||||||
|
import { TenderEmailConfigService } from './tender-email-config.service';
|
||||||
import { TenderIngestionService } from './tender-ingestion.service';
|
import { TenderIngestionService } from './tender-ingestion.service';
|
||||||
import { TenderMailService } from './tender-mail.service';
|
import { TenderMailService } from './tender-mail.service';
|
||||||
import { TenderMatchingService } from './tender-matching.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
|
* 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,
|
* (RESEARCH.md Open Question 3), so RSS ingestion is live out of the box,
|
||||||
* not "framework ready, activation deferred" like the Phase 13 sources.
|
* 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({
|
@Module({
|
||||||
imports: [ModuleRegistryModule, SettingsModule],
|
imports: [ModuleRegistryModule, SettingsModule, CalendarModule, InboxModule],
|
||||||
controllers: [TendersController],
|
controllers: [TendersController],
|
||||||
providers: [
|
providers: [
|
||||||
DoeOpenDataAdapter,
|
DoeOpenDataAdapter,
|
||||||
NetServerAdapter,
|
NetServerAdapter,
|
||||||
CosinexAdapter,
|
CosinexAdapter,
|
||||||
RssAdapter,
|
RssAdapter,
|
||||||
|
EmailAlertAdapter,
|
||||||
SourceRegistry,
|
SourceRegistry,
|
||||||
TenderNormalizerService,
|
TenderNormalizerService,
|
||||||
TenderDedupService,
|
TenderDedupService,
|
||||||
@@ -125,6 +143,7 @@ import { TendersController } from './tenders.controller';
|
|||||||
TenderDigestScheduler,
|
TenderDigestScheduler,
|
||||||
TenderNotificationPrefService,
|
TenderNotificationPrefService,
|
||||||
TenderRssFeedSourceService,
|
TenderRssFeedSourceService,
|
||||||
|
TenderEmailConfigService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TendersModule implements OnModuleInit {
|
export class TendersModule implements OnModuleInit {
|
||||||
@@ -138,6 +157,7 @@ export class TendersModule implements OnModuleInit {
|
|||||||
private readonly netServerAdapter: NetServerAdapter,
|
private readonly netServerAdapter: NetServerAdapter,
|
||||||
private readonly cosinexAdapter: CosinexAdapter,
|
private readonly cosinexAdapter: CosinexAdapter,
|
||||||
private readonly rssAdapter: RssAdapter,
|
private readonly rssAdapter: RssAdapter,
|
||||||
|
private readonly emailAlertAdapter: EmailAlertAdapter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
@@ -145,16 +165,20 @@ export class TendersModule implements OnModuleInit {
|
|||||||
// D-06/INGEST-07: registration itself is the denylist-gate enforcement
|
// D-06/INGEST-07: registration itself is the denylist-gate enforcement
|
||||||
// point — SourceRegistry.register() throws for any adapter serving a
|
// point — SourceRegistry.register() throws for any adapter serving a
|
||||||
// denylisted portal. DoeOpenDataAdapter, NetServerAdapter,
|
// denylisted portal. DoeOpenDataAdapter, NetServerAdapter,
|
||||||
// CosinexAdapter, and RssAdapter are all legitimate (none of
|
// CosinexAdapter, RssAdapter, and EmailAlertAdapter are all
|
||||||
// tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/rss are on the
|
// legitimate (none of tender24/lhs-vpbw/vergabe.landbw/cosinex-dtvp/
|
||||||
// denylist). Note: RssAdapter's `portals: ['rss']` is a SYMBOLIC
|
// rss/email-alert are on the denylist). Note: RssAdapter's
|
||||||
// placeholder — the actual admin-supplied feed hostnames are NOT
|
// `portals: ['rss']` is a SYMBOLIC placeholder — the actual
|
||||||
// covered by this gate at all (RESEARCH.md Pitfall 3); that runtime
|
// admin-supplied feed hostnames are NOT covered by this gate at all
|
||||||
// check lives in TenderRssFeedSourceService instead (D-14).
|
// (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.doeAdapter);
|
||||||
this.sourceRegistry.register(this.netServerAdapter);
|
this.sourceRegistry.register(this.netServerAdapter);
|
||||||
this.sourceRegistry.register(this.cosinexAdapter);
|
this.sourceRegistry.register(this.cosinexAdapter);
|
||||||
this.sourceRegistry.register(this.rssAdapter);
|
this.sourceRegistry.register(this.rssAdapter);
|
||||||
|
this.sourceRegistry.register(this.emailAlertAdapter);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Registered source adapters: ${this.sourceRegistry
|
`Registered source adapters: ${this.sourceRegistry
|
||||||
.activeAdapters()
|
.activeAdapters()
|
||||||
@@ -276,5 +300,30 @@ export class TendersModule implements OnModuleInit {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to seed service.bund.de RSS feed', 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