feat/discovery
needs attentionviewing older commitea68968 · incrementalPR #305reviewed 2026-07-22 23:01 UTC0H · 4M · 7L · 4I- Purpose
- Feature Discovery v1 — add ambient spotlights, a Descubre hub, and a first-run product tour to guide existing users toward unused capabilities in the Batu platform.
- Goal
- Ship contextual coachmarks (computed on-read, paced ≤1/rolling-day), a header hub badge, and a 5-step linear tour — all behind a flag (feature-discovery-spotlights, off by default) — without touching live users until the flag is flipped.
- Sub-goals
- Ambient spotlights: one contextual coachmark per session gap, retire-on-dismiss, 7 signal types
- Descubre hub: header badge listing gaps, self-serve pull, 'Volver a tomar el tour'
- First-run linear tour: fixed 5 steps, dimmed scrim + spotlight cutout, 1/N progress, 'Hazlo ahora' shortcut
- discovery_state table: user-scoped RLS, migration 0064
- FCIS throughout: pure decisions + read-coordinator shell, DiscoveryFCIS namespace
- JSend API, es/en i18n, PostHog analytics
- Adversarial review remediation: preview bypass (was keyed on Host header, now VERCEL_ENV), spotlight self-destruct fix (render latch), atomic upsert, gate-key retirement exemption
- RPU history drawer: row click opens regime-timeline drawer with deep-link URL state (?detail=<id>)
- RPU validation fix: 12-digit exact match (was 11-12), too-long state, debounced hint
- Site name collision: ID match auto-switches, name match shows advisory (not auto-attach) — 379-sites lesson
- What
- This incremental commit merges main into feat/discovery to pick up migration 0063→0064 renumber. The diff adds: RPU validation precision (12 digits exact), site-name collision advisory in InlineContractForm, row-click wiring for ContractDetailDrawer, i18n keys for the chapters/drawer namespace, and the discovery_state migration SQL.
- Why
- Renumber migration after main advanced past 0063; fix edge cases (11-digit RPU, name-only auto-attach) found during adversarial review; wire the chapters endpoint that the ContractDetailDrawer depends on.
- Areas
- apps/platform — discovery components+1200−20domains/cross-domain — feature discovery FCIS+1100−0domains/core — discovery shells/decisions+250−0packages/database — schema + migration + seed+13750−0packages/api — discovery schemas + analytics events+200−0apps/platform — contracts table + drawer + page+200−30e2e + tests+700−0
- Blast
- 67 files, +19 067/−65 lines across discovery components, cross-domain FCIS, core domain, DB schema, API contracts, and UI. Flag-gated: no user impact until feature-discovery-spotlights is flipped. Migration 0064 is additive-only (new table).
Findings · 14
correctness5
sameNameSite advisory can show stale site name after input is cleared
apps/platform/src/app/[locale]/(dashboard)/bills/_components/InlineContractForm.tsx:196
The sameNameSite effect depends only on [siteOptions] but reads serviceName, customSiteName, useServiceAsSite, siteMode, and submitting from closure. If the user clears the service name field while siteOptions has not re-fetched, the effect does not re-run and sameNameSite stays non-null — the amber advisory remains visible referencing a site that no longer matches the current input. Fix: add the missing closure variables to the dependency array, or capture a ref snapshot of the inputs inside the effect.
SiteModeToggle does not clear sameNameSite — advisory can resurface unexpectedly
apps/platform/src/app/[locale]/(dashboard)/bills/_components/InlineContractForm.tsx:586
When the user clicks the SiteModeToggle directly (not the advisory's 'Usar el sitio existente' button), the toggle sets userChoseModeRef.current = true and setSiteMode(m) but does NOT call setSameNameSite(null). The advisory is hidden while in 'existing' mode. However if the user later switches back to 'new' mode, the effect bails early (userChoseModeRef.current === true) and sameNameSite retains its old value, causing the advisory to reappear with a stale site reference. Fix: add setSameNameSite(null) to the SiteModeToggle onChange handler.
useLookupContract enabled guard uses '>= 11' — dead condition after 12-digit fix
apps/platform/src/app/[locale]/(dashboard)/bills/_components/AddContractDrawer.tsx:182
useLookupContract is called with enabled: debounced.length >= 11. Since shouldLookup only fires at exactly 12 digits, debounced is always '' when digits < 12, so the >= 11 check is never independently reached. The guard should be debounced.length === 12 to match the stated invariant and prevent any future path from triggering a lookup on an 11-digit value.
[role='dialog'] in row click guard is a dead selector
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/ContractsTable.tsx:669
The guard target.closest('button, a, input, [role="checkbox"], [role="dialog"]') includes [role="dialog"]. Radix/shadcn dialogs render in portals outside the table DOM — a click inside a dialog cannot bubble through a <tr>. The selector is harmless today but misleading to maintainers and could cause unintended behavior if dialogs ever render inside table rows.
detailId useState reads window.location.search — SSR/client hydration mismatch
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx:130
The lazy initializer guards against SSR with typeof window === 'undefined' but still produces a null (server) vs non-null (client with ?detail=id in URL) mismatch on hydration. The established App Router pattern is useSearchParams() — already used on this page (discoverySearchParams) and SSR-safe by design. Using it for the initial detailId read would eliminate the guard and the mismatch.
security2
No client-side publicId format validation before issuing chapters query
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx:131
The ?detail param is passed directly to the API as publicId. The server handles arbitrary strings safely (parameterized queries, returns 404 for unknown IDs). A lightweight format guard (e.g. /^ctr_[0-9A-Z]{26}$/) would fail fast on malformed deep-links and avoid unnecessary API round-trips. Not a security risk given server-side safety.
chapters handler service-role bypass is load-bearing — document the invariant
apps/platform/src/api/handlers/utility-contracts.handler.ts
After confirming org access via RLS-wrapped findOverviewByContractPublicId, subsequent reads use the service-role connection (no RLS). This is correct because utility_contracts/bills carry no org_id. The org gate is the overview lookup. The invariant — subsequent reads must only use IDs returned by the authenticated overview, never client-supplied IDs — is currently enforced by convention only. Adding a typed scope object would make the constraint structurally enforced.
conventions2
Removed i18n keys monitoredOnly/fullHistory — verified clean
apps/platform/src/messages/en.json
Both keys removed from en.json and es.json. Grep confirms no remaining references in .tsx/.ts files. Clean removal.
PR description references stale migration number '0058' (actual: 0064)
packages/database/drizzle/0064_discovery_state.sql
The migration was renumbered from 0063 to 0064 when main was merged in (per commit message). The PR description still says 'migration 0058'. Cosmetic only — no runtime impact. Update the PR body for audit trail accuracy.
tests5
12-digit RPU validation boundary (was 11) has no regression test
apps/platform/src/app/[locale]/(dashboard)/bills/_components/AddContractDrawer.tsx:156
The 11→12 digit boundary fix was a confirmed production bug (11-digit incomplete numbers verified as valid). The LookupState state machine — shouldLookup, debounced, too-short, too-long, showLengthHint — is entirely untested. A unit test covering 11 digits → too-short, 12 digits → triggers lookup, 13 digits → too-long would guard against regression.
ID-vs-name match split in InlineContractForm advisory has no unit test
apps/platform/src/app/[locale]/(dashboard)/bills/_components/InlineContractForm.tsx:195
The three-way branch (ID match → auto-switch; name match → advisory only; no match → clear) guards against the 379-sites mass-merge bug at the UI layer. The backend decision function is well-tested, but the UI advisory path is not. A mismatch here could silently re-introduce the over-merge bug even though the backend is correct.
Drawer deep-link URL state not covered by tests
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx:130
openDetail, closeDetail, and the detailId initializer (deep-link from ?detail= on load) are untested. The deep-link init path is the kind of edge case easy to break silently, especially after router changes.
ContractsTable onOpenDetail prop not exercised in existing memo test
apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/ContractsTable.tsx
The existing ContractsTable.memo.test.tsx renders without onOpenDetail. No test verifies the row click calls the handler or that rows gracefully no-op without the prop.
chapters handler wiring has no integration test
apps/platform/src/app/api/[...ts-rest]/route.ts:499
The new chapters: listContractChaptersHandler wiring is not covered by an integration smoke test. An auth regression or contract-shape mismatch would go undetected until runtime.
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:01current
- 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:50
- 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