Dashboard, catalog and search reads
ENG-3131 moves eight Rulebase UI
Rails-client read roots to native API2 routes. Query keys, infinite-query page
parameters, loading and error states, filters, ordering, time-zone handling and
aggregate denominators stay stable while API2 reads the Rails-owned public
tables directly. There is no API2-to-Rails HTTP bridge, no Rails fallback and no
runtime flag; rolling back the UI bundle restores the prior Rails reads.
Ownership boundary
| UI root | Rails read retired | API2 route |
|---|---|---|
components/dashboard/style-guide/add-style-guide-link-dialog.tsx | GET /notion_pages | GET /notion_pages |
features/brands/api/get-brands.ts | GET /zendesk_brands | GET /zendesk_brands (existing) |
features/customer-issues/api/get-customer-issue-statistics.ts | GET /customer_issues/{id}/statistics | GET /customer_issues/{id}/statistics |
features/dashboard/.../scorecard-performance/justification-options-breakdown.tsx | GET /scorecard_criteria/{id}/statistics | GET /scorecard_criteria/{id}/statistics |
features/dashboard/.../scorecard-performance/scorecard-performance-criteria.tsx | GET /scorecard_criteria/{id}/statistics | same |
features/dashboards/api/get-check-results.ts | GET /check_results | GET /check_results |
features/data-sources/api/get-data-sources.ts | GET /data_sources | GET /data_sources (existing) |
features/search/api/search.ts (getSearchQueryOptions) | GET /search | GET /legacy_search |
The workspace search (/search on API2, getWorkspaceSearchQueryOptions) was
already native and is unchanged. Rails keeps every write on these resources.
Contract notes that reconcile to Rails behaviour:
- Date-only
start_date/end_dateparse in the organization time zone (start of day / end of day), must be ordered, and may span at most five years (src/lib/rails-date-range.ts). Daily buckets remainDATE(started_at)in UTC, as in Rails. - Customer-issue FCR rows are omitted only when
root_countis below the mean root count across the window;fcr_rate = fcr_count / root_countand the adjusted rate is(1 - fcr_rate) * (root_count * 2). - Scorecard breakdowns use Rails’ three independent predicates
(
score = max_score,0 < score < max_score,score = 0), so amax_score = 0criterion counts as both passed and failed; the average excludesmax_score = 0; the average series includes discarded children. - Check results include completed and active evaluations (overridden but
still active evaluations stay), order by
created_at DESC, id DESC, and agents only see evaluations whose subject employee is their own; an agent without a resolved employee fails closed. - Legacy search returns an empty Pagy envelope for a blank query, applies the
Rails search policy scope, uses
pg_search_documentswithto_tsvector('simple', f_unaccent(...)), prefix and trigram matching, the numeric-identifier fast path, and the{ id, name, type, caption }payload. - Notion pages decrypt the tenant’s first Notion data source secret, call
POST /v1/searchfiltered to pages, drop blank titles, surface provider failures and cache per tenant for one hour.
Verification
Before merge: focused API2 PGlite acceptance
(src/routes/dashboard-catalog-reads.db.test.ts, organizations 25
America/New_York and 41 Europe/Berlin), the ENG-3131 source gate
(rulebase-ui/app/__tests__/lib/eng-3131-rails-source-gate.test.ts), the
request-shape UI tests for brands, check results and search, API2 typecheck,
Rulebase UI typecheck and lint. The OpenAPI export and generated
rulebase-api2-schema.d.ts are committed with the routes.
Read-only reconciliation
Run against a read replica or in a read-only transaction
(BEGIN READ ONLY;). Compare Rails and API2 responses for the same session,
tenant and parameters during the bake window (through
2026-09-15 20:30 America/New_York) and for one full weekly window after the UI
deploy. Nothing below mutates data.
Aggregate denominators — customer-issue statistics for one tenant and window
(:org, :issue, :from, :to are tenant-local day bounds converted to UTC):
SELECT DATE(c.started_at) AS day, COUNT(*) AS conversations
FROM conversations c
WHERE c.organization_id = :org
AND c.customer_issue_id = :issue
AND c.started_at >= :from AND c.started_at <= :to
GROUP BY 1 ORDER BY 1;Scorecard criterion breakdown denominators:
SELECT r.qa_agent_evaluation_scorecard_criterion_id AS criterion_id,
COUNT(*) FILTER (WHERE r.score = r.max_score) AS passed,
COUNT(*) FILTER (WHERE r.score > 0 AND r.score < r.max_score) AS partial,
COUNT(*) FILTER (WHERE r.score = 0) AS failed,
AVG(r.score::numeric / NULLIF(r.max_score, 0)) AS average
FROM qa_agent_evaluation_scorecard_criterion_results r
JOIN qa_agent_evaluations e ON e.id = r.qa_agent_evaluation_id
WHERE e.organization_id = :org
AND e.processing_status = 'completed' AND e.active
AND e.created_at >= :from AND e.created_at <= :to
GROUP BY 1;Check-result page totals (meta.page.total must match on both services):
SELECT COUNT(*)
FROM qa_agent_evaluation_scorecard_criterion_results r
JOIN qa_agent_evaluations e ON e.id = r.qa_agent_evaluation_id
WHERE e.organization_id = :org
AND e.processing_status = 'completed' AND e.active;Catalog list totals:
SELECT COUNT(*) FROM zendesk_brands WHERE organization_id = :org;
SELECT COUNT(*) FROM organization_data_sources WHERE organization_id = :org;Search candidate counts for a query (:q), per searchable type:
SELECT searchable_type, COUNT(*)
FROM pg_search_documents
WHERE organization_id = :org
AND to_tsvector('simple', f_unaccent(content))
@@ to_tsquery('simple', f_unaccent(:q) || ':*')
GROUP BY 1;Payload reconciliation: for each root, fetch the Rails and API2 responses with
the same filter, page[page], page[limit], start_date/end_date and
type values and diff them as JSON after sorting keys. Expected differences:
none. Any missing/extra row, different meta.page.total, different ordering, or
a numeric difference beyond floating-point rounding is a finding.
Rollout and rollback
Deploy API2 before the Rulebase UI. After both regional releases are healthy,
watch the API2 route error rates and latency for /notion_pages,
/customer_issues/:id/statistics, /scorecard_criteria/:id/statistics,
/check_results and /legacy_search, and confirm the Rails
NotionPagesController, CustomerIssues::StatisticsController,
ScorecardCriteria::StatisticsController, CheckResultsController and
SearchController#index transactions stop receiving first-party
rulebase-ui requests through a complete production window.
Roll back the Rulebase UI bundle (Rails reads resume immediately) when any of these trigger:
- any reconciliation query or payload diff above disagrees for a production tenant;
- 5xx rate on any listed API2 route exceeds the Rails baseline for the same route over 15 minutes, or p95 latency doubles the Rails baseline;
- a 403 for a session that Rails authorizes (agent, partner or group-scoped reads), or any cross-tenant row in a response;
- Notion provider failures surface for a tenant whose Rails
/notion_pagessucceeds with the same data source.
API2 routes can stay deployed during a UI rollback; they are read-only.