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>
21 KiB
phase, verified, status, score, behavior_unverified, overrides_applied, human_verification
| phase | verified | status | score | behavior_unverified | overrides_applied | human_verification | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 17-eigene-ausschreibungs-quellen-je-nutzer | 2026-08-12T15:10:00Z | human_needed | 7/7 must-haves verified | 0 | 0 |
|
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 |
| 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.tsxline 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
whereclauses (matchesWherewith recursiveOR/ANDhandling) rather than ignoring the condition and always "succeeding" — the test file itself documents why this matters ("a double that ignoredwhereand always 'succeeded' would only fake the protection, not test it"). - One minor, non-blocking observation:
createForUser's 20-feed cap check (count()thencreate()) 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 conditionaldeleteMany), 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
-
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 underlyinguserId-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. -
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. -
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)