fix: adds base site id to brand creation#2169
Conversation
irenelagno
left a comment
There was a problem hiding this comment.
Cross-reviewed end-to-end with the UI, DRS, and DB PRs. Direction is right — repurposing site_id, immutable + unique per org, force-pend without a base site. But there are three things I'd fix before merge, plus one cross-cutting concern that belongs in this PR.
1. The onboarding seeder bypasses this PR's force-pend check
src/controllers/llmo/llmo-onboarding.js:1360-1372 calls upsertBrand directly from the storage layer, skipping the createBrand controller and therefore skipping the new if (!baseSiteId) status = 'pending' logic added here. It passes status: 'active' but not baseSiteId. Net effect post-rollout: every newly onboarded brand is in the state status='active', site_id=NULL, which this PR says should be impossible.
Fix: add baseSiteId: site.getId() to the seed payload (site.getId() is already in scope 4 lines above in a log statement). See also the layering concern on brands.js below.
2. Uniqueness check has a self-collision on re-POST
See line comment on src/support/brands-storage.js. This interacts with fix #1 — making the seeder pass baseSiteId is unsafe until this is fixed, because onboarding is idempotent.
3. Uniqueness is not backed by the DB
Pair this with a partial unique index in adobe/mysticat-data-service#329 so the SELECT-then-INSERT race window goes away and the JS check can simplify (or be removed entirely in favor of catching the unique-violation error).
Downstream consequence worth knowing
The UI PR adobe/project-elmo-ui#1479 gates its editable Primary URL ComboBox on isPending && !hasBaseUrl. Combined with fact #1 above, every seeded brand is active-without-baseSiteId, and the UI provides no user-reachable way to fix it. Either this PR fixes the seeder (preferred) or the UI PR needs a wider edit gate or a backfill script.
Good news for DRS
I traced the DRS brand presence analyzer (spacecat_client.build_brand_config_from_v2, runner._get_brand_config_v2, brand_analyzer._check_citation, analyze_with_llm) and it does not read baseSiteId or baseUrl anywhere. Zero regression risk on brand presence analysis from this PR set.
— Review based on cross-repo analysis; happy to discuss any of the above.
| .map((r) => (typeof r === 'string' ? r : String(r))).filter(hasText); | ||
|
|
||
| // Validate baseSiteId uniqueness within the organization if provided. | ||
| if (hasText(brand.baseSiteId)) { |
There was a problem hiding this comment.
Self-collision bug. upsertBrand uses onConflict: 'organization_id,name', so a re-POST of the same brand is a legitimate update path. In that path this pre-check finds the brand's own row and returns 409 against itself.
Fix: look up the existing brand id by (organization_id, name) first and add .neq('id', existingId) to this pre-check. Or drop the JS check entirely once the partial unique index lands in adobe/mysticat-data-service#329 and just catch the unique-violation error.
There was a problem hiding this comment.
Good catch — removed the JS pre-check SELECT entirely. Uniqueness is now enforced solely by the DB constraint (brands_base_site_unique), which correctly handles upserts on the same row without self-collision.
| .eq('organization_id', organizationId) | ||
| .eq('site_id', brand.baseSiteId) | ||
| .neq('status', 'deleted') | ||
| .maybeSingle(); |
There was a problem hiding this comment.
.maybeSingle() throws if the DB ever has >1 row matching — which is exactly the invariant this PR is trying to enforce but currently isn't backed by a DB constraint. Defensive choice: use .limit(1) until the partial unique index exists in the DB.
There was a problem hiding this comment.
Agreed — removed the pre-check queries that used .maybeSingle() on a non-unique column in both upsertBrand and updateBrand. The DB constraint handles uniqueness enforcement now instead.
| const updatedBy = context.attributes?.authInfo?.profile?.email || 'system'; | ||
|
|
||
| // A brand cannot be active without a base site ID. | ||
| if (!hasText(brandData.baseSiteId)) { |
There was a problem hiding this comment.
This invariant lives in the wrong layer. The HTTP controller is only one of the upsertBrand call sites. The llmo onboarding handler in src/controllers/llmo/llmo-onboarding.js:1360 imports upsertBrand from src/support/brands-storage.js directly and bypasses this force-pend check entirely. Net effect: the seeded brand is created as active with site_id=NULL, violating the PR's stated invariant.
Consider moving this check into upsertBrand itself in brands-storage.js so it applies uniformly. Otherwise it's enforcement-by-coincidence — any new internal seed path silently reintroduces the bug.
There was a problem hiding this comment.
Fixed both parts:
- Moved the force-pend logic into
upsertBrandinbrands-storage.jsso it applies uniformly to all callers. - Added
baseSiteId: site.getId()to the onboarding seeder payload —siteis already in scope so seeded brands now correctly get a base site and stayactive.
|
This PR will trigger no release when merged. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Cross-PR review notes (from walking through DRS #1267 / UI #1479 / mysticat #329 together). Three things I'd want resolved before merge: 1.
|
|
Thanks for the thorough cross-PR review! 1.
|
## [1.436.6](v1.436.5...v1.436.6) (2026-04-13) ### Bug Fixes * adds base site id to brand creation ([#2169](#2169)) ([b72ade9](b72ade9))
|
🎉 This PR is included in version 1.436.6 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
LLMO-4172: Add baseSiteId to brand API
Summary
baseSiteIdandbaseUrlfields to the brand API response (resolved via PostgREST join onbrands.site_id → sites)baseSiteIdon brand creation (POST) — resolves tobrands.site_idbaseSiteIdcannot be changed (can still be set from NULL)status = 'pending'when creating a brand withoutbaseSiteIdbaseUrlfrom PATCH payloadsTest plan
baseSiteId→ response includesbaseSiteId+baseUrlbaseSiteId→ created aspendingbaseSiteId→ 409baseSiteIdwith a different value → silently ignoredbaseSiteIdto set it → succeedsbaseSiteIdandbaseUrl