Files
tessera-ctl/.planning/quick/260909-laa-mandantentrennung-etappe-2-bereich-tende/260909-laa-SUMMARY.md
T

174 lines
14 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: 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.