← all branches

feat/sites-1n

blockedviewing older commit
e70f9f5 · incrementalpre-PRreviewed 2026-07-07 19:28 UTC6H · 11M · 6L
The branch
Purpose
Fix the site-to-RPU 1:N relationship: stop fusing unrelated RPUs onto one site (dedup only when internal-ID AND name match; never auto-name from service_name), and give users the UX to manage the relationship.
Goal
Correct site dedup logic plus full site-management UX: match-driven drawer, site search API, site pencil modal (edit/move/create), group-by-site toggle, batch preview grouping, analytics events.
Sub-goals
  • SG-1: Shared site-resolution decision + rewire both coordinators (composite match, idempotent re-enroll)
  • SG-2: Site match/search: extended sites.list (search includes metadata.customer_id, response gains contractCount)
  • SG-3: createFromWizard accepts existingSitePublicId (org-validated, feeds resolveSiteForRpu)
  • SG-4: Drawer: segmented Crear/Usar-existente, debounced search, exact-match auto-switch, existing-site attach skips lifecycle overwrite
  • SG-5: Site cell pencil to SiteEditModal: edit details, move RPU, create new site; UX v2 swappable views
  • SG-6: Agrupar por sitio toggle: full-width site row, RPU rows keep all columns except Sitio
  • SG-7: Batch preview: grouped by (internal id + name) with existing-site flags, summary chips
  • SG-8: 4 typed analytics events, cross-domain CLAUDE.md site-resolution section
The changes (whole branch)
What
This incremental window (4 commits) completes SG-5 UX v2: swappable details/move views in SiteEditModal, editable create-new form with service-name prefill, server-resolved OCC opt-in for the modal's move action (resolveVersionServerSide: true on LinkSiteToContractCommand), and metadata threading through createSiteHandler.
Why
Diego approved the v2 wireframe — swapping views + naming scope in the title beats the previous Mantener radio that asked a question users did not come to answer.
Areas
apps/platform+1192153domains/utility+212packages/api+152domains/cross-domain+282105domains/core+331.branch+91106
Blast
25 files, +1634/-369 across the branch; heavy in apps/platform UI and cross-domain shells; no schema migration.
No DB migration required SiteEditModal is new (600 LOC in this window) resolveVersionServerSide is a new OCC opt-in — design review recommended Data remediation of existing 264 merged sites is out of scope
CI· no open PR — CI status unavailable for pre-PR branchcoderabbit· no .coderabbit.yaml in repo

Findings · 23

correctness6

high

resolveVersionServerSide=true silently approves concurrent first-claim to move transition

domains/utility/src/site-utility-contract/site-utility-contract.shells.ts:302

When the modal is opened on a first-claim RPU (no SUC), a concurrent actor can create the SUC between modal-open and the mutation firing. The shell reads existingSuc in-transaction, finds the newly created row, patches in its version, and the decision approves a move — silently overriding the concurrent actor's first-claim without a conflict error.

high

handleMove creates site then moves RPU as two separate calls — orphan site on failure

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

When moveChoice.kind === 'new', createSite.mutateAsync is called first, then setLinkedContract.mutateAsync. If the second call fails (network error, 409), the created site is orphaned with no RPU linked and no cleanup. The user's intent (create AND move atomically) is only half-fulfilled, and the partial state is invisible.

high

versionConflict error reports previousVersion+1 as actual version — guess not a re-query

domains/utility/src/site-utility-contract/site-utility-contract.shells.ts:324

When reassignSiteWithVersion returns null (CAS failed), the conflict error is constructed with previousVersion+1 as the actual version. This is a guess — the real version may be N+2 or higher under rapid concurrent writes. Any UI retry logic that reads the reported actual version will use the wrong value.

medium

Empty newSiteNameInput silently falls back to rpuNumber

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

const newSiteName = newSiteNameInput.trim() || rpuNumber — if the user clears the pre-filled input, the site is created named after the RPU number with no validation error. The Save button is not disabled when the input is empty.

medium

Simultaneous name + customerId update causes spurious 409 on second mutation

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

handleSaveDetails fires updateSite (bumps version) then setCustomerIdMutation with the pre-update version. The server falls back to the gate-read version which is now stale — triggers a spurious concurrency error whenever both fields change in the same save.

low

metadata field accepts arbitrary unknown values with no size limit

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

No maximum size constraint on the metadata object or individual values — clients can persist arbitrarily large JSONB blobs. Intended use (only customer_id) is documented in comments but not enforced at the schema level.

security4

high

Cross-org contract hijack via setSiteLinkedContractHandler

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

The handler gates the SITE on org membership but passes body.utilityContractPublicId directly to linkSiteToContractShell, which resolves the contract via service-role database (no RLS). contractQueries.findByPublicId is not org-scoped — any authenticated member of Org A who knows a uct_xxx publicId from Org B can PATCH /sites/<their-site>/linked-contract to re-link (steal) that contract to their own site. No manual org-ownership check compensates.

medium

Unbounded JSONB metadata injection via CreateSiteSchema

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

metadata is z.record(z.string(), z.unknown()) — any authenticated org member can inject arbitrary keys (nested objects, large payloads, privilege flags) into the JSONB column. Schema should be tightened to an explicit allowlist matching only customer_id.

medium

resolveVersionServerSide flag allows any caller to bypass OCC intent

domains/utility/src/site-utility-contract/site-utility-contract.shells.ts:302

Any authenticated caller who sends resolveVersionServerSide: true can move a contract they did not observe — the design-level OCC goal (prevent overwriting unseen changes) is defeated. While the CAS still prevents within-transaction races, a malicious member can unconditionally overwrite another user's move.

low

Internal org UUID in SiteResponseSchema leaks internal database structure

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

Leaking internal PostgreSQL UUIDs narrows the attack surface for IDOR enumeration. Use the org public ID (org_xxx) or omit the field.

conventions4

high

SiteResponseSchema exposes internal org UUID on wire

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

SiteResponseSchema includes orgId: z.string().uuid() which is the internal Postgres FK UUID, not the org_xxx public ID. Canonical form: internal id values must never leave the domain. The mapper at site.mapper.ts:45 passes orgId: site.orgId through explicitly.

medium

SiteListItemResponseSchema uses inferred type not satisfies-constrained

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

SiteListItemResponse is derived as z.infer<typeof SiteListItemResponseSchema> — inverting the canonical type flow (hand-authored type → Zod satisfies). All other site schemas use the satisfies pattern correctly.

medium

SiteEditModal casts ts-rest response body through untyped Record

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

Both siteQuery.data?.body and moveSearchQuery.data?.body are cast to { status: string; data?: Record<string, unknown> } and then field-by-field with typeof guards, defeating the Zod schema types that ts-rest already validated. Response shape renames produce no TypeScript error.

low

resolveVersionServerSide on command type blurs FCIS shell/decision boundary

domains/utility/src/site-utility-contract/site-utility-contract.decisions.ts:139

The flag belongs on the shell's input type, not the command consumed by the decision (which never reads it). Shell concerns (I/O, version resolution) should not leak into command types.

tests5

high

resolveVersionServerSide OCC shell path has no test coverage

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

The shell's version substitution (lines 303-305) and the subsequent CAS null-check (if (!moved) return err(versionConflict)) have no unit or integration test. A regression in the substitution logic or the CAS null-check is invisible to CI.

high

metadata threading in createSiteHandler has no test

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

The fix that conditionally spreads validation.data.metadata into CreateSiteCommand has no test asserting that metadata survives the handler round-trip. Diego already caught a regression on preview — the fix needs a test to prevent recurrence.

medium

No unit test for decideLinkSiteToContract with resolveVersionServerSide on command

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

No test asserts that resolveVersionServerSide: true on the command is inert at the decision layer — documenting the invariant that only the shell intercepts it.

medium

CreateSiteSchema metadata field not covered in schema validation tests

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

No test case asserts metadata is accepted or that it is correctly optional. A schema change silently removing it would not be caught until preview.

low

setSiteLinkedContractHandler has no integration test coverage

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

Neither the resolveVersionServerSide=true path (SiteEditModal) nor the standard expectedSucVersion path is tested at the integration level.

improvement4

medium

SiteEditModal move-view state fragmented across 4 variables — extract useMoveView()

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

moveSearch, debouncedMoveSearch, newSiteNameInput, newSiteInternalId, moveChoice are reset in two separate useEffect branches and read together in handleMove. A useMoveView() hook makes reset a single call and eliminates partial-reset bugs.

medium

resolveVersionServerSide boolean is leaky abstraction — prefer discriminated expectedSucVersion

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

Replace the boolean flag with expectedSucVersion: number | null | 'server-resolve'. The shell checks for 'server-resolve' and substitutes; the decision only ever receives number | null. No boolean leaks into the command type.

low

Untyped API response casts bypass compile-time schema checks in SiteEditModal

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

Use z.infer<typeof SiteWithLocationResponseSchema> or a typed JSend helper instead of Record<string, unknown> casts. Field renames on the server currently produce no TypeScript error.

low

handleSaveDetails two sequential mutations — add a composite backend endpoint

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

Name + customerId updates fire as two sequential mutations causing version staleness bug. Preferred fix: a composite PATCH shell that updates both atomically.

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:28current
  7. 499b1a5blockedincremental6H · 9M · 5L2026-07-07 18:26
  8. ff23301needs attentionincremental2H · 6M · 5L2026-07-07 01:05