Files

161 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Schema-Modelle + handgeschriebene Migration + lokale DB-Anwendung</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260722100000_add_tender_notifications/migration.sql</files>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: TenderMatchingService.matchDelta — delta-only, idempotent (RED→GREEN)</name>
<files>apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-matching.service.spec.ts</files>
<behavior>
- 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).
</behavior>
<action>
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<void>: 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.
</action>
<verify>
<automated>pnpm --filter api test -- tender-matching.service</automated>
</verify>
<done>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:{}.</done>
</task>
<task type="auto">
<name>Task 3: pollDueSources sammelt neue Tender-IDs + ruft matchDelta; Provider-Registrierung</name>
<files>apps/api/src/tenders/tender-ingestion.service.ts, apps/api/src/tenders/tenders.module.ts, apps/api/src/tenders/tender-ingestion.service.spec.ts</files>
<action>
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.
</action>
<verify>
<automated>pnpm --filter api test -- tender-ingestion.service && grep -q "TenderMatchingService" apps/api/src/tenders/tenders.module.ts</automated>
</verify>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
- `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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
Create `.planning/phases/12-tender-notifications/12-01-SUMMARY.md` when done
</output>