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
@@ -55,6 +55,7 @@ export class TenderNormalizerService {
case 'ai-netserver':
case 'cosinex-dtvp':
case 'rss':
case 'email-alert':
return this.normalizeBag(raw);
case 'doe-opendata':
default:
@@ -118,13 +119,14 @@ export class TenderNormalizerService {
/**
* Generic-bag path (gap closure): NetServer/cosinex-DTVP scraper adapters
* (and, since Phase 14 Plan 02, the RssAdapter, INGEST-04) carry a flat
* (and, since Phase 14 Plan 02, the RssAdapter, INGEST-04; and since
* Phase 14 Plan 03, the EmailAlertAdapter, INGEST-05) carry a flat
* `{title, buyerName, procedureType, legalFramework, deadlineAt}` bag in
* `raw.ocdsPayload` — there is no eForms/OCDS structure to inspect.
* `legalFramework` is deliberately NOT mapped: NormalizedTenderFields has
* no target field for it. RSS records always have `buyerName`/
* `procedureType`/`deadlineAt` null (baseline title/link/guid mapping
* only, D-04/D-05 thin-fields deferral).
* no target field for it. RSS/email-alert records always have
* `buyerName`/`procedureType`/`deadlineAt` null (baseline title/link/guid
* mapping only, D-04/D-05 thin-fields deferral).
*/
private normalizeBag(raw: RawTenderRecord): NormalizedTenderFields {
const bag = getBagPayload(raw.ocdsPayload);