Files

14 KiB
Raw Permalink Blame History

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
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
true
NOTIFY-03
truths artifacts key_links
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)
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
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.

<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.

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.

<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>
- `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

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