docs(12): add VALIDATION.md, resolve RESEARCH open questions

This commit is contained in:
2026-07-22 08:58:35 +02:00
parent 09993b001c
commit 37a4042e53
2 changed files with 100 additions and 1 deletions
@@ -420,20 +420,23 @@ for (const pref of dueUsers) {
| A4 | Fehlende Mandanten-SMTP → skip+log, Match bleibt `notifiedAt=null` (Retry) | Pitfall 6 | Niedrig — akzeptables MVP-Verhalten; Alternative wäre ein „dead-letter"-Flag, für MVP unnötig. | | A4 | Fehlende Mandanten-SMTP → skip+log, Match bleibt `notifiedAt=null` (Retry) | Pitfall 6 | Niedrig — akzeptables MVP-Verhalten; Alternative wäre ein „dead-letter"-Flag, für MVP unnötig. |
| A5 | Matching läuft am Ende von `pollDueSources` (kein separater Match-Scheduler) | Pattern C | Niedrig — CONTEXT nennt beides als Discretion; der Poll-Tick ist der natürliche Delta-Auslöser. | | A5 | Matching läuft am Ende von `pollDueSources` (kein separater Match-Scheduler) | Pattern C | Niedrig — CONTEXT nennt beides als Discretion; der Poll-Tick ist der natürliche Delta-Auslöser. |
## Open Questions ## Open Questions (RESOLVED)
1. **Re-Benachrichtigung bei Änderung (`contentHash`)?** 1. **Re-Benachrichtigung bei Änderung (`contentHash`)?**
- Was wir wissen: Phase 10 erkennt Änderungen via `contentHash` (SCHEMA-02), aktualisiert aber `pollDueSources` sammelt aktuell keine geänderten IDs. - Was wir wissen: Phase 10 erkennt Änderungen via `contentHash` (SCHEMA-02), aktualisiert aber `pollDueSources` sammelt aktuell keine geänderten IDs.
- Was unklar ist: Soll eine Fristverlängerung/Aufhebung eine „Notice aktualisiert"-Mail auslösen? - Was unklar ist: Soll eine Fristverlängerung/Aufhebung eine „Notice aktualisiert"-Mail auslösen?
- Empfehlung: Für MVP **nein** — nur genuin neue Tender benachrichtigen (A2). Beim Discuss/Planning bestätigen. - Empfehlung: Für MVP **nein** — nur genuin neue Tender benachrichtigen (A2). Beim Discuss/Planning bestätigen.
- **RESOLVED:** Nein — keine Re-Benachrichtigung bei `contentHash`-Änderung. Plan 12-01 sammelt in `pollDueSources` ausschließlich genuin NEUE Tender-IDs (delta-only) und übergibt nur diese an `matchDelta`; geänderte Bestands-Tender lösen keine Mail aus.
2. **Feste Digest-Uhrzeit pro Nutzer?** 2. **Feste Digest-Uhrzeit pro Nutzer?**
- Was wir wissen: D-01/D-03 fordern nur Intervall (daily/weekly/off), keine Uhrzeit. - Was wir wissen: D-01/D-03 fordern nur Intervall (daily/weekly/off), keine Uhrzeit.
- Empfehlung: Ein globaler 07:00-Lauf; `digestHour` nur nachrüsten, falls gefordert (A1). - Empfehlung: Ein globaler 07:00-Lauf; `digestHour` nur nachrüsten, falls gefordert (A1).
- **RESOLVED:** Ein einziger globaler Digest-Cron um 07:00 Europe/Berlin (Plan 12-02); kein per-Nutzer-`digestHour`. Nur das Intervall (daily/weekly/off) ist nutzerkonfigurierbar; `digestHour` bleibt bei Bedarf ein späterer Zusatz.
3. **Was passiert mit `notifiedAt=null`-Matches, wenn digestInterval='off' UND instantAlert=false?** 3. **Was passiert mit `notifiedAt=null`-Matches, wenn digestInterval='off' UND instantAlert=false?**
- Was wir wissen: Solche Matches würden sich unbegrenzt ansammeln (nie versendet). - Was wir wissen: Solche Matches würden sich unbegrenzt ansammeln (nie versendet).
- Empfehlung: Akzeptabel — sie sind einfach „stiller" State; die UI zeigt Treffer ohnehin. Optional: bei „off" den Match direkt als suppressed markieren. Für MVP: nichts tun (harmlos). Beim Planning entscheiden. - Empfehlung: Akzeptabel — sie sind einfach „stiller" State; die UI zeigt Treffer ohnehin. Optional: bei „off" den Match direkt als suppressed markieren. Für MVP: nichts tun (harmlos). Beim Planning entscheiden.
- **RESOLVED:** Nichts tun — die Matches bleiben stiller `notifiedAt=null`-State (kein Versand, keine Suppression-Markierung). Die Treffer sind weiterhin in der Phase-11-Trefferliste sichtbar; harmlos für MVP.
## Environment Availability ## Environment Availability
@@ -0,0 +1,96 @@
---
phase: 12
slug: tender-notifications
status: draft
nyquist_compliant: true
wave_0_complete: false
created: 2026-07-22
---
# Phase 12 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
> Extracted from 12-RESEARCH.md § Validation Architecture (Test framework, Requirements→Test map, Sampling rate, Wave-0 gaps).
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Vitest (API 3.x, Web 4.x — both existing, no install) |
| **Config file** | `apps/api/vitest.config.ts` (existing — not modified by this phase) |
| **Quick run command (API slice)** | `pnpm --filter api test -- <spec>` |
| **Quick run command (Web slice)** | `pnpm --filter web test -- <spec>` |
| **Full suite command** | `pnpm test` (turbo) bzw. `pnpm --filter api test && pnpm --filter web test` |
| **Estimated runtime** | ~10-15 seconds (scoped) |
No new packages: `nodemailer`, `@nestjs/schedule`, `cron`, `prisma` are already in `apps/api/package.json` (legitimized in Phase 7/10).
---
## Sampling Rate
- **After every task commit:** Run the affected slice spec (`pnpm --filter api test -- <spec>`).
- **After every plan wave:** Run `pnpm --filter api test && pnpm --filter web test`.
- **Before `/gsd-verify-work`:** `pnpm test` must be green.
- **Max feedback latency:** ~15 seconds (scoped run).
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 12-01-01 | 01 | 1 | NOTIFY-03 | T-12-01/03 | Schema TenderMatch (@@unique[tenderId,savedSearchId], EIN notifiedAt) + NotificationPref + instantAlert; Migration lokal angewendet | integration | `cd apps/api && npx prisma validate && npx prisma generate` (+ psql-Tabellencheck, manual) | ⚠️ migration | ⬜ pending |
| 12-01-02 | 01 | 1 | NOTIFY-03 | T-12-02/04 | matchDelta: delta-only (id IN newTenderIds), Match-Erzeugung, idempotenter Upsert (update:{} bewahrt notifiedAt) | unit | `pnpm --filter api test -- tender-matching.service` | ❌ W0 | ⬜ pending |
| 12-01-03 | 01 | 1 | NOTIFY-03 | T-12-02 | pollDueSources sammelt genuin neue Tender-IDs, ruft matchDelta nur mit diesen; Provider registriert | unit | `pnpm --filter api test -- tender-ingestion.service` | ⚠️ extend | ⬜ pending |
| 12-02-01 | 02 | 2 | NOTIFY-04 | T-12-06/08 | TenderMailService: getDecryptedSmtpConfig je Send, frischer Transport + close(), Skip bei fehlender Config, kein globaler Mailer | unit (mock nodemailer) | `pnpm --filter api test -- tender-mail.service` | ❌ W0 | ⬜ pending |
| 12-02-02 | 02 | 2 | NOTIFY-01/03 | T-12-05/07/09 | Digest-Cron: Fälligkeit daily/weekly/off + Default-daily, Multi-Tenant findMany, eine sektionierte Mail/Nutzer, notifiedAt-Stempel nur nach Erfolg, robust bei SMTP-Fehler | unit | `pnpm --filter api test -- tender-digest.scheduler` | ❌ W0 | ⬜ pending |
| 12-02-03 | 02 | 2 | NOTIFY-01/04 | — | SettingsModule-Import + Provider (TenderMailService, TenderDigestScheduler); DI-Graph auflösbar | typecheck | `cd apps/api && npx tsc --noEmit -p tsconfig.json` | n/a | ⬜ pending |
| 12-03-01 | 03 | 3 | NOTIFY-02/03 | T-12-11/12/13 | Instant-Dispatch: nur instantAlert=true, Bündelung pro Profil/Tick, notifiedAt='instant' nur nach Erfolg, catch-and-log | unit | `pnpm --filter api test -- tender-matching.service` | ⚠️ extend | ⬜ pending |
| 12-03-02 | 03 | 3 | NOTIFY-03 | T-12-10 | Instant+Digest = genau EINE Mail (kein Doppelversand über beide Kanäle) | integration | `pnpm --filter api test -- tender-notifications.integration` | ❌ W0 | ⬜ pending |
| 12-04-01 | 04 | 3 | NOTIFY-01/02 | T-12-14/15/16 | Pref-Service (Default daily, per-user Upsert) + @IsIn-DTO + GET/PUT-Routen VOR :id + instantAlert-Durchreichung; userId aus Auth-Context | integration | `pnpm --filter api test -- tender-notification-pref.service` (+ `npx tsc --noEmit`) | ❌ W0 | ⬜ pending |
| 12-04-02 | 04 | 3 | NOTIFY-01/02 | — | API-Client: fetch/saveNotificationPref + instantAlert in SavedSearch-Typ/Payloads | typecheck | `cd apps/web && npx tsc --noEmit` (+ grep notification-pref/instantAlert) | n/a | ⬜ pending |
| 12-04-03 | 04 | 3 | NOTIFY-01/02 | T-12-16 | Settings-Digest-Intervall-Auswahl (lädt+speichert); SavedSearchBar Sofort-Alert-Toggle pro Profil | component | `pnpm --filter web test -- SavedSearchBar` (+ `npx tsc --noEmit`) | ⚠️ extend | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `apps/api/src/tenders/tender-matching.service.spec.ts` — matchDelta: delta-only (0 Matches bei 2188 Bestand), Match-Erzeugung, Idempotenz (update:{} bewahrt notifiedAt), leeres Delta (Plan 12-01 Task 2, RED first); in Plan 12-03 Task 1 um Instant-Dispatch erweitert (RED first)
- [ ] `apps/api/src/tenders/tender-mail.service.spec.ts` — getDecryptedSmtpConfig je Send, frischer Transport + close() im finally, Skip bei fehlender Config ohne Throw, sektionierter Digest-Body (Plan 12-02 Task 1, RED first) — `nodemailer` mocken wie im DKV-Vorbild
- [ ] `apps/api/src/tenders/tender-digest.scheduler.spec.ts` — Fälligkeit daily/weekly/off + Default-daily, Multi-Tenant findMany (kein findFirst), eine Mail/Nutzer, kein Doppelversand, Robustheit (Plan 12-02 Task 2, RED first)
- [ ] `apps/api/src/tenders/tender-notifications.integration.spec.ts` — instant-on ⇒ 1 Instant/0 Digest; instant-off ⇒ 0 Instant/1 Digest — genau eine Mail über beide Kanäle (Plan 12-03 Task 2)
- [ ] `apps/api/src/tenders/tender-notification-pref.service.spec.ts` — per-user CRUD, Default 'daily', Upsert auf @@unique userId, @IsIn-Validierung (Plan 12-04 Task 1, RED first)
- [ ] (extend) `apps/api/src/tenders/tender-ingestion.service.spec.ts` — matchDelta wird nur mit den genuin neuen Tender-IDs des Ticks aufgerufen (Plan 12-01 Task 3)
- [ ] (extend) `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx` — Sofort-Alert-Toggle rendert Zustand + ruft updateSavedSearch({ instantAlert }) (Plan 12-04 Task 3)
Vitest-Framework existiert bereits (api + web) — keine Framework-Installation nötig.
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Migration `20260722100000_add_tender_notifications` auf DB angewendet | NOTIFY-03 | DB hat keinen Host-Port; lokale Anwendung via `docker compose exec -T db psql -U tessera -d tessera_dev` (MEMORY-Hinweis); kein Docker-Deploy auf dem Testserver durch Claude | `SELECT to_regclass('public."TenderMatch"'), to_regclass('public."TenderNotificationPref"')` ≠ NULL; `\d "TenderSavedSearch"` zeigt `instantAlert`; `prisma migrate status` grün |
| Tatsächlicher SMTP-Versand (Transportaufbau + Zustellung) | NOTIFY-04 | Realer Mailversand ist mit gemocktem nodemailer nicht beobachtbar; braucht lokales Relay | Digest-/Instant-Testlauf gegen Mailhog `localhost:1025`; Mail erscheint dort, Body ist nach Profil gegliedert (Digest) bzw. eine Sammelmail (Instant) |
| Zwei-Mandanten-Digest-Isolation | NOTIFY-01/03 | Echte Cross-Tenant-Isolation braucht zwei reale Nutzer in zwei Mandanten mit eigener SmtpConfig — im Unit-Test nur gemockt | Zwei Nutzer/zwei Mandanten daily → jeder erhält genau seine eigenen Treffer über seine eigene SMTP; kein Nutzer sieht Treffer des anderen |
| Ein-Mail-Garantie über beide Kanäle (End-to-End) | NOTIFY-03 | Automatisiert durch 12-03-02 (integration), aber finale Bestätigung am echten Pipeline-Lauf | Profil instant=on + user daily, ein neuer Match → insgesamt genau EINE Mail (die Instant-Mail); der folgende Digest sendet für dieses Paar nichts |
| Backfill-Nicht-Flut bei Profil-Anlage | NOTIFY-03 (D-07) | Beobachtung am realen Bestand (~2188 Tender) | Neues Profil bei vorhandenem Bestand anlegen → 0 un-benachrichtigte Matches, keine Mail-Flut; Treffer bleiben in der Trefferliste sichtbar |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or a documented Wave 0 / manual-gate dependency
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references (spec scaffolds; RED-first for pure-logic units)
- [x] No watch-mode flags (all use `vitest run` via `pnpm --filter … test`)
- [x] Feedback latency < 15s (scoped runs)
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** draft — pending execution (wave_0_complete: false)