--- 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 Create `.planning/phases/12-tender-notifications/12-01-SUMMARY.md` when done