← all branches

feat/select-all

needs attentionviewing older commit
b21721d · fullPR #281reviewed 2026-07-08 19:03 UTC7H · 6M · 8L · 3I
The branch
Purpose
Add Gmail-style bulk selection that works across pages — users can select all N contracts matching the current filters (not just the 25 on the visible page) and dispatch batch CFE jobs or exports against the full set.
Goal
Select-all-matching bulk selection with persistent batch-result panel and per-RPU retry
Sub-goals
  • SG-1: New GET /utility-contracts/keys endpoint returns identifying keys of all matching contracts (capped at 500)
  • SG-2: BulkActionBar props refactored from selectedIds+rpus to records[] + cross-page meta
  • SG-3: Gmail-style 'select all N matching' banner with honest over-cap message
  • SG-4: Persistent batch-result panel grouping errors by code with one-click retry
  • SG-5: BatchRequestError parsed per-record issues for request-level validation rejections
The changes (whole branch)
What
New listKeys API route and handler; BulkActionBar reimplemented with cross-page selection model; selectedMeta Map accumulates off-page row metadata; fetchContractKeys client; 4 new commits adding the feature progressively (select-all → batch result panel → per-RPU failure mapping → schema fix).
Why
Users with large contract portfolios couldn't batch-query all their RPUs in one action because selection was limited to the visible 25-row page.
Areas
apps/platform/src/api+1170apps/platform/src/app/(dashboard)/bills+87893domains/utility/src/utility-contract-overview+1441packages/api/src+931packages/analytics/src+110docs+1200
Blast
19 files, +1249/-95 lines. Touches API contracts, handlers, UI components, domain queries, API schemas and types. No DB migrations. No shared infrastructure changes.
new-api-endpoint cross-page-state adr-016-violation
CI checks· GraphQL API access insufficient to read check statusCodeRabbit· No .coderabbit.yaml in repo

Findings · 24

correctness5

high

TOCTOU: COUNT and SELECT run as separate READ COMMITTED snapshots

domains/utility/src/utility-contract-overview/utility-contract-overview.queries.ts

listOverviewKeys runs Promise.all([SELECT LIMIT 500, COUNT(*)]). Under READ COMMITTED each sees a different snapshot. Case 1: inserted row → total = keys.length+1, the client's `if (total > keys.length) throw` guard fires and aborts a valid select-all. Case 2: deleted row → total = keys.length-1, a phantom publicId enters the selection and its batch dispatch fails. Fix: wrap both in a REPEATABLE READ transaction, or use a single query with a window COUNT.

medium

Retry re-dispatches already-succeeded RPUs when failure IDs can't be resolved from selection

apps/platform/src/app/[locale]/(dashboard)/bills/_components/BulkActionBar.tsx

idByRpu is built from records. If a server error.rpu doesn't match exactly (normalisation difference), the lookup returns null, onSelectionNarrow is skipped, and the Retry button re-sends ALL originally-selected records — including the ones that already dispatched. JobAlreadyExists guards prevent double execution but the whole retry batch may be rejected.

medium

fetchContractKeys omits isMonitored — keys and list filters can diverge

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_lib/contract-keys-client.ts

Only search and view are forwarded; isMonitored is not. Currently the page doesn't gate on isMonitored so both endpoints are consistent, but if isMonitored is added to the list call later the select-all will include contracts the list never shows.

low

pageFullySelected uses stale rpus during keepPreviousData transitions — spurious select-all banner

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx

While the next page loads, rpus still holds the previous page. If all previous-page rows are selected, pageFullySelected=true and the 'select all N matching' banner reappears briefly on every page flip.

low

groupFailures shows only the first failure's message per error code

apps/platform/src/app/[locale]/(dashboard)/bills/_components/BulkActionBar.tsx

When multiple RPUs share an error code, only the first message is kept. For InvalidRecord/generic errors each RPU may carry a distinct Zod field path; users see only one reason for all affected RPUs in the group.

security3

medium

Bulk-enumeration endpoint falls into generic rate-limit bucket

apps/platform/src/api/handlers/utility-contracts.handler.ts

GET /utility-contracts/keys returns up to 500 contract keys per call and uses the default 100 req/15 min in-memory rate limiter (per serverless instance, no distributed enforcement). An authenticated member can enumerate the org's entire RPU roster at significantly higher throughput than intended. Consider registering this route in getRateLimitConfig() under the more restrictive 'read' bucket.

low

sitePublicId not validated as belonging to caller's org at the application layer

apps/platform/src/api/handlers/utility-contracts.handler.ts

sitePublicId is passed directly to the query; org-scoping relies entirely on WHERE uco.org_id=$orgId and RLS. Pre-existing pattern matching listContractsHandler — no new risk introduced here — but lacks defense-in-depth at the application layer.

info

pgTextArray parameterisation is safe (SQL injection not a risk)

domains/utility/src/utility-contract-overview/utility-contract-overview.queries.ts

pgTextArray() passes the array literal through drizzle's sql template tag as a bound parameter ($1::text[]), not raw SQL. The existing character escaping is defense-in-depth for the array-literal format, not needed for injection prevention.

conventions7

high

Handler owns the RLS transaction directly — missing shell layer (ADR-016)

apps/platform/src/api/handlers/utility-contracts.handler.ts

listContractKeysHandler opens rlsDb.transaction() and calls the query function directly. Per ADR-016 and canonical-form.md, handlers must never compose transactions or call queries. A listOverviewKeysShell should own the transaction boundary and return Result<T,E>.

high

listOverviewKeys returns plain object, not Result<T,E> — ADR-016 violation

domains/utility/src/utility-contract-overview/utility-contract-overview.queries.ts

Returns Promise<{keys, total}> and can throw. Per ADR-016 all fallible ops return Result<T,E>. The handler papers over this with a bare try/catch at the wrong layer.

high

Handler uses bare try/catch instead of Result<T,E> mapping — ADR-016 violation

apps/platform/src/api/handlers/utility-contracts.handler.ts

The handler wraps the query in try/catch and delegates to mapDomainErrorToResponseWithContext. Idiomatic pattern is shell returns Result<T,E>, handler maps result.ok / result.error — no exceptions should reach this layer.

medium

Contract view param z.unknown() with no 422 response defined

apps/platform/src/api/contracts/utility-contracts.contract.ts

view: z.unknown().optional() with no 422 response code. Any validation rejection collapses to 500. No 403 declared for org-auth failures either.

low

SELECT_ALL_CAP and CONTRACT_KEYS_LIMIT are the same value in two places

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx

500 is the batch ceiling shared by the keys endpoint, the batch jobs route, and the UI affordance. The response body already returns `limit`; the client could read `response.limit` or both sides could import a shared constant from @batu/api.

low

handleSelectAllMatching throws Error on truncation (ADR-016: no thrown exceptions)

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx

Throws new Error('match set exceeds the selection cap'). Per ADR-016 errors should be discriminated Results. If BulkActionBar's catch is ever removed the rejection is silently swallowed.

low

GET request sends Content-Type: application/json (no body)

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_lib/contract-keys-client.ts

Content-Type describes the body's media type and is semantically incorrect on a bodyless GET. Remove it.

tests6

high

No test for onSelectAllMatching rejection path (cap race guard)

apps/platform/src/app/[locale]/(dashboard)/bills/_components/__tests__/BulkActionBar.test.tsx

The page-level handler throws when total > keys.length; BulkActionBar catches and toasts. No test verifies that the toast fires and the selection is not partially corrupted when the callback rejects.

high

fetchContractKeys (contract-keys-client) is entirely untested

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_lib/contract-keys-client.ts

The file encodes a non-obvious double-JSON-encoding for the view param (intentional by design, per its comment). No unit test verifies the URL shape, response unwrapping from body.data, or error propagation. A regression here would silently break select-all-matching.

high

groupFailures unknown-code ('generic') path has no test

apps/platform/src/app/[locale]/(dashboard)/bills/_components/__tests__/BulkActionBar.test.tsx

Tests only exercise known codes (ServiceNameMissing, JobAlreadyExists, InvalidRecord). An unrecognised server error code collapses to 'generic', which changes the rendered label and shows the raw message. No test covers this fallback.

medium

Cross-page selection persistence (meta survives paging) is untested

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/page.tsx

The core UX claim — selection of off-page rows persists — relies on selectedMeta accumulation. No test selects rows on page 1, flips to page 2, and confirms those rows still appear in records passed to BulkActionBar.

medium

listContractKeysHandler has no handler-level test

apps/platform/src/api/handlers/utility-contracts.handler.ts

Integration tests exercise the query layer directly. No test verifies that the handler applies RLS, enforces CONTRACT_KEYS_LIMIT, parses view correctly, or returns 401 on a resolveCurrentOrg failure.

medium

formatRpus truncation boundary (6 items) not exercised

apps/platform/src/app/[locale]/(dashboard)/bills/_components/__tests__/BulkActionBar.test.tsx

Tests only exercise 1-2 failures per group; the '+N' suffix branch and the exact-6-no-suffix boundary are untested. Off-by-one at the boundary would not be caught.

improvement3

low

Duplicated view-state extraction between listContractsHandler and listContractKeysHandler

apps/platform/src/api/handlers/utility-contracts.handler.ts

Both handlers inline the same 5-field view-state mapping (rules, phases, phasesEngaged, assignedToMe, sorting). Extract a shared `toViewState(view, opts)` helper — one place to update when the view schema gains a new field.

low

groupFailures and formatRpus are pure functions defined inside the component

apps/platform/src/app/[locale]/(dashboard)/bills/_components/BulkActionBar.tsx

Both close over nothing from component scope. Defined inside the function body they are recreated on every render. Moving them to module-level (alongside NON_RETRYABLE_ERRORS, KNOWN_ERROR_CODES) costs nothing.

info

view param double-encoding comment could be clearer for future readers

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_lib/contract-keys-client.ts

The param is named `view: string` (a JSON-encoded blob) and the function JSON.stringifies it again. The existing comment explains the intent; renaming to `viewJson` and noting 'this function adds the jsonQuery wrapper' would prevent a well-meaning fix from breaking the encoding.

History · 3 commits

  1. faa43eeneeds attentionincremental1H · 7M · 8L2026-07-09 01:11
  2. af91420needs attentionincremental0H · 8M · 10L2026-07-08 23:28
  3. b21721dneeds attentionfull7H · 6M · 8L2026-07-08 19:03current