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>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
---
|
||||
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)*
|
||||
Reference in New Issue
Block a user