feat(14-03): add EmailAlertAdapter generic extraction + 'email-alert' normalizer dispatch

GREEN phase (TDD) for Task 1: extractCandidateLinks (cheerio a[href] +
footer-noise filter + MAX_LINKS_PER_EMAIL cap, plaintext regex fallback),
titleFromEmail (subject -> first body line -> fallback), and
sourceNoticeIdFor (sha256 link hash) implement D-04's generic, no-portal-
specific-parser evaluation of alert emails.

SourceType gains 'email-alert'; TenderNormalizerService routes it through
the existing normalizeBag() path (same as ai-netserver/cosinex-dtvp/rss).
EmailAlertAdapter.fetchTenders() is a Task-1 placeholder — Task 2 wires the
real per-tenant fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:38:36 +02:00
parent 4d6fbb136e
commit 8983231196
4 changed files with 158 additions and 6 deletions
@@ -85,7 +85,9 @@ function bagRecord(
? 'cosinex-dtvp'
: sourceType === 'rss'
? 'service-bund'
: 'tender24';
: sourceType === 'email-alert'
? 'email-alert'
: 'tender24';
return {
sourceType,
sourcePortal,
@@ -355,4 +357,40 @@ describe('TenderNormalizerService', () => {
expect(normalized.title).toBe('Unbenannte Ausschreibung');
});
});
describe("'email-alert' sourceType (Phase 14, Plan 03, INGEST-05)", () => {
it('maps an email-alert bag through normalizeBag(): title passes through, cpvDivisions stays empty', () => {
const raw = bagRecord('email-alert', {
title: 'Vergabebekanntmachung: Straßenbauarbeiten Musterstadt',
buyerName: null,
procedureType: null,
legalFramework: null,
deadlineAt: null,
});
const normalized = service.normalize(raw);
expect(normalized.title).toBe(
'Vergabebekanntmachung: Straßenbauarbeiten Musterstadt',
);
expect(normalized.cpvDivisions).toEqual([]);
expect(normalized.cpvCodes).toEqual([]);
expect(normalized.region).toBeNull();
expect(normalized.bundesland).toBeNull();
expect(normalized.buyerName).toBeNull();
expect(normalized.procedureType).toBeNull();
expect(normalized.deadlineAt).toBeNull();
expect(normalized.dedupKey).toBe(
`${raw.sourcePortal}:${raw.sourceNoticeId}`,
);
});
it('email-alert title falls back to "Unbenannte Ausschreibung" when empty', () => {
const raw = bagRecord('email-alert', { title: '' });
const normalized = service.normalize(raw);
expect(normalized.title).toBe('Unbenannte Ausschreibung');
});
});
});