feat/energia-ui2
needs attentionviewing older commita6ab4b4 · fullPR #316reviewed 2026-07-16 14:02 UTC6H · 12M · 9L · 6I- 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
- 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+4300−0apps/platform/src/api+340−0apps/platform/src/app/[locale]/(dashboard)/energia+3600−0apps/platform/src/lib+700−5packages/database+13500−0packages/api/src/schemas+150−1scripts/energia+181−0docs/specs+122−0
- 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.
Findings · 33
correctness4
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')).
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.
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.
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
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'.)
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
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)'.
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')).
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
detectDemandOversized: monthsWithDemandData detail field not asserted
domains/utility/src/finding/__tests__/finding.detectors.test.ts
trappedDepositMxn is asserted but monthsWithDemandData is never asserted.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
findingsSummary.ts is purely functional — no issues
apps/platform/src/app/[locale]/(dashboard)/energia/_lib/findingsSummary.ts
Pure, correct, consistent with API contract.