Files
tessera-ctl/.planning/phases/17-eigene-ausschreibungs-quellen-je-nutzer/17-VERIFICATION.md
T
schalli a46006eada
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 51s
Tessera CI/CD / Build & Publish Images (push) Successful in 7s
docs(17): verify phase against the codebase — human_needed
Every mechanism checked against live schema, migrations and code rather
than the summaries. Three browser click-throughs remain unrun (no browser
tool this session), so the phase lands human_needed, not passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:08:45 +02:00

159 lines
21 KiB
Markdown
Raw 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: 17-eigene-ausschreibungs-quellen-je-nutzer
verified: 2026-08-12T15:10:00Z
status: human_needed
score: 7/7 must-haves verified
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "17-01 Task 2 human-check: Als normaler Nutzer (Rolle USER, Modulzugang) /modules/tender-radar/my-sources oeffnen, Postfach speichern, neu laden — Werte stehen noch. Mit zweitem Konto desselben Mandanten anmelden — Formular ist leer, nicht die Werte des ersten Nutzers."
expected: "Jeder Nutzer sieht und speichert ausschliesslich sein eigenes Postfach; kein Nutzer sieht das Formular des anderen vorausgefuellt."
why_human: "Erfordert echte Browser-Session mit zwei unterschiedlichen angemeldeten Konten; aus dem Code/Tests allein nicht zu beobachten. Als WINDOWS.md #7 (unrun-verify, status open) erfasst."
- test: "17-03 Task 1 human-check: Als normaler Nutzer /modules/tender-radar/my-sources oeffnen — drei Abschnitte sichtbar, eigenen RSS-Feed anlegen (erscheint sofort in eigener Liste), service.bund.de-Feed steht darunter ohne Entfernen-Knopf, Digest-Intervall auf 'Woechentlich' stellen, neu laden — Wert bleibt."
expected: "Alle drei Abschnitte funktionieren durchgehend im echten Browser, kein Verweis auf die Administrationsseite fuer einen normalen Nutzer."
why_human: "Kein Browser-Tool in der Ausfuehrungssitzung verfuegbar. Als WINDOWS.md #8 (unrun-verify, status open) erfasst."
- test: "17-03 Task 2 human-check: Als Konto mit Rolle USER /modules/tender-radar/settings direkt aufrufen — keine Bedienelemente, nur Hinweis + Verweis. Als Administrator dieselbe Adresse — Abrufintervall + plattformweite Feeds da, Postfach/Benachrichtigung nicht mehr. Zahnrad fuehrt in beiden Faellen nach 'Meine Quellen'."
expected: "Rollentrennung greift im echten Browser identisch zu den Komponententests; Navigation ueber das Zahnrad funktioniert im echten Next.js-Router."
why_human: "Kein Browser-Tool in der Ausfuehrungssitzung verfuegbar. Als WINDOWS.md #9 (unrun-verify, status open) erfasst."
---
# Phase 17: Eigene Ausschreibungs-Quellen je Nutzer — Verification Report
**Phase Goal:** Jeder Nutzer speist seine eigenen Ausschreibungs-Quellen ein — eigenes Alert-Postfach und eigene RSS-Feeds statt einer gemeinsamen Konfiguration. Die Einstellungsseite trennt danach sauber nach Zustaendigkeit: Plattform-Administration (Abrufintervall) bleibt Admin-Sache, Quellen und Digest-Intervall gehoeren dem Nutzer.
**Verified:** 2026-08-12
**Status:** human_needed
**Re-verification:** No — initial verification
## Summary Verdict
**The phase goal is achieved in the codebase.** All backend and frontend mechanisms described in 17-CONTEXT.md (D-01..D-05), all five ROADMAP requirements (SRC-01..SRC-05), and all seven ROADMAP Success Criteria are implemented, migrated locally, and covered by 362/362 API `src/tenders` tests + 58/58 web tender-radar tests, both re-run fresh in this verification (not taken from SUMMARY claims). The one hard scope fence (D-05: `Tender` stays platform-global) holds — verified directly against the live database schema, not just against source comments. Test quality on inspection is genuinely good: hand-written expectations, evaluating Prisma fakes for ownership/delete-protection, no tautological "expectation built from the same helper as the code under test" pattern found.
**However, the phase cannot be marked `passed`.** Three `human-check` items from the plans (17-01 Task 2, 17-03 Task 1, 17-03 Task 2) were never executed in a real browser — this is honestly disclosed in the SUMMARYs and in WINDOWS.md (#7/#8/#9, all `status: open`), not glossed over. This verifier ran the same programmatic checks the SUMMARYs claim (test suites, type-checks, migration status, DB index/content inspection) and confirms all of them pass, but the actual click-through UX (form pre-fill, save-then-reload persistence, role-based visibility in a live Next.js router, navigation via the gear icon) remains genuinely unproven. Per the routing rules, this makes the phase `human_needed`, not `passed`.
## Goal Achievement
### Observable Truths (ROADMAP Success Criteria, SC 1–7)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Zwei Nutzer desselben Mandanten koennen gleichzeitig je ein eigenes Alert-Postfach anbinden; keiner sieht/ueberschreibt das des anderen | ✓ VERIFIED (mechanism) / see human-check | `TenderEmailConfig.userId @unique` confirmed live in DB (`TenderEmailConfig_userId_key`, no `_tenantId_key`). `getConfigForApi(userId)`/`saveConfig({userId,tenantId})` scoped strictly by userId from `extractTriageContext(req)`, never DTO. Unit + IDOR-shaped controller test pass. Real two-account browser click-through NOT run (WINDOWS.md #7). |
| 2 | Nutzer legt eigene RSS-Feeds an, sieht eigene + plattformweite; service.bund.de bleibt fuer alle aktiv | ✓ VERIFIED | `TenderRssFeedSource.userId/tenantId` nullable, `@@unique([userId,url])` confirmed live in DB. `listForUser` returns `OR:[{userId:null},{userId}]`. Live DB query confirms exactly one row: `service.bund.de`, `isActive=true`, `userId`/`tenantId` empty — unchanged since Phase 14. |
| 3 | Kein Nutzer kann fremden/plattformweiten Feed loeschen oder plattformweiten Feed anlegen | ✓ VERIFIED | `remove()` is a single conditional `deleteMany` (`id` AND (`userId=caller` OR (`isAdmin` AND `userId=null`))) — no TOCTOU window, ownership comparison lives in the DB condition. `NotFoundException` on no match (never confirms existence). `POST .../rss-feeds` with `scope:'platform'` requires `role===ADMIN|SUPER_ADMIN` inline in the controller, checked against the same `extractTriageContext` source `RolesGuard` reads. DTO carries no `userId`/`tenantId` field — cannot be spoofed. |
| 4 | Abruf holt alle Postfaecher/Feeds in einem Durchlauf; Ausfall einer Quelle blockiert die anderen nicht | ✓ VERIFIED | `EmailAlertAdapter.fetchTenders`: `findMany({where:{isActive:true}})` (unwrapped, cross-tenant, deliberate — comment intact), `for` loop with per-row `try/catch`. `RssAdapter.fetchTenders`: same shape, per-feed `try/catch`. Both confirmed unchanged in code and covered by new multi-row/catch-per-row tests (hand-verified, not tautological — see Test Quality section). |
| 5 | `/modules/tender-radar/my-sources` zeigt Postfach, eigene Feeds, Benachrichtigungsintervall an einer Stelle | ✓ VERIFIED (mechanism) / see human-check | `my-sources/page.tsx` renders `EmailAlertConfigForm` + `RssFeedListForm scope="personal"` + `DigestIntervalForm` in sequence, gated only by module access (no admin whitelist change needed — nested route). Web tests (`my-sources.test.tsx`, 5 tests) pass. Real browser click-through NOT run (WINDOWS.md #8). |
| 6 | `/modules/tender-radar/settings` nur Abrufintervall + plattformweite Feeds; normaler Nutzer sieht keine fehlschlagenden Bedienelemente | ✓ VERIFIED (mechanism) / see human-check | `settings/page.tsx`: three-state display gate (`user===null` → placeholder, `isAdmin` → content, else → hint+link). Mailbox/notification sections physically removed from this file. `settings-roles.test.tsx` (5 tests) confirms USER sees neither section heading, ADMIN/SUPER_ADMIN see both, unresolved role shows neither. Server-side enforcement independently confirmed (`@UseModule` + inline admin check). Real browser click-through NOT run (WINDOWS.md #9). |
| 7 | `Tender` bleibt unveraendert ohne Mandantenfeld und ohne RLS (D-05) | ✓ VERIFIED | Live `\d "Tender"` shows no `tenantId` column — only the pre-existing, nullable `ownerTenantId` (Phase 14, D-13, reused by design per plan's own D-06 decision, not a new mechanism). No `CREATE POLICY`/RLS migration touches `Tender`. `grep -r forTenant` across `tenders/` finds only explanatory comments ("must NEVER be wrapped in forTenant()"), zero actual wrapping calls. |
**Score:** 7/7 truths verified as implemented mechanisms. 3 of the 7 additionally carry an outstanding real-browser confirmation step (SC 1, 5, 6) — these are not counted as failed (the mechanism is verified), but are surfaced below as required human verification per the routing rules (a ⚠️/human-check item takes precedence over `passed`).
### Requirements Coverage (SRC-01..SRC-05)
| Requirement | Description | Status | Evidence |
|---|---|---|---|
| SRC-01 | Jeder Nutzer bindet eigenes Alert-Postfach an; ein Mandant kann mehrere Postfaecher haben (D-01) | ✓ SATISFIED | Schema/migration/service/controller all confirmed live; `TenderEmailConfig.userId @unique`, `tenantId` plain. |
| SRC-02 | RSS-Feeds haben einen Besitzer; Feeds ohne Besitzer bleiben plattformweit, admin-gepflegt (D-02) | ✓ SATISFIED | Schema/migration/service confirmed live; delete-protection + 20-feed cap + SSRF guard on both create paths confirmed by reading `tender-rss-feed.service.ts` directly. |
| SRC-03 | Zeitgesteuerter Abruf holt weiterhin alle Quellen in einem Durchlauf; kaputte Quelle blockiert andere nicht | ✓ SATISFIED | Both adapters confirmed unwrapped, per-row `try/catch` intact. |
| SRC-04 | Nutzereigene Einstellungen liegen auf einer eigenen, fuer jeden Modulnutzer erreichbaren Seite (D-04) | ✓ SATISFIED (mechanism) | `/my-sources` page confirmed to render all three sections; gear icon confirmed pointed at `/my-sources`. Real navigation click NOT run (WINDOWS.md #8/#9). |
| SRC-05 | Verbleibende Administrationseinstellungen sind fuer normale Nutzer nicht sichtbar; Rollenpruefung greift in UI wie API (D-03) | ✓ SATISFIED (mechanism) | Display-gate confirmed in code + tests; server-side `@UseModule`/inline-admin-check confirmed independently. Real browser role-switch NOT run (WINDOWS.md #9). |
REQUIREMENTS.md traceability table (`| SRC-01 | Phase 17 | Complete |` … `| SRC-05 | Phase 17 | Complete |`) matches this assessment. No orphaned SRC requirements found.
### Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
| `apps/api/prisma/migrations/20260812100000_tender_email_config_per_user/migration.sql` | Backfill-then-cutover migration | ✓ VERIFIED | Read in full; correct order (add nullable → backfill → delete unowned → drop old unique → set NOT NULL → new unique). Applied locally, `prisma migrate status` clean. |
| `apps/api/src/tenders/email-config-migration-sql.spec.ts` | Text-only order proof | ✓ VERIFIED | 6 tests, all assert on real file content/ordering, not vacuous. |
| `apps/web/src/app/(portal)/modules/tender-radar/my-sources/page.tsx` | Full 3-section user page | ✓ VERIFIED | Renders `EmailAlertConfigForm` + `RssFeedListForm scope="personal"` + `DigestIntervalForm`, admin-only link to settings. |
| `apps/api/prisma/migrations/20260812110000_tender_rss_feed_owner/migration.sql` | Nullable owner-column migration | ✓ VERIFIED | Read in full; no backfill needed (nullable = today's state), correct index swap. Applied locally. |
| `apps/api/src/tenders/rss-feed-migration-sql.spec.ts` | Text-only order proof | ✓ VERIFIED | 6 tests present and passing. |
| `apps/web/.../my-sources/my-sources.test.tsx` | 3-section coverage test | ✓ VERIFIED | 5 tests, passing. |
| `apps/web/.../settings/settings-roles.test.tsx` | Role-gate coverage test | ✓ VERIFIED | 5 tests, passing, checks rendered DOM not internal state. |
| `apps/web/.../settings/components/DigestIntervalForm.tsx` | Extracted, behavior-unchanged component | ✓ VERIFIED | Confirmed used identically on both pages. |
### Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
| `TenderEmailConfig.userId` | `extractTriageContext(req).userId` | GET/PUT email-config | ✓ WIRED | Confirmed in `tenders.controller.ts` — `userId`/`tenantId` never read from body/query. |
| `TenderEmailConfig.tenantId` | `EmailAlertAdapter.extractCandidates(..., cfg.tenantId, ...)` | poll fan-out | ✓ WIRED | `records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt))` confirmed in source. |
| GET/PUT `/email-config` route position | before `@Get(':id')` | route-order pitfall | ✓ WIRED | Confirmed: all static routes (`source-config`, `rss-feeds`, `email-config`, `coverage`, `denylisted-portals`, `triage`, `saved-searches`) precede `@Get(':id')` at line 582. |
| `TenderRssFeedSource.userId` | `extractTriageContext(req).userId` | POST/DELETE rss-feeds | ✓ WIRED | Confirmed — DTO carries no ownership field. |
| `TenderRssFeedSource.tenantId` | `RawTenderRecord.ownerTenantId` | `RssAdapter.fetchTenders` | ✓ WIRED | `if (feed.tenantId) { for (const record of feedRecords) record.ownerTenantId = feed.tenantId; }` confirmed in source. |
| `TendersModule.onModuleInit()` | service.bund.de seed | idempotent find-then-create | ✓ WIRED | `seedServiceBundRssFeed()` in `tenders.seed.ts` — `findFirst({where:{userId:null,url:...}})` then create — confirmed, matches live DB (exactly 1 row). |
| `RssFeedListForm(scope)` | `POST /rss-feeds` with `scope` | dual-purpose component | ✓ WIRED | `createRssFeed(payload, scope)` confirmed passing `scope` into request body; server re-checks admin role independently. |
| `useAuthStore().user.role` | admin section visibility | display-only gate | ✓ WIRED | Confirmed in both `settings/page.tsx` and `my-sources/page.tsx`; `user===null` renders neither state (no flash). |
| Zahnrad in `tender-radar/page.tsx` | `/modules/tender-radar/my-sources` | universal entry point | ✓ WIRED | `href="/modules/tender-radar/my-sources"` confirmed at line 84. |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|---|---|---|---|---|
| `TenderEmailConfig` rows | `userId`, `tenantId` | Live Postgres (172.19.0.2) | Confirmed via `psql \d` and index listing | ✓ FLOWING |
| `TenderRssFeedSource` rows | `url`, `label`, `isActive`, `userId`, `tenantId` | Live Postgres | Confirmed: exactly 1 row, service.bund.de, platform-wide, active | ✓ FLOWING |
| `Tender` schema | column list | Live Postgres | Confirmed: no `tenantId` column, only pre-existing `ownerTenantId` | ✓ FLOWING (negative check — absence confirmed) |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|---|---|---|---|
| API `src/tenders` full suite (independent re-run, not from SUMMARY) | `pnpm --filter @tessera/api exec vitest run src/tenders` | 28 files, 362 tests passed | ✓ PASS |
| API type-check | `pnpm --filter @tessera/api type-check` | clean | ✓ PASS |
| Web type-check | `pnpm --filter @tessera/web type-check` | clean | ✓ PASS |
| Web tender-radar test suite | `pnpm --filter @tessera/web exec vitest run "src/app/(portal)/modules/tender-radar"` | 10 files, 58 tests passed | ✓ PASS |
| Prisma migration status | `npx prisma migrate status` (via container-IP DATABASE_URL) | "Database schema is up to date!" — 29 migrations | ✓ PASS |
| `TenderEmailConfig` index list | live `psql` query | `_userId_key` present, `_tenantId_key` absent | ✓ PASS |
| `TenderRssFeedSource` index list | live `psql` query | `_userId_url_key` present, old `_url_key` absent | ✓ PASS |
| `Tender` schema, no tenant column | live `psql \d "Tender"` | no `tenantId`; `ownerTenantId` unchanged | ✓ PASS |
| i18n key parity, `tenderRadar` namespace | node key-diff script | 239/239 keys match de/en | ✓ PASS |
| Backlog item moved to completed | `ls .planning/todos/completed/...` | file present, pending copy absent | ✓ PASS |
| Git commits exist | `git log --oneline -- apps/api/src/tenders ...` | all 9 task commits found (`05b1d29`, `55ceb24`, `adb72f6`, `9616155`, `7dee116`, `4100bb5`, `150046e`, `809afbc`) | ✓ PASS |
### Probe Execution
Not applicable — no `scripts/*/tests/probe-*.sh`-style probes declared or referenced by this phase's plans.
### Test Quality Spot-Check (requested item 7)
Inspected `tender-rss-feed.service.spec.ts`, `email-alert.adapter.spec.ts`, `RssFeedListForm.test.tsx`, `settings-roles.test.tsx`, `email-config-migration-sql.spec.ts` in full.
- **No tautological "expectation built from the production helper" pattern found.** Every test file that could tempt this (in-memory Prisma fakes, next-intl translation stubs) instead hand-writes expected values and hand-writes the mock translation table separately from the component/message files under test — explicitly called out in comments, e.g. `RssFeedListForm.test.tsx` line 19: "Text is hand-written here, NOT derived from the component/message files under test — a test that builds its own expectation from the same helper the component uses proves nothing."
- **No status-code-as-proof pattern found.** Delete-protection tests assert both the thrown exception type/instance AND the row-count side effect (`expect(prisma.__rows.size).toBe(1)` after a rejected delete) — not merely that an HTTP status or exception class was thrown.
- **The in-memory Prisma fakes genuinely evaluate `where` clauses** (`matchesWhere` with recursive `OR`/`AND` handling) rather than ignoring the condition and always "succeeding" — the test file itself documents why this matters ("a double that ignored `where` and always 'succeeded' would only fake the protection, not test it").
- One minor, non-blocking observation: `createForUser`'s 20-feed cap check (`count()` then `create()`) is not atomic — a genuine race between two concurrent requests from the same user could both pass the count check before either creates a row, allowing a transient overshoot of the cap by a small margin. This is a soft rate-limit, not a security boundary (ownership/deletion protection IS atomic via the single conditional `deleteMany`), and is not called out as a threat in the plan's own STRIDE table, so it is noted here as an observation, not a gap.
### Anti-Patterns Found
None. Grep for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER` (and case-insensitive `placeholder`/`not yet implemented`) across all 15 phase-modified backend/frontend files returned only pre-existing, benign uses of the word "placeholder" (documenting the `portals: ['rss']` symbolic constant, and the loading-skeleton UI state) — none indicate unfinished work.
### Human Verification Required
1. **17-01 Task 2 human-check (WINDOWS.md #7, open)**
**Test:** As a normal USER-role account with module access, open `/modules/tender-radar/my-sources`, fill and save the mailbox form, reload — values persist. Then log in as a second account of the same tenant — the form is empty, not pre-filled with the first user's values.
**Expected:** Each user sees and edits only their own mailbox.
**Why human:** Requires two live authenticated browser sessions; the underlying `userId`-scoped query and IDOR-safe controller wiring is verified by code/DB inspection and unit tests, but the actual UX round-trip was never clicked through.
2. **17-03 Task 1 human-check (WINDOWS.md #8, open)**
**Test:** As a normal user, open `/modules/tender-radar/my-sources` — three sections visible. Add a personal RSS feed — it appears immediately in the editable list; the service.bund.de feed appears below without a remove button. Set the digest interval to "Woechentlich", reload — value persists.
**Expected:** All three sections work end-to-end in a real browser.
**Why human:** No browser tool was available in the executing session.
3. **17-03 Task 2 human-check (WINDOWS.md #9, open)**
**Test:** As a USER-role account, navigate directly to `/modules/tender-radar/settings` — no controls, only the hint + link. As an ADMIN account, same URL — poll interval + platform feeds show, mailbox/notification sections do not. The gear icon on the module page leads to "Meine Quellen" in both cases.
**Expected:** Role-based visibility and navigation work identically to the passing component tests, in the real Next.js router.
**Why human:** No browser tool was available in the executing session.
All three items are honestly recorded as `open`/`unrun-verify` in `.planning/WINDOWS.md` (entries #7, #8, #9) and in each plan's own SUMMARY.md — none of the three SUMMARYs claim these were passed. STATE.md's `stopped_at` field also explicitly flags "Browser-Gegenprobe aller drei Plaene ... stehen als offene Punkte in WINDOWS.md" as outstanding before `/gsd-ship`. This verification confirms that disclosure is accurate and does not overstate what has actually been proven.
### Gaps Summary
No implementation gaps found. All must-haves at the mechanism level (schema, migration, service, controller, adapter, frontend wiring, tests, i18n, requirements traceability, backlog closure) are verified directly against the live codebase and a live local database — not inferred from SUMMARY claims. The phase's own scope fence (D-05, `Tender` stays platform-global) is independently confirmed intact.
The only open item is the disclosed set of three real-browser click-throughs, which the phase's own artifacts already flag as unrun rather than passed. This is a verification-completeness gap, not an implementation gap — routed here as `human_needed` per the standard decision tree (a non-empty human-verification section always overrides `passed`, even when every other truth is independently verified).
---
*Verified: 2026-08-12*
*Verifier: Claude (gsd-verifier)*