feat/select-all
needs attentionviewing older commitb21721d · fullPR #281reviewed 2026-07-08 19:03 UTC7H · 6M · 8L · 3I- 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
- 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+117−0apps/platform/src/app/(dashboard)/bills+878−93domains/utility/src/utility-contract-overview+144−1packages/api/src+93−1packages/analytics/src+11−0docs+120−0
- 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.
Findings · 24
correctness5
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.
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.
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.
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.
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
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.
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.
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
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>.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.