← all branches

feat/discovery

blockedviewing older commit
6a8bf42 · fullpre-PRreviewed 2026-07-11 01:08 UTC8H · 11M · 8L · 2I
The branch
Purpose
Feature Discovery v3: surface unused high-value capabilities to provider org users via contextual coachmarks (spotlights), computed at read time from live state — no tracker tables, no backfill job.
Goal
Ship the first production-ready spotlight system: one contextual spotlight per provider user per day, anchored to UI elements via data-tour attributes, with server-side pacing and client-side outcome recording.
Sub-goals
  • SG-1: Domain layer — discovery_state entity (FCIS), decisions, queries, shells, type-checks, mapper
  • SG-2: Cross-domain coordinator — decideSpotlight pure function with full rule order (providers-only, CFE gate, budget, eligibility, retirement, priority) + signal queries + read shell
  • SG-3: API surface — ts-rest contracts, handlers (getSpotlight, recordOutcome), error mappers
  • SG-4: UI component — FeatureSpotlight client component + spotlight-catalog, anchored via data-tour attributes on bills/contratos, bills/descargas, credentials/api pages
  • SG-5: Tests — decisions unit tests (core + cross-domain), integration test (cross-domain shell), E2E anchor smoke
The changes (whole branch)
What
New feature from scratch: 42 files changed (+2,389/-2 lines). Added discovery_state Drizzle schema + migration, full FCIS domain entity in domains/core, cross-domain coordinator (decideSpotlight + getDiscoverySpotlightShell), two new API endpoints, FeatureSpotlight React component, data-tour anchor attributes on 3 pages, analytics events, i18n strings.
Why
Provider org users are missing features (solar monitoring, payment monitoring, export configs, API keys, CFE setup) that are already built but undiscovered. Contextual spotlights surface each capability at the moment the user is most likely to need it, driven by live adoption state rather than static flags.
Areas
domains/core/src/discovery+5480domains/cross-domain/src+9140apps/platform/src/api+2131apps/platform/src/components/discovery+3050apps/platform/src/app/[locale]/(dashboard)+212packages/api/src/schemas+590packages/database/src/schema+980e2e/platform+460packages/analytics/src+420
Blast
42 files, +2,389/-2 lines. New domain entity + cross-domain coordinator + 2 API endpoints + 1 React component. Dashboard layout now mounts FeatureSpotlight on every page load for provider orgs. No existing behavior changed except 3 UI elements gained data-tour attributes.
feature-flag-gated (PostHog: feature-discovery-spotlights) providers-only v1 (consumers silently skipped) no outbox events by design (v1 spec §6)
CI· No open PR — CI signals unavailable for pre-PR branchCodeRabbit· No .coderabbit.yaml in repo

Findings · 29

correctness6

critical

updateWithVersion omits version check in WHERE clause — optimistic lock is broken

domains/core/src/discovery/discovery.queries.ts:96

WHERE clause filters only on `id`, not `version`. `currentVersion + 1` is set but the concurrent-write guard is missing. A race between two simultaneous `recordOutcome` calls silently overwrites the first. Add `eq(discoveryState.version, decision.currentVersion)` to the WHERE. Every other versioned entity in the codebase (api-key, secret-configuration, site, context) includes this guard.

high

RLS binding in recordOutcomeHandler: createRLSDb passed to shell which calls .transaction() itself

apps/platform/src/api/handlers/discovery.handler.ts:86

Handler passes `createRLSDb(database, userId)` to the shell, which then calls `db.transaction(async tx => …)`. The invariant is `createRLSDb(db, uid).transaction(tx => …)` — RLS binds per-transaction only. If the proxy does NOT re-run SET LOCAL inside the transaction spawned by the shell, writes execute without RLS enforcement. Verify the proxy correctly sets the RLS context at the start of every .transaction() call, or restructure so the handler wraps the shell call in the transaction.

high

Budget window blocks ALL spotlights for 24h when ANY key was seen — one impression starves all others

domains/cross-domain/src/feature-discovery.decisions.ts:3

Budget check uses `exposures.some(e => within budget window)` across ALL keys. A single 'seen' on spotlight A prevents spotlight B from appearing for 24h. If the spec intends 'at most one new spotlight per day per user' (not 'zero spotlights once anything was seen'), the filter should check the candidate key's own lastSeenAt, not any key. Verify the spec intent — if cross-key suppression is intentional, document it explicitly.

medium

recordedRef not reset on spotlightKey change — new spotlight on same mount won't record 'seen'

apps/platform/src/components/discovery/FeatureSpotlight.tsx:8

recordedRef is never reset when spotlightKey changes. If the layout stays mounted and a new key is received (SPA navigation), recordedRef.current is already true and 'seen' is not recorded. Store the last-recorded key (string | null) instead of a boolean to deduplicate by key.

medium

Missing version-conflict error variant — null from updateWithVersion maps to generic 500

domains/core/src/discovery/discovery.shells.ts:66

Once the WHERE version-guard is added (critical finding), a null return means version conflict, not a database failure. The shell should emit a dedicated DiscoveryErrors.versionConflict() (4xx retryable) rather than a generic 500. Add the variant before shipping the version-guard fix.

low

getDiscoverySpotlightShell accepts DbOrTx — bare db would bypass RLS silently

domains/cross-domain/src/feature-discovery.shells.ts:2

Shell relies on callers to have wrapped the arg in a createRLSDb transaction. Accepting `tx: Transaction` instead of `DbOrTx` makes this contract explicit at the type level and prevents future call sites from accidentally passing a bare db.

security3

medium

Raw database error message leaked to client via serverError(error.message)

apps/platform/src/api/mappers/discovery.mapper.ts:14

mapGetSpotlightError and mapRecordOutcomeError pass error.message directly into serverError(). If DiscoveryDatabaseError wraps a raw Drizzle/Postgres exception, the response body may contain schema names, table names, or query fragments. Sanitize to a generic message and log the raw error server-side only.

low

orgId path param not bound to discovery_state queries — design intent should be documented

apps/platform/src/api/handlers/discovery.handler.ts:80

requireOrgAccess gates on orgId but discovery_state rows are scoped only by profileId (not orgId). Safe today via RLS own-rows-only + profileId binding. If discovery_state ever gains org scope, this silently stops scoping correctly. Add a comment documenting the intentional profile-scope design.

info

createRLSDb seeded with userId (Supabase auth UID) but rows filtered on profileId — verify RLS predicate matches

apps/platform/src/api/handlers/discovery.handler.ts:84

If the RLS policy uses auth.uid() and discovery_state.profileId stores a domain profile ID (not the Supabase UID), the policy predicate silently doesn't filter and only the app-layer WHERE guards the rows. Confirm the column type stored in discovery_state.profileId is the Supabase auth UID or that the RLS policy uses the correct identifier.

conventions6

high

Missing DiscoveryFCIS namespace export in barrel

domains/core/src/discovery/index.ts:1

canonical-form.md requires the barrel index.ts to export a named {Entity}FCIS namespace (e.g. DiscoveryFCIS grouping queries, shells, decisions). Every other entity's barrel exports this namespace. The discovery barrel exports individual symbols only.

medium

Entity file naming mismatch: table is discovery_state but decision/query/shell files use discovery.*

domains/core/src/discovery/:1

canonical-form.md requires {entity}.decisions.ts etc. where {entity} matches the domain table slug. The Drizzle table is discovery_state, so canonical names would be discovery-state.decisions.ts. Using discovery.* is inconsistent with the one-to-one convention used by all other entities.

medium

Cross-domain feature-discovery files have no FCIS namespace or barrel export

domains/cross-domain/src/feature-discovery.queries.ts:1

Cross-domain coordinators should follow the entity file pattern with a namespace export. The standalone .queries.ts with no companion barrel or namespace makes the module inconsistent with how other cross-domain coordinators are structured.

medium

ValidationErrorResponseSchema defined locally instead of imported from packages/api

apps/platform/src/api/contracts/discovery.contract.ts:34

The schema is copy-pasted across 4+ contract files (column-configurations, credentials, api-keys, discovery). Should be extracted to packages/api/src/schemas/jsend.schemas.ts alongside JSendSuccessSchema/JSendFailSchema/JSendErrorSchema.

low

Multi-paragraph docstring block in handler file violates comment policy

apps/platform/src/api/handlers/discovery.handler.ts:1

Project policy: one short line max, no multi-line comment blocks explaining what code does. The file-level JSDoc block describes both handlers, org segment sourcing, and ts-rest caveats. Should be removed or reduced to a single-line reference.

low

Multi-line docstring in FeatureSpotlight.tsx violates comment policy

apps/platform/src/components/discovery/FeatureSpotlight.tsx:1

Same comment policy violation as the handler. The multi-paragraph JSDoc block listing spec references and behavior rules should be removed.

tests11

high

recordSpotlightOutcomeShell: null-from-DB error path (write returns null) has no integration test

domains/core/src/discovery/discovery.shells.ts:66

The shell guard at line 66 returns err(DiscoveryErrors.databaseError(operation)) when the DB write returns null. No test covers this path. Once the version-guard is added (critical fix), this path becomes the version-conflict path — add integration test before merging.

high

updateWithVersion: version increment in DB not verified at the query layer

domains/core/src/discovery/discovery.queries.ts:85

No integration test directly exercises updateWithVersion and asserts the version field is incremented in the DB row. Integration tests verify seenCount bumps through the shell but never assert version = currentVersion + 1 post-write. Add a direct query-layer integration test.

high

API handlers: no tests for auth/authz failure paths (401, 403, 422, 500)

apps/platform/src/api/handlers/discovery.handler.ts:45

Neither getSpotlightHandler nor recordOutcomeHandler has a test. Missing: (1) unauthenticated → 401; (2) no profileId → 401; (3) not org member → 403; (4) invalid outcome value → 422; (5) shell returns DiscoveryDatabaseError → 500. Handler tests are the canonical way to lock in the full validate→authorize→shell→map chain.

high

recordOutcomeHandler: profileId-from-auth (not body) security invariant has no test

apps/platform/src/api/handlers/discovery.handler.ts:100

The handler passes profileId from authReq.auth to the shell, preventing callers from recording outcomes against another user's profile. No test asserts this property. This is a load-bearing security invariant that should be an explicit handler-level assertion.

high

findByProfileAndKey: no-match (null return) path not directly tested

domains/core/src/discovery/discovery.queries.ts:36

The function returns null for a missing (profileId, key) pair, tested only implicitly through the shell's first-seen flow. A direct integration test asserting null for a genuinely missing key is needed per the testing.md thin-DB-wrapper guideline.

medium

FeatureSpotlight: missing anchor behavior (no 'seen' recorded, analytics event fires) not tested

apps/platform/src/components/discovery/FeatureSpotlight.tsx:136

The 10×300ms retry exhaustion path emits discovery.spotlight_anchor_missing and renders nothing without recording 'seen'. This 'do not consume pacing budget on missing anchor' invariant is uncovered by any test. Add an RTL test that renders the component with a key whose data-tour anchor is absent.

medium

FeatureSpotlight: dismiss and click mutation paths have no component test

apps/platform/src/components/discovery/FeatureSpotlight.tsx:178

onDismiss and onCta call record() and track() but there are no RTL tests asserting 'dismissed' or 'clicked' outcome mutations fire, or that the coachmark disappears after dismiss.

medium

spotlight-catalog: resolveSpotlightPage mapping coverage incomplete

apps/platform/src/components/discovery/spotlight-catalog.ts:33

No unit tests cover: (1) exact match per page; (2) sub-path match (/bills/contratos/detail → bills-contratos); (3) unrelated path → null; (4) near-miss prefix that must NOT match (/bills/contratoss). Pure function — trivially fast unit tests.

medium

E2E: actual spotlight show/dismiss/click flow not covered

e2e/platform/discovery-anchors.spec.ts:1

The E2E spec only asserts anchor DOM presence. No test exercises: PostHog flag enabled → GET spotlight returns key → coachmark appears → dismiss → outcome recorded → next page load returns null (budget gate). This is the primary user-visible behavior of the feature.

low

findByProfileId: multi-row return path not directly tested at the query layer

domains/core/src/discovery/discovery.queries.ts:24

findByProfileId is used by the read coordinator to load all exposure rows. No test asserts that after recording two distinct keys for the same profile, findByProfileId returns both rows correctly mapped.

low

FeatureSpotlight: desktop-only suppression (isDesktop=false → query disabled) not tested

apps/platform/src/components/discovery/FeatureSpotlight.tsx:60

No component test mocks a narrow viewport and asserts the GET /discovery/spotlight query is never triggered and nothing renders. Spec-level behavior (v3 spec §5 'desktop only') with no test coverage.

improvement3

low

ValidationErrorResponseSchema duplicated across 4+ contract files — extract to shared location

apps/platform/src/api/contracts/discovery.contract.ts:34

Byte-for-byte identical across discovery.contract.ts, column-configurations.contract.ts, credentials.contract.ts, api-keys.contract.ts. Extract to packages/api/src/schemas/jsend.schemas.ts alongside existing JSend schemas.

low

hasContracts in DiscoverySignalState is redundant with contractCount > 0

domains/cross-domain/src/feature-discovery.queries.ts:77

Both hasContracts (boolean) and contractCount (number) are populated, but hasContracts is just contractCount > 0. Remove hasContracts and update decisions to use contractCount > 0 directly — one field to keep in sync instead of two.

info

Scroll/resize effect uses `anchorRect !== null` as dependency with eslint-disable

apps/platform/src/components/discovery/FeatureSpotlight.tsx:159

The dependency array uses an expression rather than a stable value, requiring an eslint-disable suppression. Introduce a boolean ref for whether listeners are attached, or derive `const anchorFound = anchorRect !== null` outside the effect to make the dependency stable.

History · 36 commits

  1. c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
  2. 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
  3. ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
  4. 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
  5. 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
  6. cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
  7. 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
  8. ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
  9. 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
  10. 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
  11. 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
  12. 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
  13. 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
  14. d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
  15. 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
  16. 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
  17. 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
  18. b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
  19. 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
  20. 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
  21. 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
  22. 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
  23. a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
  24. 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
  25. 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
  26. 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
  27. 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
  28. fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
  29. d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
  30. c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
  31. 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
  32. 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
  33. 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
  34. 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
  35. fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
  36. 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08current