feat/sites-1n
needs attentionviewing older commitff23301 · incrementalpre-PRreviewed 2026-07-07 01:05 UTC2H · 6M · 5L · 4I- 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
- 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+64−0domains/core/src/site+22−0domains/cross-domain/src+717−236
- 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.
Findings · 20
correctness3
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.
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.
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
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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
- 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:26
- ff23301needs attentionincremental2H · 6M · 5L2026-07-07 01:05current