161 lines
14 KiB
Markdown
161 lines
14 KiB
Markdown
---
|
||
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>
|