feat/sites-1n
blockedviewing older commit499b1a5 · incrementalpre-PRreviewed 2026-07-07 18:26 UTC6H · 9M · 5L- 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
- 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+1106−162domains/cross-domain+447−131docs/development+288−0packages/analytics+45−0packages/api+14−2domains/core+33−1domains/utility+29−0
- 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).
Findings · 22
correctness4
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.
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.
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.
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
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.
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
'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.
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.
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.
'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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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
- 7b5ff24needs attentionincremental0H · 2M · 3L2026-07-09 00:58
- 6231fe5needs attentionincremental0H · 2M · 4L2026-07-08 22:34
- 8fa38b5needs attentionincremental2H · 4M · 5L2026-07-08 01:45
- 83fd5a0needs attentionincremental3H · 8M · 11L2026-07-08 01:37
- 23c04fcneeds attentionincremental3H · 3M · 2L2026-07-07 20:30
- e70f9f5blockedincremental6H · 11M · 6L2026-07-07 19:28
- 499b1a5blockedincremental6H · 9M · 5L2026-07-07 18:26current
- ff23301needs attentionincremental2H · 6M · 5L2026-07-07 01:05