14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12-tender-notifications | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 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.
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.
<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> |
<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>