← all branches

feat/sites-1n

needs attentionviewing older commit
ff23301 · incrementalpre-PRreviewed 2026-07-07 01:05 UTC2H · 6M · 5L · 4I
The branch
Purpose
Stop the mass-merge bug: unrelated RPUs being fused onto one site because the old dedup used name alone (razón social) as the merge key, causing 264 sites to absorb 1,088 RPUs incorrectly.
Goal
Make the Site↔RPU relationship correct, visible, and manageable. SG-1 establishes the single shared dedup rule for both site-creating coordinators.
Sub-goals
  • SG-1 (this commit): Shared resolveSiteForRpu pure function + rewire both coordinators (dedup = internalId AND name; no service-name default; idempotent; empty+empty = new site)
  • SG-2: Site match/search query + API endpoint
  • SG-3: Contract→site operations API (create/move/attach)
  • SG-4: Drawer match-driven Sitio block
  • SG-5: Contratos Site cell pencil → modal
  • SG-6: Group-by-site view
  • SG-7: Batch preview grouping
  • SG-8: i18n + analytics + docs
The changes (whole branch)
What
Added pure resolveSiteForRpu function (site-resolution.ts), added findByOrgInternalIdAndName query to site.queries.ts, rewired ensureSite in cfe-job-intent.shells.ts to use the new resolver, partially rewired contract-wizard.shells.ts (createFromWizardShell uses resolver; enableMonitoringShell only got the name-default fix), added 10 unit tests for resolveSiteForRpu, updated cfe-job-intent integration tests.
Why
Previous dedup used customer_id OR exact name — the OR caused mass-merge because razón social (serviceName) repeats across hundreds of RPUs. The fix replaces OR with AND, adds idempotent re-enroll via contract→site lookup, and enforces RPU number (not service name) as the fallback site name.
Areas
.branch+640domains/core/src/site+220domains/cross-domain/src+717236
Blast
10 files, +803/-236 across core domain (query) and cross-domain shells + tests. No schema migration. No API or UI changes in this increment.
enableMonitoringShell not wired to resolveSiteForRpu — SG-1 incomplete No PR open yet — incremental review, pre-PR
CI· No PR open — no CI run available for this pre-PR branchcoderabbit· No .coderabbit.yaml in repo

Findings · 20

correctness3

medium

findByOrgInternalIdAndName missing soft-delete filter

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

The query has no isNull(sites.deletedAt) guard. If a site is soft-deleted, a new enrollment with the same internalId+name resolves to the deleted site's id. resolveSiteForRpu returns useExisting with that id; ensureSite calls findById (also no soft-delete filter) and resurrects the deleted site as the new RPU's home. User sees an RPU attached to a decommissioned site. Fix: add isNull(sites.deletedAt) to the WHERE conditions.

low

Double-trim: shell pre-trims then clean() trims again — harmless but misleading contract

domains/cross-domain/src/cfe-job-intent.shells.ts:497

Both ensureSite (cfe-job-intent) and createFromWizardShell pre-trim name and internalId with .trim() || null before passing to resolveSiteForRpu, which calls clean() internally. Functionally identical (trim is idempotent), but the duplication signals design confusion about which layer owns normalization.

low

existingSucs[0] silently picks arbitrary site when soft invariant breaks

domains/cross-domain/src/cfe-job-intent.shells.ts:507

findSitesForContractInOrg may return multiple rows if the one-SUC-per-(contractId, orgId) invariant is violated by concurrent enrollments. The code takes [0]. The invariant is maintained by ensureSuc's move-instead-of-insert logic, but there is no UNIQUE constraint enforcing it. Low risk given transaction boundary, but findSitesForContractInOrg could add .limit(1) to make the intent explicit.

security3

medium

findById after resolution has no orgId guard — defense-in-depth gap

domains/cross-domain/src/cfe-job-intent.shells.ts:531

When resolution.kind === 'useExisting', the shell calls SiteFCIS.siteQueries.findById(tx, resolution.siteId) — a query with no orgId filter. Today this is safe because all siteId sources (findSitesForContractInOrg, findByOrgInternalIdAndName) are org-scoped. But when explicitExistingSiteId is wired to a future caller that takes siteId from user input (e.g. the 'usar sitio existente' drawer), the unguarded findById would silently read a foreign-org site. Add a post-findById orgId assertion: if (site && site.orgId !== params.orgId) return null.

info

Drizzle sql tagged template correctly parameterizes internalId — no injection risk

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

sql`${sites.metadata}->>'customer_id' = ${internalId}` is safe. Drizzle treats plain JS string interpolations as $N bound parameters. Identical to the existing findByOrgAndCustomerId pattern.

info

orgId scoping is correct in findByOrgInternalIdAndName and both call sites

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

eq(sites.orgId, orgId) is a mandatory AND condition. Both call sites pass orgId resolved from the authenticated session (not from request body). Cannot be bypassed.

conventions4

high

enableMonitoringShell not wired to resolveSiteForRpu — SG-1 incomplete

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

enableMonitoringShell calls decideCreateSite directly and always inserts a fresh site without any dedup check. The branch scope.md success criterion explicitly states 'Both site-creating coordinators share one resolution rule.' Only createFromWizardShell was wired; enableMonitoringShell was not. Calling enableMonitoring twice for the same contract with the same siteName+customerId creates two duplicate site rows. Fix: add a findSitesForContractInOrg + findByOrgInternalIdAndName lookup before site creation, then route through resolveSiteForRpu as ensureSite does.

medium

resolveSiteForRpu does not follow decide{Op} naming convention

domains/cross-domain/src/site-resolution.ts:52

The canonical form requires pure decision functions to be named decide{Operation}. The function returns a plain tagged union (SiteResolution) rather than Result<T,E>. The scope.md draft used decideResolveSiteForRpu — the name drifted during implementation. The function has no error path so Result wrapping would be noise, but the naming convention deviation reduces discoverability. Consider renaming to decideResolveSiteForRpu.

low

Integration test retains stale 'SG-9' comment — should be SG-1

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts:2

The banner comment reads 'Integration Tests (SG-9)' but the branch is working on SG-1. SG-9 does not appear in scope.md at all. Fix: change SG-9 → SG-1 on line 2.

info

findByOrgAndName and findByOrgAndCustomerId are now dead exports after rewire

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

Both functions remain in siteQueries but no non-test caller invokes them post-diff. They are the old single-field dedup functions the new resolver replaced. Worth removing or at minimum annotating as 'not to be used for dedup decisions' to prevent future misuse.

tests6

high

No integration test verifying name-alone dedup is rejected at DB level

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts

The razón social bug fix (never dedup on name alone) is covered at the unit level for resolveSiteForRpu, but there is no integration test that pre-seeds a site with a known name, enrolls a second RPU with only that name (no internalId), and asserts the new RPU gets its own separate site. If the ensureSite guard (requestedInternalId && requestedName) were accidentally removed, all unit tests still pass — only a DB-level integration test catches the regression.

medium

commitCfeJobIntentBatchShell has zero test coverage

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts

The batch shell is completely untested: batch-level dedup (two records for same RPU, last intent wins), two RPUs with same internalId+name resolving to one shared site, all-or-nothing rollback. The batch shell is the primary code path for bulk CSV uploads.

medium

Outbox events never verified in shell tests

domains/cross-domain/src/__tests__/cfe-job-intent.integration.test.ts

testing.md rule explicitly states 'Verify outbox event was written in same transaction' for shell tests. None of the integration tests query the outbox_events table. The utility.site_contract.reassigned event (ensureSuc path 2) is completely unverified.

medium

findByOrgInternalIdAndName has no direct query-level test

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

The JSONB predicate (metadata->>'customer_id' = internalId) is tested only indirectly through the shell. No test verifies: (a) null when metadata has no customer_id key, (b) null when site belongs to a different org despite matching name+customerId, (c) case-sensitivity behavior.

medium

ensureSuc reassignment path (path 2) is untested

domains/cross-domain/src/cfe-job-intent.shells.ts:590

ensureSuc has three branches: exact match (idempotent), move-SUC-to-new-site (reassign), and insert-new. The reassignment path (contract already claimed on a different site) is never exercised. It may be de-facto dead given idempotent re-enroll fires first, but that needs a targeted test to confirm.

low

Cross-org isolation not tested at findByOrgInternalIdAndName level

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

No test seeds org B with a site carrying a known customerId+siteName, then enrolls an RPU in org A with the same values, and asserts org A gets a new site rather than reusing org B's.

improvement4

medium

clean() contradicts ResolveSiteInput JSDoc — interface says 'pre-trimmed by shell' but function re-trims

domains/cross-domain/src/site-resolution.ts:28

The JSDoc says requestedSiteName and requestedInternalId are '(trimmed by the shell; null/empty when absent)', yet clean() inside the function trims again. The contradiction means the function silently accepts untrimmed input despite advertising a pre-trimmed contract. Either remove the shell pre-trims and let clean() own normalization, or remove clean() and enforce the pre-condition at the type level.

low

createFromWizardShell omits autoCreated/source metadata from wizard-created sites

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

ensureSite (cfe-job-intent) always writes { autoCreated: true, source: 'cfe_job_intent' } to site metadata. createFromWizardShell only stores { customer_id }. If observability queries filter on autoCreated: true to find programmatic sites, wizard-created sites are invisible.

low

resolveSiteForRpu and types not exported from cross-domain package index

domains/cross-domain/src/site-resolution.ts

Both current callers are intra-package (relative imports), so this is fine today. If a future coordinator in another package needs the shared rule, it must reach into package internals. Given the stated role as 'the single shared rule', exporting from the package index would be consistent with the intent.

info

tx as unknown as Database cast is pre-existing, not introduced by this diff

domains/cross-domain/src/cfe-job-intent.shells.ts

The cast is required because createSiteShell etc. accept Database (full client, supports nested saves), not DbOrTx. Pre-existing pattern across all cross-domain shells. No new risk.

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