← all branches

feat/energia-ui2

needs attentionviewing older commit
a6ab4b4 · fullPR #316reviewed 2026-07-16 14:02 UTC6H · 12M · 9L · 6I
The branch
Purpose
Wire the Energía module to a real findings ledger — automated anomaly detection over CFE bills (billing errors + optimization opportunities)
Goal
P2 read-only findings API + detection engine: full-stack from DB schema through domain entity (FCIS), REST API, and UI wired to real findings. Closes two security findings from PR #303 review.
Sub-goals
  • SG-1: DB schema + Drizzle schema for utility_contract_findings (contract-anchored, no org_id)
  • SG-2: FCIS entity (type, errors, decisions, queries, shells, mapper, type-check) with 8 corpus-verified detectors
  • SG-3: Read-only API — GET /findings + GET /findings/:publicId with energia module gate
  • SG-4: UI — hallazgos/page.tsx + resumen/page.tsx wired to real findings via useFindings hook
  • SG-5: Security fixes — neutralize realData.ts + axoMockData.ts (no real customer data in bundle); delete cross-tenant org-switcher
The changes (whole branch)
What
New utility_contract_findings table + migration; full FCIS finding entity; ts-rest findings contract + handler + mapper; useFindings hook; Hallazgos and Resumen pages wired to API; orgContext.tsx rebuilt without org-switcher; axoMockData.ts + realData.ts neutralized.
Why
Energia MVP requires a real ledger backing the findings UI; prior demo had hardcoded customer data and a cross-tenant switcher flagged as security findings in loop-review #303.
Areas
domains/utility/src/finding+43000apps/platform/src/api+3400apps/platform/src/app/[locale]/(dashboard)/energia+36000apps/platform/src/lib+7005packages/database+135000packages/api/src/schemas+1501scripts/energia+1810docs/specs+1220
Blast
62 files, ~24k insertions. Core new entity (finding) + 9 energia UI pages + DB migration 0058. No existing domain entities modified (domains/core org type: 1 line change). DB: new table only, no schema changes to existing tables.
TEMP soft-launch bypass present (do-not-merge marker in PR description) PR explicitly marked [PREVIEW] — stacked on #310 Empty state expected until detector has run against org's bill data
CI· CI status not accessible via this tokenCodeRabbit· No .coderabbit.yaml present

Findings · 33

correctness4

high

insert() throws plain POJO instead of returning Result — escapes shell uncaught

domains/utility/src/finding/finding.queries.ts

When INSERT ... RETURNING yields zero rows, insert() throws FindingErrors.databaseError('insert') — a plain POJO (not an Error instance, not a Result). The shell has no try/catch around db.transaction(), so the exception propagates as an unhandled rejection instead of resolving to err(FindingDatabaseError). Callers expecting Result<_, _> will not get one. Fix: wrap transaction in try/catch and return err(mappedError), or change insert() to return Finding|null and handle null in the shell with return err(FindingErrors.databaseError('insert')).

medium

Optimistic lock miss in updateEstimate is counted in neither 'updated' nor 'unchanged'

domains/utility/src/finding/finding.shells.ts

When updateEstimate returns null (another writer won the optimistic lock race), the shell silently falls through. Neither updated nor unchanged increments, so inserted+updated+unchanged < candidates_processed. The lock-miss candidate should increment unchanged or a 'skipped' counter.

low

NaN-coercion from pathological DB numeric strings may cause spurious updates

domains/utility/src/finding/finding.decisions.ts

existingEstimated = Number(existing.estimatedImpact) — if the stored string is 'NaN' or '' due to a migration artifact, Number('NaN') !== any command value is always true, causing a spurious update on every detection run for that finding.

info

mapFindingReadError exhaustiveness check is vacuous — single-type parameter

apps/platform/src/api/mappers/finding.mapper.ts

The parameter is typed FindingFCIS.FindingNotFoundError (one concrete type, not a union), so the default: never branch is unreachable by construction. Widen to the full read-error union to make the switch meaningful.

security2

high

TEMP soft-launch bypass not reverted — /energia route reachable by all authenticated users

apps/platform/src/lib/soft-launch.ts

Two '// TEMP: energia live for preview demo — revert before merge' comments mark intentional but un-reverted changes. /energia is absent from GATED_PATH_MODULES so isGated() never blocks it; energia is absent from GREYED/HIDDEN so navState() returns 'live' for every org. The API handler's ensureModuleEntitled remains intact (no finding data is exposed to unentitled callers), but the route is prematurely visible in navigation to all orgs. Must be reverted before merge per the comments themselves. (PR description also says 'Still do-not-merge as-is'.)

medium

Platform-admin short-circuit in ensureModuleEntitled — latent org-context edge case

apps/platform/src/api/handlers/finding.handler.ts

Platform admins bypass the module gate, then resolveCurrentOrg runs independently. If a platform admin has no active membership context set, resolveCurrentOrg may resolve unexpectedly. Not a confirmed bypass but the admin path has no explicit test. The double-resolution of membership (once inside ensureModuleEntitled, once in resolveCurrentOrg) warrants a focused integration test for platform admin callers.

conventions7

high

Shell db.transaction() not wrapped in try/catch — throw from findingQueries.insert breaks Result contract

domains/utility/src/finding/finding.shells.ts

detectFindingsForOrgShell declares Promise<Result<DetectFindingsResult, DetectFindingsError>> but has no try/catch around await db.transaction(). When findingQueries.insert throws, the shell's returned Promise rejects instead of resolving to err(FindingDatabaseError). ADR-016 anti-pattern: 'Throwing exceptions for business errors (invisible control flow)'.

medium

findingQueries.insert throws instead of returning Finding|null — inconsistent with domain query convention

domains/utility/src/finding/finding.queries.ts

All other insert query functions in the utility domain return Entity|null on zero rows. finding.queries.ts throws FindingErrors.databaseError('insert') instead. This breaks the Result contract at the shell layer. Fix: return null, have shell test for null, return err(FindingErrors.databaseError('insert')).

medium

decideUpsertFinding returns Result<_, never> — bare type is idiomatic for total functions

domains/utility/src/finding/finding.decisions.ts

The function is total (never fails). cross-domain/src/site-resolution.ts documents the precedent: 'this decision is TOTAL — return the bare union instead of Result<..., never>'. Using Result<_, never> forces the shell to write an unreachable guard with a comment 'Unreachable'. Either return the bare UpsertFindingDecision type or document the choice.

medium

FINDING_CATEGORIES/STATUSES/SEVERITIES duplicated between DB schema and domain type file

domains/utility/src/finding/finding.type.ts

The same three as const arrays appear identically in packages/database/src/schema/utility-contract-findings.ts and finding.type.ts. Canon says DB schema should import from finding.type.ts (types flow one way). The 'ZERO external imports' constraint in finding.type.ts is the blocker — document or resolve the trade-off.

low

mapFindingReadError naming deviates from map{Verb}{Entity}Error convention

apps/platform/src/api/mappers/finding.mapper.ts

All other error mappers use a verb: mapCreateBillError, mapUpdateProfileError. mapFindingReadError uses adjective 'Read'. Convention name: mapGetFindingError.

low

GET /findings list contract spuriously declares a 404 response

apps/platform/src/api/contracts/finding.contract.ts

The list route declares 404: JSendFailSchema(['NOT_FOUND']). The handler never returns 404 — an empty org returns 200 with findings:[]. Only the get route needs 404.

info

Handler parameter types redundantly annotated alongside typeof contract.handler

apps/platform/src/api/handlers/finding.handler.ts

listFindingsHandler is typed typeof findingsContract.list.handler (correct) but async params are also annotated with explicit types. Redundant annotations can silently diverge from the contract if the contract query type changes.

tests10

high

Handler 403 module-gate path untested

apps/platform/src/api/handlers/finding.handler.ts

ensureModuleEntitled is the primary access-control boundary for both endpoints. Neither the contract test nor any integration test exercises the 403 FORBIDDEN path. A misconfigured gate (e.g. wrong module slug) would silently open the endpoint to all orgs with no failing test.

high

Outbox event atomicity not verified in integration test for detectFindingsForOrgShell

domains/utility/src/finding/__tests__/finding.integration.test.ts

Integration tests check inserted/updated/unchanged counts but do NOT assert that utility.finding.detected outbox events were written in the same transaction. Per ADR-016 and the shell testing checklist, shell tests must verify outbox atomicity.

high

No integration test for the finding status transition shell (outbox + DB atomicity)

domains/utility/src/finding/__tests__/finding.integration.test.ts

decideTransitionFindingStatus is well-tested at the decision layer, but there is no integration test for the write shell that wraps it. Required coverage: (a) updateStatus + outbox event written in same tx, (b) optimistic lock miss returns conflict without partial write, (c) verified transition persists verifiedAmount.

medium

candidates=0 early-return path in detectFindingsForOrgShell not tested

domains/utility/src/finding/__tests__/finding.integration.test.ts

Integration test always seeds real bill data. A test with an org that has no XML-sourced bills should verify the shell returns {inserted:0, updated:0, unchanged:0} without error.

medium

listByOrgEnriched pagination: offset-past-end and limit boundary cases not covered

domains/utility/src/finding/__tests__/finding.integration.test.ts

Not covered: limit=0 or limit=1 boundary, offset past the end of results (should return empty array, not error), total remains correct when offset+limit exceeds row count.

medium

detectFpChronicPenalty: bimonthly RPU gap handling not regression-tested

domains/utility/src/finding/__tests__/finding.detectors.test.ts

Source code comments that bimonthly RPUs produce billing gaps and the streak counter must work across them. No test exercises a 3-month streak with a bimonthly gap.

medium

detectConsumptionYoySpike: trendVsRecentPct detail field not asserted

domains/utility/src/finding/__tests__/finding.detectors.test.ts

detectConsumptionYoySpike populates trendVsRecentPct in details but no test asserts its value. Detail fields are part of the UI display contract.

low

detectDemandOversized: monthsWithDemandData detail field not asserted

domains/utility/src/finding/__tests__/finding.detectors.test.ts

trappedDepositMxn is asserted but monthsWithDemandData is never asserted.

low

finding.mapper.ts not tested in isolation

domains/utility/src/finding/finding.mapper.ts

Domain mapper covered only implicitly through integration tests. A unit test pinning field-by-field mapping would catch accidental field renames or null-coercion bugs without a DB.

info

useFindings hook has no unit tests — consistent with project norms

apps/platform/src/lib/hooks/api/useFindings.ts

UI hooks are conventionally not unit-tested in isolation in this codebase. No gap relative to project norms.

improvement10

medium

finding.decisions.ts: 10 detectors in one 1098-line file — extract to finding.detectors.ts

domains/utility/src/finding/finding.decisions.ts

All 10 detector implementations plus core upsert/transition logic are co-located in one 1098-line file. Canonical form expects decisions.ts to hold decision functions, not a detector battery. Extracting detectors into finding.detectors.ts reduces blast radius and allows cleaner test imports.

medium

Shell: per-decision INSERT/UPDATE loop — O(N) DB round-trips in one transaction

domains/utility/src/finding/finding.shells.ts

One INSERT + one outbox.insert per decision. For orgs with many findings this means O(N) individual statements per detection run. Batch INSERT would collapse N round-trips to 2 within the same transaction, keeping outbox delivery guarantee intact.

medium

HallazgosPage: limit hardcoded to 200 — findings silently truncated, counts misleading

apps/platform/src/app/[locale]/(dashboard)/energia/hallazgos/page.tsx

useFindings({ limit: 200 }) — no load-more, no cursor. The 'Ver los N hallazgos' summary count derives from the already-truncated list. An org with >200 findings will be silently undercounted. Needs pagination or at minimum a warning cap before GA.

low

COUNT(*) in listByOrgEnriched omits INNER JOIN — count can exceed items for soft-deleted contracts

domains/utility/src/finding/finding.queries.ts

The paginated rows query does INNER JOIN to utility_contracts, but the count query does not. If a finding's contract is soft-deleted, count > items.length, producing a perpetual 'more pages' state. Apply the same INNER JOIN in the count query or use count(*) OVER() as a window function.

low

Org-scoping IN-subquery re-evaluated on every read — consider CTE at scale

domains/utility/src/finding/finding.queries.ts

buildOrgScopedFindingsWhere constructs an IN(subquery) re-evaluated on every call, appearing in both rows and count queries. At org scale, a CTE or LATERAL JOIN would let the planner materialise the contract set once.

low

DETECTOR_VERSIONS fallback silently emits '{type}@1' for unregistered detectors

domains/utility/src/finding/finding.shells.ts

resolveDetectorVersion falls back to ${type}@1. A new detector added without a DETECTOR_VERSIONS entry silently gets version '@1', indistinguishable from an intentional initial version. Prefer a warning or throw on unregistered types.

low

humanizeType() fallback produces unpolished es-MX UI copy for unknown detector types

apps/platform/src/app/[locale]/(dashboard)/energia/_lib/findingView.ts

Unknown types produce strings like 'Fp regime change' in the es-MX UI. Consider logging a warning in dev and rendering 'Hallazgo sin clasificar' so new detector types are obvious rather than quietly degraded.

info

orgContext.tsx: ENERGIA_PLACEHOLDER synthetic data still visible in production UI

apps/platform/src/app/[locale]/(dashboard)/energia/_lib/orgContext.tsx

EnergiaOrgProvider spreads ENERGIA_PLACEHOLDER overwriting only id/name/logo. Remaining fabricated figures (spend, carbon, groupings) flow to non-API-backed views. Documented intentional stub — track before GA.

info

scripts/energia/detect-findings.ts is a thin shell wrapper — no concerns

scripts/energia/detect-findings.ts

Calls FindingFCIS.detectFindingsForOrgShell directly. CLI arg parsing only. No duplicated logic.

info

findingsSummary.ts is purely functional — no issues

apps/platform/src/app/[locale]/(dashboard)/energia/_lib/findingsSummary.ts

Pure, correct, consistent with API contract.

History · 2 commits

  1. f9b8901needs attentionincremental3H · 5M · 4L2026-07-17 19:56
  2. a6ab4b4needs attentionfull6H · 12M · 9L2026-07-16 14:02current