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

21 KiB
Raw Blame History

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
test expected why_human
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. Jeder Nutzer sieht und speichert ausschliesslich sein eigenes Postfach; kein Nutzer sieht das Formular des anderen vorausgefuellt. 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 expected why_human
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. Alle drei Abschnitte funktionieren durchgehend im echten Browser, kein Verweis auf die Administrationsseite fuer einen normalen Nutzer. Kein Browser-Tool in der Ausfuehrungssitzung verfuegbar. Als WINDOWS.md #8 (unrun-verify, status open) erfasst.
test expected why_human
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'. Rollentrennung greift im echten Browser identisch zu den Komponententests; Navigation ueber das Zahnrad funktioniert im echten Next.js-Router. 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
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.
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)