---
phase: 12-tender-notifications
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql
- apps/api/src/tenders/tender-matching.service.ts
- apps/api/src/tenders/tender-matching.service.spec.ts
- apps/api/src/tenders/tender-ingestion.service.ts
- apps/api/src/tenders/tenders.module.ts
autonomous: true
requirements: [NOTIFY-03]
must_haves:
truths:
- "Ein neu-in-diesem-Poll-Tick eingelesener Tender, der auf ein aktives Suchprofil passt, erzeugt genau eine TenderMatch-Zeile mit notifiedAt=NULL"
- "Das Anlegen eines Suchprofils bei ~2188 Bestands-Tendern erzeugt 0 un-benachrichtigte Matches (delta-only, D-07)"
- "Ein erneutes Matchen desselben (tender × savedSearch)-Paares lässt ein bereits gesetztes notifiedAt unangetastet (idempotenter Upsert, D-06)"
artifacts:
- "apps/api/prisma/schema.prisma (TenderMatch, TenderNotificationPref, TenderSavedSearch.instantAlert)"
- "apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql (auf lokale Dev-DB angewendet)"
- "apps/api/src/tenders/tender-matching.service.ts (matchDelta)"
- "apps/api/src/tenders/tender-matching.service.spec.ts"
key_links:
- "pollDueSources sammelt die IDs genuin NEUER Tender-Zeilen und ruft am Tick-Ende matchDelta(newTenderIds) auf"
- "matchDelta nutzt buildTenderWhere(profile.filters) AND id IN newTenderIds — keine neue Filterlogik"
- "TenderMatch.@@unique([tenderId, savedSearchId]) ist das Upsert-Target und garantiert ein Match je Paar"
---
Schema-Fundament + delta-only Matching-Engine für Phase 12. Legt die drei Datenmodell-Änderungen an (TenderMatch als „getroffen"-Datensatz mit einem einzigen notifiedAt-Gate, TenderNotificationPref als per-user Digest-Intervall, TenderSavedSearch.instantAlert als per-profile Flag) und verdrahtet einen neuen TenderMatchingService in den bestehenden Ingestion-Poll-Tick, sodass ausschließlich die in DIESEM Tick neu eingelesenen Tender gegen die aktiven Suchprofile ausgewertet werden.
Purpose: NOTIFY-03 Kern-Invariante. Das explizite matched-vs-notified-Datenmodell (D-06) und die strukturelle Rückstau-Unterdrückung durch delta-only Matching (D-07) sind das Fundament, auf dem Digest (12-02) und Instant (12-03) aufsetzen. Ohne diese Zeilen gibt es nichts zu benachrichtigen.
Output: Migrierte lokale Dev-DB, TenderMatchingService.matchDelta, erweiterter pollDueSources, registrierte Provider.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/phases/12-tender-notifications/12-CONTEXT.md
@.planning/phases/12-tender-notifications/12-RESEARCH.md
@apps/api/prisma/schema.prisma
@apps/api/src/tenders/tender-ingestion.service.ts
@apps/api/src/tenders/tender-query.builder.ts
@apps/api/src/tenders/tender-saved-search.service.ts
@apps/api/src/tenders/tenders.module.ts
@apps/api/prisma/migrations/20260721170000_add_tender_saved_search/migration.sql
Task 1: Schema-Modelle + handgeschriebene Migration + lokale DB-Anwendung
apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql
Erweitere schema.prisma um drei Änderungen nach dem exakten Phase-11-Scoping-Muster (userId-Scoping im Service, KEIN forTenant()/RLS; tenantId denormalisiert mitgeführt für spätere Isolation + SMTP-Auflösung), wie in RESEARCH.md Pattern A spezifiziert:
(a) Neues Model TenderMatch — der „getroffen"-Datensatz (D-06). Felder: id (uuid pk), tenderId, savedSearchId, userId (denormalisiert), tenantId (denormalisiert), matchedAt (DateTime default now), notifiedAt (DateTime? — NULL ist das Eligibility-Gate), notifiedChannel (String? — nur Audit 'digest'/'instant', NICHT Teil der Invariante). Relationen: tender (FK Tender, onDelete Cascade), savedSearch (FK TenderSavedSearch, onDelete Cascade). Constraints: @@unique([tenderId, savedSearchId]) als Upsert-Target, @@index([userId]), @@index([notifiedAt]). EIN einziges notifiedAt-Feld verwenden — NICHT zwei getrennte Spalten (RESEARCH.md Alternatives Considered: zwei Spalten verkomplizieren die Invariante und suggerieren fälschlich Doppelversand).
(b) Neues Model TenderNotificationPref — per-user Digest-Intervall (D-03): id (uuid pk), userId (String @unique — ein Row je Nutzer), tenantId, digestInterval (String @default("daily") — Werte 'daily'|'weekly'|'off' gemäß D-01), createdAt, updatedAt, @@index([userId]).
(c) Erweitere bestehendes Model TenderSavedSearch (D-04): Feld instantAlert Boolean @default(false) — Sofort-Alert pro Profil, Default AUS; plus Gegenrelation matches TenderMatch[].
(d) Erweitere Model Tender um die Gegenrelation matches TenderMatch[] (analog zur bestehenden triage TenderTriage[]-Relation).
Schreibe die Migration von Hand als apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql — Muster exakt wie 20260721170000_add_tender_saved_search/migration.sql (CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN für instantAlert mit DEFAULT false, CREATE UNIQUE INDEX / CREATE INDEX IF NOT EXISTS, FK-Constraints mit ON DELETE CASCADE). Kommentiere die matched-vs-notified-Invariante und das userId-Scoping inline.
Wende die Migration auf die LOKALE Dev-DB an (kein Docker-Deploy Testserver) via `docker compose exec -T db psql -U tessera -d tessera_dev -f -` mit dem Migrations-SQL, oder gleichwertig `pnpm --filter api prisma migrate deploy`. Danach `pnpm --filter api prisma generate`, damit der Prisma-Client die neuen Modelle kennt.
cd apps/api && npx prisma validate && npx prisma generate && docker compose -f ../../docker-compose.yml exec -T db psql -U tessera -d tessera_dev -c "SELECT to_regclass('public.\"TenderMatch\"'), to_regclass('public.\"TenderNotificationPref\"');" | grep -q TenderMatch
schema.prisma enthält TenderMatch (mit @@unique([tenderId,savedSearchId]) und EINEM notifiedAt), TenderNotificationPref, TenderSavedSearch.instantAlert; die Tabellen existieren in der lokalen Dev-DB; prisma generate läuft fehlerfrei.
Task 2: TenderMatchingService.matchDelta — delta-only, idempotent (RED→GREEN)
apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts
- Test 1 (delta-only, D-07): Bei 3 aktiven Profilen und einer newTenderIds-Liste von 2 IDs wird buildTenderWhere je Profil UND mit id IN newTenderIds aufgerufen; niemals eine ungefilterte Query über die volle Tender-Tabelle. Simulierte 2188 Bestands-Tender, die NICHT in newTenderIds sind, erzeugen 0 Matches.
- Test 2 (Match-Erzeugung): Ein Tender, der laut buildTenderWhere zu Profil P passt, führt zu genau einem tenderMatch.upsert mit create-Daten { tenderId, savedSearchId: P.id, userId: P.userId, tenantId: P.tenantId } und notifiedAt undefined (bleibt NULL).
- Test 3 (Idempotenz, D-06): Der Upsert verwendet update:{} — ein erneuter matchDelta-Lauf über dasselbe Paar überschreibt ein bereits gesetztes notifiedAt NICHT.
- Test 4 (leeres Delta): matchDelta([]) macht nichts (kein DB-Zugriff, kein Fehler).
Schreibe ZUERST tender-matching.service.spec.ts (RED) gemäß behavior-Block, dann implementiere tender-matching.service.ts (GREEN). Muster für Service-Struktur + Prisma-Mock aus tender-triage.service.spec.ts / tender-ingestion.service.spec.ts übernehmen.
TenderMatchingService (@Injectable) mit constructor(prisma: PrismaService). Öffentliche Methode matchDelta(newTenderIds: string[]): Promise: bei leerem Array sofort return. Lade alle aktiven Suchprofile via prisma.tenderSavedSearch.findMany() (alle Profile aller Nutzer — Tender-Katalog ist global D-03). Für jedes Profil: baue where = buildTenderWhere(profile.filters as unknown as TenderQueryDto) aus tender-query.builder.ts (Wiederverwendung der getesteten Phase-11-Filterlogik — Don't Hand-Roll), kombiniere mit { id: { in: newTenderIds } } (delta-only Grenze — das ist die strukturelle Rückstau-Unterdrückung D-07). Selektiere passende Tender-IDs via prisma.tender.findMany({ where: { AND: [where, { id: { in: newTenderIds } }] }, select: { id: true } }). Für jede Treffer-ID: prisma.tenderMatch.upsert auf where { tenderId_savedSearchId: { tenderId, savedSearchId: profile.id } }, create { tenderId, savedSearchId, userId: profile.userId, tenantId: profile.tenantId } (notifiedAt bleibt NULL), update {} (idempotent — bewahrt notifiedAt bei Re-Match, D-06).
WICHTIG: In dieser Wave erzeugt matchDelta NUR Match-Zeilen — noch KEIN Instant-Versand (das kommt in 12-03, das diese Datei erweitert). Kein TenderMailService-Import hier.
Beachte: filters ist Json und muss als TenderQueryDto gecastet werden; estimatedValue/deadlineAt-Nullhandling ist bereits in buildTenderWhere gelöst — nicht duplizieren.
pnpm --filter api test -- tender-matching.service
tender-matching.service.spec.ts deckt delta-only, Match-Erzeugung, Idempotenz und leeres Delta ab und ist grün; matchDelta ruft buildTenderWhere je Profil auf und upsertet auf @@unique([tenderId,savedSearchId]) mit update:{}.
Task 3: pollDueSources sammelt neue Tender-IDs + ruft matchDelta; Provider-Registrierung
apps/api/src/tenders/tender-ingestion.service.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-ingestion.service.spec.ts
Erweitere TenderIngestionService.pollDueSources (RESEARCH.md Pattern C / Code Examples): Führe innerhalb der Tender-Verarbeitungsschleife eine lokale Sammelliste newTenderIds ein. VOR dem bestehenden tender.upsert je Record einen indexierten Vorab-Check ausführen: prisma.tender.findUnique({ where: { dedupKey: tender.dedupKey }, select: { id: true } }); den bestehenden upsert unverändert lassen und dessen Rückgabe-id verwenden; wenn der Vorab-Check null lieferte (genuin NEUE Zeile), saved.id an newTenderIds pushen. Der Vorab-findUnique ist indexiert (dedupKey @unique) und bei Dutzenden Records/Tag vernachlässigbar. Am Tick-Ende — nach der while-Schleife und VOR/parallel zu pruneExpiredTenders, aber innerhalb des try-Blocks — falls newTenderIds.length: await this.matching.matchDelta(newTenderIds). Der bestehende catch-and-log-Rahmen bleibt: ein Matching-Fehler darf den Tick nicht crashen.
Injiziere TenderMatchingService in den TenderIngestionService-Konstruktor. Erweitere den bestehenden Spec tender-ingestion.service.spec.ts um einen Test: nach einem Tick mit 2 neuen + 1 bestehenden Record wird matching.matchDelta genau mit den 2 neuen IDs aufgerufen (bestehende Record-ID nicht enthalten) — delta-only am Auslösepunkt.
Registriere TenderMatchingService als Provider in tenders.module.ts (providers-Array, neben den bestehenden). Aktualisiere den Modul-Doc-Kommentar um den Plan-12-01-Beitrag.
pnpm --filter api test -- tender-ingestion.service && grep -q "TenderMatchingService" apps/api/src/tenders/tenders.module.ts
pollDueSources sammelt genuin neue Tender-IDs und ruft matchDelta nur mit diesen auf; TenderMatchingService ist im Modul registriert und in TenderIngestionService injiziert; der erweiterte Ingestion-Spec ist grün.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Poll-Tick (Server-intern) → DB | Vom Upstream normalisierte Tender-Daten kreuzen in TenderMatch-Zeilen; kein direkter User-Input, aber Profil-`filters` (user-erstellt) steuern die where-Query |
| Suchprofil-`filters` (user-erstellt) → Prisma-where | Das JSON eines Nutzers wird zu einer DB-Query kompiliert |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-12-01 | Tampering | matchDelta buildTenderWhere(filters) | medium | mitigate | filters wird ausschließlich über die getestete buildTenderWhere-Whitelist zu parametrisierten Prisma-Filtern; kein Raw-SQL, keine String-Konkatenation (Phase-11-T-11-01-Muster) |
| T-12-02 | Denial of Service | matchDelta über volle Tabelle | high | mitigate | delta-only Grenze `id IN newTenderIds` verhindert Full-Table-Scan/Rückstau-Flut (D-07); Bestands-Tender werden nie an matchDelta übergeben |
| T-12-03 | Information Disclosure | TenderMatch userId/tenantId Denormalisierung | medium | mitigate | userId/tenantId werden aus dem Profil-Datensatz (server-seitig) kopiert, nie aus Request-Input; Reads erfolgen erst in 12-02/12-04 strikt where:{userId} |
| T-12-04 | Elevation of Privilege | Idempotenter Upsert vs. Re-Notify | high | mitigate | update:{} bewahrt notifiedAt — ein Re-Match kann ein bereits benachrichtigtes Paar strukturell nicht zurücksetzen (D-06) |
| T-12-SC | Tampering | npm/pip/cargo installs | high | accept | Diese Phase installiert KEINE Pakete (RESEARCH Package Legitimacy Audit: keine Neuinstallation) — kein Slopcheck nötig |
- `pnpm --filter api test -- tender-matching.service` grün (delta-only, Idempotenz, Match-Erzeugung)
- `pnpm --filter api test -- tender-ingestion.service` grün (matchDelta nur mit neuen IDs)
- `docker compose exec -T db psql`-Check: TenderMatch + TenderNotificationPref existieren in der Dev-DB
- `npx prisma validate` fehlerfrei
- Ein neuer, passender Tender erzeugt genau eine TenderMatch-Zeile mit notifiedAt=NULL
- Ein neu angelegtes Profil erzeugt bei ~2188 Bestands-Tendern 0 un-benachrichtigte Matches (delta-only)
- Re-Match bewahrt ein gesetztes notifiedAt (idempotenter Upsert)
- Migration ist auf der lokalen Dev-DB angewendet, Prisma-Client kennt die neuen Modelle