← all branches

feat/sites-1n

blockedviewing older commit
499b1a5 · incrementalpre-PRreviewed 2026-07-07 18:26 UTC6H · 9M · 5L
The branch
Purpose
Stop fusing unrelated RPUs onto one site due to the old name-only dedup rule (Pilgrim's Pride incident). Give users the UX to see and manage the 1:N Site↔RPU relationship.
Goal
Make Site↔RPU relationship correct, visible, and manageable: dedup only when internal ID AND name match; expose contractCount in site pickers; SiteEditModal for edit/move/split; group-by-site table view.
Sub-goals
  • SG-3: createFromWizardShell accepts existingSitePublicId (org-validated, wins over siteName/customerId)
  • SG-4: InlineContractForm match-driven drawer (auto-switch on exact match, picker, skips lifecycle overwrite on existing-site attach)
  • SG-5: Site cell pencil → SiteEditModal (edit details with N-RPU warning, move RPU, split to new site)
  • SG-6: 'Agrupar por sitio' toggle (site as full-width row, chevron collapse, page-local sort)
  • SG-7: Batch preview grouping with existing-site flags
  • SG-8: 4 typed analytics events + site_mode on inline_created
The changes (whole branch)
What
contractCount enrichment in sites list/get handlers; existingSitePublicId path in createFromWizardShell; full InlineContractForm site-picker UX; new SiteEditModal (523 lines); group-by-site view in ContractsTable; countBySiteIds query; extended site search to match metadata.customer_id; 4 analytics event types.
Why
Prior commit (SG-1) fixed dedup logic server-side. This pass makes the 1:N relationship visible and actionable in the UI.
Areas
apps/platform+1106162domains/cross-domain+447131docs/development+2880packages/analytics+450packages/api+142domains/core+331domains/utility+290
Blast
23 files, +2083/−297 across platform UI, cross-domain shells, and API schemas. Core blast surface: InlineContractForm (RPU creation flow), ContractsTable (all contratos views), SiteEditModal (new), createFromWizardShell (all RPU wizard creates), listSitesHandler + getSiteHandler (site pickers everywhere).
No PR yet — pre-PR branch review Move operation is broken at runtime (expectedSucVersion: null → always 409) 2 critical FCIS atomicity violations in SiteEditModal 1 high IDOR: setLinkedContract lacks contract-org ownership check 3 high test gaps: wizard shell existingSitePublicId path, countBySiteIds, handler contractCount
github-ci· No PR open — no CI rollup availablecoderabbit· No .coderabbit.yaml in repo

Findings · 22

correctness4

critical

Move reassign always 409 — expectedSucVersion: null with existing SUC

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/SiteEditModal.tsx:183

setLinkedContract is called with expectedSucVersion: null. The backend decideLinkSiteToContract treats null as a version mismatch when a SUC already exists, returning 409. Every RPU in the contratos table already has a SUC, so move always fails at runtime. Fix: expose current SUC version on ContractRow or fetch it at modal-open time and pass it as expectedSucVersion.

high

Orphan site on 'new' split — two sequential mutations with no rollback

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/SiteEditModal.tsx:197

createSite.mutateAsync() then setLinkedContract.mutateAsync() are two independent HTTP requests. If setLinkedContract fails after createSite succeeds, an orphan site is left in the org with no linked RPU. Fix: single cross-domain shell wrapping both writes atomically, or compensating delete in the catch block.

medium

Auto-switch effect can re-trigger and silently override selectedSite

apps/platform/src/app/[locale]/(dashboard)/bills/_components/InlineContractForm.tsx:208

userChoseModeRef is only set by button clicks, not the auto-switch. If siteOptions changes again after auto-switch (second debounce resolves different result), the effect re-fires and silently overrides selectedSite. Fix: set userChoseModeRef.current = true inside the auto-switch code path.

low

sites[i]! index alignment fragile if mapper filters or reorders

apps/platform/src/api/handlers/sites.handler.ts:131

.map((mapped, i) => contractCounts[sites[i]!.id]) assumes mapSitesWithLocationSummaryToResponse is length-preserving and order-preserving. Key by site publicId through the map instead.

security2

high

IDOR: setLinkedContract does not verify contract belongs to caller's org

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

Handler verifies the target site is in the caller's org but utility_contracts has no orgId column and contract lookup runs under service-role bypassing RLS. A member of Org A can supply a utilityContractPublicId from Org B. Fix: add org-scoped contract ownership check in the shell via entity_relationships join.

low

Same-site self-move guard is client-side only

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

The picker filters the current site client-side; server decision does not reject newSiteId === existingSuc.siteId. Would produce spurious outbox events on self-move. Add guard in the decision function.

conventions6

critical

'New' split: createSite + setLinkedContract are two HTTP requests — FCIS atomicity violation

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/SiteEditModal.tsx:195

ADR-016 FCIS: shells own the transaction boundary — fetch → decide → write entity + outbox atomically. Two separate HTTP mutations violate this invariant. If the second fails, the first side-effect is permanent with no outbox event. Fix: a single cross-domain shell (createSiteAndLinkContractShell) wrapping both writes in one transaction.

high

Handler calls siteContractQueries.countBySiteIds() directly — bypasses shell layer

apps/platform/src/api/handlers/sites.handler.ts:124

FCIS canonical form: handlers never call queries directly — always through shells. Calling a cross-domain utility query directly from the core handler also breaks domain isolation. Extract to a shell or composed read function.

medium

Analytics event 'contracts.group_by_site_toggled' uses plural domain prefix

packages/analytics/src/events.ts

All other events use singular 'contract.' prefix. Rename to 'contract.group_by_site_toggled' to match {domain}.{entity}.{action} convention.

medium

'Keep' mode: two separate API calls for name + customerId — partial update risk

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/SiteEditModal.tsx:229

If updateSite succeeds and setCustomerIdMutation fails, site is left with name updated but internal ID unchanged — no outbox event. Extend updateSiteShell to accept optional customerId for atomic writes.

medium

Unsafe runtime cast on ts-rest createSite result

apps/platform/src/app/[locale]/(dashboard)/bills/contratos/_components/SiteEditModal.tsx:208

(created.body as { data?: { publicId?: string } }) bypasses ts-rest typed response. After checking created.status === 201 the type already narrows correctly. Remove the cast.

low

New user-facing strings hardcoded in InlineContractForm — not via next-intl

apps/platform/src/app/[locale]/(dashboard)/bills/_components/InlineContractForm.tsx:606

'Crear sitio nuevo', 'Usar sitio existente', picker placeholders added hardcoded while the rest of the form uses useTranslations(). All user-facing strings must go through next-intl.

tests6

high

createFromWizardShell existingSitePublicId — no integration test

domains/cross-domain/src/contract-wizard.shells.ts

Happy path (attach to explicit site) and error path (cross-org site rejected 400) are both untested. The org-membership guard is a security boundary with no test.

high

countBySiteIds — no unit or integration test

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

Empty-array fast-path, single site, multi-site GROUP BY all untested. A regression would silently show zero for all contractCounts.

high

listSitesHandler / getSiteHandler contractCount field — no integration test

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

No test asserts contractCount is returned correctly. The field could be missing or always zero with no test catching it.

medium

site.queries search by metadata customer_id — no integration test

domains/core/src/site/site.queries.ts

JSONB ILIKE operator is a runtime SQL concern. A typo (-> vs ->>) would silently return no results for internal-id searches.

medium

ContractsTable groupedData sort/bucket — no unit test

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

Non-trivial es-locale collator sort and page-local bucket logic is untested. Edge cases: unsited rows, duplicate sitePublicId, accented names.

medium

SiteEditModal handleSave three branches — no component or E2E test

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

keep/move/new branches each call different mutations with distinct behavior. None are exercised by any test.

improvement4

medium

Duplicated SiteOption type and site-search picker UI

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

Identical SiteOption interface, debounced search, response-cast pattern, and picker list markup duplicated across InlineContractForm and SiteEditModal. Extract to a shared useSiteSearch hook and SitePicker component.

medium

Raw ts-rest response cast bypasses contract typing in both pickers

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

body cast to Record<string,unknown> when the ts-rest contract already types the response. Use the discriminated union directly.

low

U+FFFF sentinel for unsited-row sort order is fragile

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

Use null-first/null-last comparison instead of Unicode boundary sentinel to make intent explicit.

low

contractCount marked .optional() in schema but handler always supplies it

packages/api/src/schemas/site.schemas.ts

Make contractCount z.number().int().nonnegative() (non-optional) to align with handler guarantee and remove downstream defensive guards.

History · 8 commits

  1. 7b5ff24needs attentionincremental0H · 2M · 3L2026-07-09 00:58
  2. 6231fe5needs attentionincremental0H · 2M · 4L2026-07-08 22:34
  3. 8fa38b5needs attentionincremental2H · 4M · 5L2026-07-08 01:45
  4. 83fd5a0needs attentionincremental3H · 8M · 11L2026-07-08 01:37
  5. 23c04fcneeds attentionincremental3H · 3M · 2L2026-07-07 20:30
  6. e70f9f5blockedincremental6H · 11M · 6L2026-07-07 19:28
  7. 499b1a5blockedincremental6H · 9M · 5L2026-07-07 18:26current
  8. ff23301needs attentionincremental2H · 6M · 5L2026-07-07 01:05