feat/discovery
needs attentionviewing older commit090b4da · incrementalPR #305reviewed 2026-07-13 23:50 UTC1H · 3M · 5L · 4I- Purpose
- Feature Discovery v1 — provide contextual feature discovery for existing Batu users via ambient spotlights, a Descubre hub header badge, and a first-run product tour. Flag-gated (feature-discovery-spotlights, OFF by default).
- Goal
- Let providers-segment users discover unused capabilities organically without cold outreach. The hub shows the user's next action; the tour orients new users once.
- Sub-goals
- SG-1: Ambient spotlight — one coachmark per context, computed from real DB rows, paced ≤1/rolling-day, retire on dismiss
- SG-2: Descubre hub — header badge + list of gap items, self-serve pull + re-tour CTA
- SG-3: First-run guided tour — 5-step fixed sequence with scrim/cutout, skip-and-return
- SG-4: discovery_state table (migration 0058), FCIS domain (DiscoveryFCIS), JSend API, es/en i18n, PostHog events
- SG-5 (this commit): Preview-only debug block on the gap response so dogfooders can see why the gap is empty
- What
- Adds an optional debug block to the GET /gap API response, gated to VERCEL_ENV==='preview'. When enabled, exposes orgSegment, isAdmin, and live signal booleans so the team can diagnose why a non-providers or otherwise gated org sees an empty Descubre hub. Three files: handler (includeDebug flag), shell (DiscoveryGapDebug interface + debug propagation in both branches), and API schema (optional debug field in DiscoveryGapResponseSchema).
- Why
- Dogfooding on preview revealed that non-providers orgs see an empty hub with no explanation. The debug block surfaces the segment + signals that gate them out, accelerating diagnosis without prod risk.
- Areas
- apps/platform/src/api/handlers+7−1domains/cross-domain/src+32−0packages/api/src/schemas+13−0
- Blast
- 3 files, +52/-1 lines. Preview-env only by construction (VERCEL_ENV gate). Zero prod surface change.
Findings · 14
correctness3
Non-providers debug path: getDiscoverySignalState outside try/catch — breaks Result<T,E>
domains/cross-domain/src/feature-discovery.shells.ts:163
The getDiscoverySignalState call inside `if (input.includeDebug)` for non-providers orgs sits OUTSIDE the try/catch block that wraps the providers path. If the DB query throws, the exception propagates uncaught out of getDiscoveryGapShell instead of returning err(FeatureDiscoveryErrors.databaseError()). Breaks the Result<T,E> contract and causes an unhandled promise rejection in the handler. Move the call inside a try/catch or wrap the entire non-providers debug branch.
Zod signals schema is open z.record — should mirror closed DiscoverySignalState interface
packages/api/src/schemas/discovery.schemas.ts
signals is typed as z.record(z.string(), z.union([z.boolean(), z.number()])) — an open map. DiscoverySignalState is a fixed-key interface. A z.object({ hasCfeCredential: z.boolean(), ... }) would catch field-name drift at the schema boundary and satisfy the project pattern of hand-written Zod schemas that satisfies z.ZodType<ApiType>. New fields of other types added to the interface will silently pass the current open record.
Local dev: VERCEL_ENV unset means includeDebug=false (debug path untestable locally without manual env)
apps/platform/src/api/handlers/discovery.handler.ts
On a local dev machine where VERCEL_ENV is not set, process.env.VERCEL_ENV === 'preview' evaluates to false, so the debug path cannot be exercised locally without manually setting VERCEL_ENV=preview. Not a bug, but worth documenting for team members trying to test this feature.
security2
Debug signals visible to all authenticated preview users, not just admins
domains/cross-domain/src/feature-discovery.shells.ts
DiscoveryGapDebug.signals contains live boolean state about an org's integrations. On preview, any authenticated org member — not just admins — receives this in the gap response. VERCEL_ENV gating is reliable so prod is safe. Consider restricting debug=true to isAdmin===true on preview if the debug block is only needed by the dev team.
VERCEL_ENV gating is server-side only — reliable, no client bypass
apps/platform/src/api/handlers/discovery.handler.ts
process.env.VERCEL_ENV in the Next.js API handler is a real server-side env var (not inlined into the client bundle like NEXT_PUBLIC_VERCEL_ENV). The gating is reliable — clients cannot spoof it. No action required.
conventions2
process.env.VERCEL_ENV read twice in same handler file
apps/platform/src/api/handlers/discovery.handler.ts
The handler already reads process.env.VERCEL_ENV === 'preview' earlier (for relaxDailyBudget). The new includeDebug check repeats the raw env access inline. A shared const IS_PREVIEW = process.env.VERCEL_ENV === 'preview' at file scope (or module top) would keep the condition in one place.
DiscoveryGapDebug defined in shell file — canonical form prefers a types file
domains/cross-domain/src/feature-discovery.shells.ts
Per canonical form, view/result types used across the handler↔shell boundary belong in a .type.ts file. DiscoveryGapDebug and updated DiscoveryGapView are defined in feature-discovery.shells.ts. Low priority for a debug-only type.
tests3
includeDebug=true path (both branches) has no test coverage
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
Neither the non-providers+includeDebug=true path nor the providers+includeDebug=true path has integration test coverage. The non-providers debug branch calls getDiscoverySignalState and could fail or return wrong shapes silently. The providers branch spread is also unverified. A test asserting: (a) debug is absent when includeDebug=false, (b) debug.signals, debug.orgSegment, debug.isAdmin match expected values when includeDebug=true, for both org segments, would cover the new behavior.
getDiscoveryGapShell has no integration tests at all
domains/cross-domain/src/__tests__/feature-discovery.integration.test.ts
The existing integration suite covers getDiscoverySpotlightShell but not getDiscoveryGapShell. The non-providers short-circuit (items:[], tourSeen:true) is a production code path that affects every non-providers org; a regression there would silently return empty results with no test catching it. This gap predates this commit but is worth addressing given the new nested branch now touching that exact code path.
No test verifying debug field is absent on non-preview
apps/platform/src/api/handlers/discovery.handler.ts
A handler-level or contract test asserting that debug is absent when VERCEL_ENV !== 'preview' would be a useful guard against accidental prod leakage. Given this is the first preview-env-gated diagnostic on this endpoint, a guard test is worth adding.
improvement4
Debug object literal duplicated in two shell branches — extract helper
domains/cross-domain/src/feature-discovery.shells.ts
{ orgSegment: input.orgSegment, isAdmin: input.isAdmin, signals } is constructed identically in the non-providers early-return branch and the providers happy-path spread. Extract a const buildDebug = (signals: DiscoverySignalState): DiscoveryGapDebug => ({ orgSegment: input.orgSegment, isAdmin: input.isAdmin, signals }) and use it in both places. Prevents drift if DiscoveryGapDebug gains fields.
Unnecessary conditional spread — debug: result.value.debug is simpler
apps/platform/src/api/handlers/discovery.handler.ts:125
...(result.value.debug ? { debug: result.value.debug } : {}) is more complex than needed. debug: result.value.debug (typed DiscoveryGapDebug | undefined) produces identical JSON output since JSON.stringify omits undefined values. If explicit key-omission is intentional, add a comment explaining why.
Same conditional spread redundancy in shell providers path
domains/cross-domain/src/feature-discovery.shells.ts
...(input.includeDebug ? { debug: { ... } } : {}) could be debug: input.includeDebug ? buildDebug(signals) : undefined once a buildDebug helper is extracted. Cleaner and consistent with the handler simplification above.
Signal query asymmetry between non-providers (debug only) and providers (always) paths
domains/cross-domain/src/feature-discovery.shells.ts
Non-providers path only fetches signals when includeDebug=true; providers path always fetches them. The paths are mutually exclusive so no double-call occurs, but the structural asymmetry is worth noting if the non-providers guard is ever relaxed.
History · 36 commits
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50current
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08