docs(quick-260909-laa): Etappe 2 Bereich tenders abgeschlossen, Luecke behoben

This commit is contained in:
2026-09-09 16:12:49 +02:00
parent 8cbf4c12b8
commit 6464ccb821
3 changed files with 329 additions and 2 deletions
@@ -0,0 +1,173 @@
---
phase: quick-260909-laa
plan: 01
subsystem: database
tags: [prisma, postgresql, row-level-security, multi-tenancy, nestjs]
# Dependency graph
requires:
- phase: quick-260909-jts
provides: forTenant()/withTenantTransaction() pattern proven for background-job "beides" cases (groups)
provides:
- Five tender user-CRUD services (saved-search, triage, notification-pref, email-config, rss-feed) bound to forTenant()
- Per-hit halves of both tender background services (digest scheduler, matching service) bound to forTenant()
- rls-scratch-check.mjs tenders-area section measuring WINDOWS #19 boundary and the missing user dimension in the delivered policies
- docs/mandantentrennung-etappe2-fehlerrichtung.md "Bereich tenders" section (signal table + silent-failure form)
- docs/mandantentrennung-zugriffsklassifikation.md fully synced for all 23 tenders pairs
affects: [quick-260909-next-tenders-area-or-stage-3, settings-area-quick-task, stage-3-planning]
# Actuals (#2632)
actuals:
tokens: 39000
tasks: 3
commits: 3
tech-stack:
added: []
patterns:
- "forTenant() bound once per method / once per loop-hit-row, never shared across methods (groups/ldap convention)"
- "__makeBoundClient() two-client test proof (same in-memory Map, per-call logging wrapper) instead of an identity mock"
- "P2002 on a tenant-less unique key (userId, [userId,tenderId]) translated into a German ConflictException — NACHTRAG: bei Lieferung galt das nur fuer die beiden userId-Schluessel; setTriage() auf [userId,tenderId] fehlte und wurde vom Verifizierer gefunden, nachgereicht mit 8cbf4c1 samt zwei Tests, deren Rotwerden durch Rueckbau belegt ist"
key-files:
created: []
modified:
- apps/api/scripts/rls-scratch-check.mjs
- docs/mandantentrennung-etappe2-fehlerrichtung.md
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/api/src/tenders/tender-saved-search.service.ts
- apps/api/src/tenders/tender-triage.service.ts
- apps/api/src/tenders/tender-notification-pref.service.ts
- apps/api/src/tenders/tender-email-config.service.ts
- apps/api/src/tenders/tender-rss-feed.service.ts
- apps/api/src/tenders/tenders.controller.ts
- apps/api/src/tenders/tender-digest.scheduler.ts
- apps/api/src/tenders/tender-matching.service.ts
key-decisions:
- "tender-rss-feed.service.ts reclassified from muss-mandantengebunden to beides (same precedent as ldapConfig in 260909-ipc) — createForUser binds, listForUser/createPlatform/remove stay deliberately unbound (WINDOWS #19)"
- "No withTenantTransaction() introduced in this area — measured exactly one $transaction (array form, platform-global Tender table, tender-fingerprint-backfill.service.ts), outside any tenant binding"
- "tender-digest.scheduler.ts candidate query additionally selects the denormalized tenantId of the match row so the loop can bind; the tenant-switch edge case is named, not solved (Stage 3)"
patterns-established:
- "Silent-failure notification form documented separately from the visible-emptiness form in the critique doc, with the notifiedAt-stays-NULL retry guarantee as the one checkable signal"
requirements-completed: [WINDOWS-20, ETAPPE-2-TENDERS]
coverage:
- id: D1
description: "rls-scratch-check.mjs tenders-area section measures the three special cases (no user dimension in the policies, WINDOWS #19 platform-row invisibility, P2002 on a bound upsert to an invisible row) against the delivered migration"
verification:
- kind: other
ref: "node apps/api/scripts/rls-scratch-check.mjs — 32/32 checks passed"
status: pass
human_judgment: false
- id: D2
description: "Five user-CRUD services (saved-search, triage, notification-pref, email-config, rss-feed) bound to forTenant(); rss-feed's three intentionally-unbound paths stay unbound with WINDOWS #19 comments"
requirement: "WINDOWS-20"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-saved-search.service.spec.ts, tender-triage.service.spec.ts, tender-notification-pref.service.spec.ts, tender-email-config.service.spec.ts, tender-rss-feed.service.spec.ts, tenders.controller.spec.ts"
status: pass
human_judgment: false
- id: D3
description: "Per-hit halves of tender-digest.scheduler.ts and tender-matching.service.ts bound to forTenant(); cross-tenant candidate/profile queries stay unbound with a Stage-3-handoff comment"
requirement: "ETAPPE-2-TENDERS"
verification:
- kind: unit
ref: "apps/api/src/tenders/tender-digest.scheduler.spec.ts, tender-matching.service.spec.ts, tender-notifications.integration.spec.ts"
status: pass
human_judgment: false
- id: D4
description: "docs/mandantentrennung-etappe2-fehlerrichtung.md gets a 'Bereich tenders' section with the observed measurement, a per-path signal table, and a dedicated silent-failure-form subsection; docs/mandantentrennung-zugriffsklassifikation.md stays in sync with rls-access-inventory.spec.ts for all 23 tenders pairs"
verification:
- kind: unit
ref: "apps/api/src/prisma/rls-access-inventory.spec.ts"
status: pass
human_judgment: false
duration: 45min
completed: 2026-09-09
status: complete
---
# Quick Task 260909-laa: Etappe 2 Bereich tenders Summary
**Five tender user-CRUD services and the per-hit halves of two background notification services bound to `forTenant()`, with the platform-wide catalog, two fan-out adapters, and three WINDOWS-#19-affected RSS paths deliberately left unbound and documented as such.**
## Performance
- **Duration:** ~45 min
- **Started:** 2026-09-09T13:20:00Z (approx.)
- **Completed:** 2026-09-09T14:03:00Z
- **Tasks:** 3
- **Files modified:** 20 (13 source/spec files + 2 docs, across three commits)
## Accomplishments
- Measured the three special cases of this area against the delivered `_rls_remaining_tenant_tables` migration (32/32 scratch checks): the five policies have no user dimension, a platform-wide RSS row is invisible under every tenant context, and a bound `upsert` onto an invisible cross-tenant row fails on the unique constraint, not the policy.
- Bound the five per-user CRUD services (`tender-saved-search`, `tender-triage`, `tender-notification-pref`, `tender-email-config`, `tender-rss-feed`) to `forTenant()`; `tender-rss-feed.service.ts` binds only `createForUser` and leaves `listForUser`/`createPlatform`/`remove` deliberately unbound with a WINDOWS #19 code comment, because they touch the nullable-tenant platform-wide row.
- Bound the per-hit halves of both background notification services (`tender-digest.scheduler.ts`, `tender-matching.service.ts`) to the tenant of the candidate/profile row currently being processed; their cross-tenant candidate/profile queries stay unbound and are commented as a Stage 3 handoff.
- Wrote a `## Bereich tenders` section into the critique document with the observed scratch-tool output, a per-path signal table, and a dedicated subsection for this area's new failure form: two notification paths that, on too little read, send nothing — silently.
- Kept `docs/mandantentrennung-zugriffsklassifikation.md` in sync with `rls-access-inventory.spec.ts` for all 23 tenders pairs, including reclassifying `tenderRssFeedSource` from `muss-mandantengebunden` to `beides`.
## Task Commits
Each task was committed atomically:
1. **Task 1: Measure the three special cases and write the tenders failure-direction section** - `3498147` (feat)
2. **Task 2: Bind the five user-CRUD services and thread tenantId through the controller** - `3336a6e` (feat)
3. **Task 3: Bind the per-hit halves of both background services and close both documents** - `df5c5b7` (feat)
**Plan metadata:** committed separately by the orchestrator after this summary.
_Note: all three tasks were TDD-flavored (test proof before/alongside the binding change), single commit per task since the two-client proof and the binding change belong to the same logical unit._
## Files Created/Modified
- `apps/api/scripts/rls-scratch-check.mjs` — new `runTendersAreaChecks` section (9 checks: no user dimension, WINDOWS #19 invisibility + rejected insert, P2002 on invisible-row upsert), wired into `main()` after `runGroupsAreaChecks`
- `docs/mandantentrennung-etappe2-fehlerrichtung.md` — new `## Bereich tenders` section (t1–t5), plus a Task 3 nachtrag closing the per-hit halves
- `docs/mandantentrennung-zugriffsklassifikation.md` — Stand column for 5 pairs updated in Task 2, 5 more in Task 3; area overview table, class distribution, and "Der Hintergrunddienst als Falle" section all re-measured and updated
- `apps/api/src/tenders/tender-saved-search.service.ts` — `list`/`update`/`remove` bind via `forTenant()`, gained `tenantId` parameter
- `apps/api/src/tenders/tender-triage.service.ts` — `listForUser`/`favoriteIds` bind via `forTenant()`, gained `tenantId` parameter
- `apps/api/src/tenders/tender-notification-pref.service.ts` — `getForUser` binds, `setForUser` translates P2002 into a German `ConflictException`
- `apps/api/src/tenders/tender-email-config.service.ts` — `getConfigForApi`/`testConnection` bind and gained `tenantId`; `saveConfig`'s internal read now also binds; P2002 translated
- `apps/api/src/tenders/tender-rss-feed.service.ts` — `createForUser` binds; `listForUser`/`createPlatform`/`remove` stay unbound with WINDOWS #19 comments
- `apps/api/src/tenders/tenders.controller.ts` — eight call sites thread `tenantId` from `extractTriageContext` into the newly-parameterized service methods
- `apps/api/src/tenders/tender-digest.scheduler.ts` — candidate query selects denormalized `tenantId`; per-candidate loop binds pref/match/user/stamp
- `apps/api/src/tenders/tender-matching.service.ts` — per-profile loop binds match upsert and the instant-dispatch fresh/user/stamp accesses
- All corresponding `.spec.ts` files — `__makeBoundClient()` two-client proof, per-method binding tests, gegentest for the three intentionally-unbound RSS paths, silent-failure tests for both background services
## Decisions Made
- `tender-rss-feed.service.ts`/`tenderRssFeedSource` reclassified from `muss-mandantengebunden` to `beides` in the classification doc — mirrors the `ldapConfig` precedent from 260909-ipc, no behavior change, just a more accurate class.
- No `withTenantTransaction()` introduced anywhere in this area: Task 1 measured exactly one `$transaction` in `apps/api/src/tenders` (array form, on the platform-global `Tender` table in `tender-fingerprint-backfill.service.ts`), outside any tenant binding — the extension header's mandated re-check for a new transactional case is answered with "no new case," not assumed.
- The digest scheduler's candidate query now additionally selects the match row's denormalized `tenantId` so the per-row loop can bind at all; the edge case of a user having matches under two different tenants (a stale denormalized value after a tenant switch) is named in code and in both docs, not solved — explicitly Stage 3's problem.
## Deviations from Plan
None — plan executed exactly as written. The one place where execution diverged from the plan's literal wording (Befund C's "zehn Aufrufstellen") is a clarification, not a deviation: of the ten controller call sites that today discard `tenantId`, only eight actually needed the parameter threaded through, because the two RSS call sites (`listRssFeeds`, `removeRssFeed`) call service methods (`listForUser`, `remove`) that deliberately stay unbound and therefore never gained a `tenantId` parameter. This is the same "a raw count is a claim, not a finding" lesson the plan itself calls out repeatedly (Befund C is analogous to the 62-vs-61 raw-hit correction) — verified by re-reading each of the ten call sites individually rather than trusting the count.
## Issues Encountered
- The `rls-access-inventory.spec.ts` doc-vs-source consistency check failed after Task 2's and Task 3's binding changes, as expected since the check runs on every test invocation — the classification doc's Stand column was updated within the same task (not deferred to a later pass) so every task's own verification stayed self-contained and green.
- TypeScript flagged two implicit-`any` parameters in `tender-matching.service.ts` after `tenantPrisma` (typed `any`) replaced `this.prisma` as the receiver for two `.map()` calls — fixed with explicit inline parameter types (`match: { tender: unknown }`, `match: { id: string }`).
## User Setup Required
None — no external service configuration required. `DATABASE_URL` remains on the `tessera` role with `BYPASSRLS`; the switch stays off.
## Next Phase Readiness
- The `tenders` area is now at a mixed-but-fully-documented state: 5 pairs fully bound, 1 pair (`tenderRssFeedSource`) mixed with a code-level WINDOWS #19 boundary, 2 pairs (`tenderMatch`/`user` in the two background services split across `beides`/`gemischt`) with their per-hit halves closed and cross-tenant halves named as Stage 3 handoffs, and 12 pairs deliberately untouched (D-03 catalog + fan-out adapters).
- Stage 3 inherits: the WINDOWS #19 policy fix for `TenderRssFeedSource`/`SearchProvider`, the cross-tenant halves of the two background services (with the documented tenant-switch edge case), and the `req.tenantPrisma` architecture question (still undecided, as in every prior area).
- Stage 4 (cutover) preflight inherits Befund K: `tender-mail.service.ts` depends on `SettingsService.getDecryptedSmtpConfig(tenantId)`, and `settings.service.ts` (4 raw hits) is still fully unbound — after cutover this would silently stop all outbound mail. Recorded in the critique doc, not solved here.
- The classification doc's area overview now shows `tenders` at 36 ungebunden / 26 gebunden (was 62/0); remaining areas at their prior stand: `dkv` (21), `user` (17), `module-registry` (17), `dashboard` (13), `calendar` (12), `tenant` (8), `favorites` (7), `settings` (4) — all still fully untouched, the largest remaining pool of work for whichever Stage 2 area comes next.
---
*Phase: quick-260909-laa*
*Completed: 2026-09-09*
## Self-Check: PASSED
All 12 referenced artifact files found on disk; all 3 task commit hashes (`3498147`, `3336a6e`, `df5c5b7`) found in git history.