feat(serenity): defer Semrush provisioning for pending (draft) brands#2635
Conversation
Lets the add-brand wizard "Save as pending" store a Semrush brand with no
sub-workspace, no project, and no primary URL, deferring all provisioning to
activation.
- createBrandForOrg: when status is pending, skip provisioning and stash the
chosen { primaryUrl, markets:[{market,languageCode}] } on the brand instead
of requiring a primary URL + AI models. The row lands as pending (no anchor).
- brands-storage: persist/clear pending_provisioning, surface it on reads.
- activate: fall back to the stash when the request omits markets/brandDomain,
then remove each provisioned market (nulling the column once none remain;
failed markets stay with the primary URL for retry).
Requires the brands.pending_provisioning column (mysticat-data-service migration
20260618120000) and the brand pendingProvisioning attribute from
spacecat-shared-data-access; both must ship before this is exercised in a
deployed env.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the mysticat-data-service column + spacecat-shared attribute rename (clearer than the generic pending_provisioning). Renames the column refs, the brand field, the API response field, and the normalize helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Add two activate-flow tests for the deferred-provisioning brandDomain fallback: a stash with markets but no primaryUrl (empty-input guard) and a stash whose primaryUrl is unparseable (new URL() throws). Both assert brandDomain resolves to null and activation still proceeds, closing the 4 patch-coverage line gaps codecov flagged on serenity.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Request changes - one authorization gap and two code-quality concerns need addressing before merge.
Changes: Defers Semrush provisioning for pending (draft) brands by stashing market + primaryUrl at create time and replaying at activation (7 files).
Must fix before merge
- [Critical]
pendingSemrushProvisioningwritable via PATCH without runtime enforcement -src/support/brands-storage.js:878(details inline) - [Important] Null
brandDomainsilently passed tohandleCreateMarketSubworkspace-src/controllers/serenity.js:802(details inline) - [Important]
domainFromUrlduplicatesbrandDomainFromPayloadcreating divergence risk -src/controllers/serenity.js:85(details inline) - [Important] Stale
docs/serenity.mdno longer reflects activate endpoint's fallback behavior - the doc describesmarketsas required in the activate body, which is no longer true for pending brands. Update the "activate / deactivate" section to document the new fallback path and stash-cleanup lifecycle.
Non-blocking (3): minor issues and suggestions
- nit: OpenAPI schema
pendingSemrushProvisioning.marketsitems lackrequired: [market, languageCode]constraint -docs/openapi/schemas.yaml:3844 - suggestion: Stash cleanup is coupled to the
anyLive && setStatusguard. Self-heals via 409 idempotency on retry, but a comment explaining why the coupling is acceptable would help future readers -src/controllers/serenity.js:904 - suggestion: Create path hardcodes single-market stash (
markets: [{ market, languageCode }]) while activate handles N markets. If the wizard evolves to multi-market draft creation, this asymmetry becomes a bug. A TODO comment would flag the constraint -src/controllers/brands.js:1410
Note: CI checks are passing.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 4m 35s | Cost: $6.95 | Commit: e25ae05aac4cc554ae59e7e53a91d4fb904b14ff
If this code review was useful, please react with 👍. Otherwise, react with 👎.
| if (updates.pendingSemrushProvisioning !== undefined) { | ||
| patch.pending_semrush_provisioning = normalizePendingSemrushProvisioning( | ||
| updates.pendingSemrushProvisioning, | ||
| ); |
There was a problem hiding this comment.
issue (blocking): pendingSemrushProvisioning is writable via the PATCH /brands/:brandId endpoint. The OpenAPI schema marks this field readOnly: true, but that is a documentation hint only - there is no runtime enforcement. The updateBrandForOrg controller passes context.data into updateBrand as updates, and the new code here persists updates.pendingSemrushProvisioning if present.
Why it matters: This field is system-controlled (set during "Save as pending", consumed and cleared during activation). An authenticated user can inject arbitrary primaryUrl and markets into any brand they can PATCH. At activation without explicit body parameters, the stashed values are trusted - primaryUrl flows into brandDomain for upstream Semrush project creation, and markets drive which markets get provisioned.
Fix: Strip the field in updateBrandForOrg before passing to updateBrand:
delete updates.pendingSemrushProvisioning;| // the stashed draft primary URL (the wizard's "Save as pending" URL). | ||
| const brandDomain = hasText(body.brandDomain) | ||
| ? body.brandDomain | ||
| : domainFromUrl(pendingSemrushProvisioning?.primaryUrl); |
There was a problem hiding this comment.
issue (blocking): brandDomain can resolve to null here (stash has no primaryUrl, or URL is unparseable) and that null propagates into handleCreateMarketSubworkspace. The non-pending create path in brands.js explicitly guards this with if (!hasText(brandDomain)) return badRequest(...). Activation should fail fast with the same discipline.
Why it matters: A silent null can produce an opaque upstream error or an orphaned sub-workspace rather than a clear 400 to the caller.
Fix: After line 802, add:
if (!hasText(brandDomain)) {
throw new ErrorWithStatusCode('brandDomain is required to provision a Semrush market', 400);
}| * primary URL resolves to the same domain at activation as it would at create. | ||
| * Returns null for empty/unparseable input. | ||
| */ | ||
| function domainFromUrl(value) { |
There was a problem hiding this comment.
issue (blocking): domainFromUrl reimplements the hostname-extraction logic from brandDomainFromPayload in src/controllers/brands.js. The JSDoc acknowledges "Mirrors brandDomainFromPayload" - but code duplication IS the divergence mechanism. When someone adds www. stripping or punycode normalization to one site and not the other, a draft brand will resolve to a different domain at activation than at direct create.
Why it matters: Two implementations of the same domain derivation that must agree by contract but share no code.
Fix: Extract the URL-string-to-hostname kernel into a shared utility (e.g., src/support/url-utils.js) and import from both controllers.
MysticatBot review on the pending-brands PR: - [Critical] Strip system-controlled `pendingSemrushProvisioning` in `updateBrandForOrg` before it reaches `updateBrand`. The field is readOnly in OpenAPI only (no runtime teeth); without this an authenticated PATCH could inject a primaryUrl/markets that activation later trusts for Semrush provisioning. Mirrors the existing `delete updates.baseUrl`. - [Important] Fail fast (400) at activate when `brandDomain` resolves to null (no body brandDomain and an absent/unparseable stashed primaryUrl), matching the create path's `hasText(brandDomain)` guard, instead of propagating null into handleCreateMarketSubworkspace. - [Important] De-duplicate domain derivation: extract the URL→hostname kernel into `src/support/url-utils.js` (`hostnameFromUrlString`) and call it from both `brandDomainFromPayload` (brands) and the serenity activate fallback, removing the divergence risk between the two copies. - [Important] Update `docs/serenity.md` activate section: brandDomain + markets are now optional (stash fallback) with the per-market stash cleanup lifecycle documented. - Nits: OpenAPI `markets` items now `required: [market, languageCode]`; comment explaining the stash-cleanup/anyLive coupling; TODO flagging the single-market draft stash vs N-market activate asymmetry. Tests: rewrote the two null-brandDomain activate cases to assert the new 400; added url-utils unit coverage and a PATCH-strip test. 459 pass; serenity/brands/url-utils at 100% line coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all 7 items in Must-fix
Non-blocking
459 tests pass; serenity/brands/url-utils at 100% line coverage;
|
There was a problem hiding this comment.
Hey @rainer-friederich,
Verdict: Approve - all 7 prior findings addressed correctly; no new issues.
Changes: Defers Semrush provisioning for pending (draft) brands by stashing market + primaryUrl at create time and replaying at activation (10 files).
Previously flagged, now resolved
- PATCH injection of
pendingSemrushProvisioning- stripped viadeletebefore reaching storage, with regression test - Null
brandDomainat activate - fail-fast 400 guard added, matching the create path's discipline domainFromUrlduplication - extracted to sharedhostnameFromUrlStringinsrc/support/url-utils.js- Stale
docs/serenity.md- activate section now documents the fallback path and stash lifecycle - OpenAPI
pendingSemrushProvisioning.marketsitems now carryrequired: [market, languageCode] - Stash cleanup coupling comment added explaining idempotent-retry safety
- Single-market stash TODO added flagging the wizard limitation
Note: CI build is pending at review time.
Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 0m 45s | Cost: $5.26 | Commit: 15658257127fbb9d962153990aa281ccff5906b4
If this code review was useful, please react with 👍. Otherwise, react with 👎.
3.79.0 adds the `pendingSemrushProvisioning` Brand attribute, which the serenity activate flow reads/clears atomically via `brand.setPendingSemrushProvisioning(...)` / `brand.save()`. Without this release the ElectroDB model would not project the attribute, so the deferred-provisioning stash could not be consumed on activation. Lockfile updated surgically (version/resolved/integrity + root pin only); 3.79.0's dependency set is byte-identical to 3.78.0's, so no transitive churn. Unrelated `"peer": true` flips that a full `npm install` would emit are pre-existing npm-version drift and were deliberately excluded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# [1.584.0](v1.583.1...v1.584.0) (2026-06-19) ### Features * **serenity:** defer Semrush provisioning for pending (draft) brands ([#2635](#2635)) ([74fc1b2](74fc1b2))
|
🎉 This PR is included in version 1.584.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
What
Lets the add-brand wizard's "Save as pending" store a Semrush brand with no sub-workspace, no project, and no primary URL — deferring all Semrush provisioning to activation.
Previously a pending save still 400'd with
A primary URL is required to provision a Semrush brand, becausecreateBrandForOrgbranched only on whethersemrushMarketwas present, never on status. The elmo client already sent{ status: 'pending', semrushMarket }correctly — the fix is server-side.Changes
pending, skip provisioning entirely and stash{ primaryUrl: first url || null, markets:[{market,languageCode}] }on the brand instead of requiring a primary URL + AI models. The row lands aspending(no anchor — no site_id, no semrush_workspace_id). The active path is unchanged (still provisions).pending_semrush_provisioning(upsertBrand,updateBrand,mapDbBrandToV2).markets/brandDomain, then remove each market that provisions (status 201 or 409) from the stash — nulling the column once none remain, keeping failed markets (with the primary URL) for retry. The brand still flips active on any live market.Brandschema documents the read-onlypendingSemrushProvisioningfield.Storage shape
One JSONB column
brands.pending_semrush_provisioning = { primaryUrl?, markets:[{market, languageCode}] }. It folds both the deferred market and the primary URL (which otherwise lives only on the Semrush side) into a single draft-only staging area.Out of scope
AI models for a pending brand are not stored, and
activatedoes not attach models (pre-existing — models go viaPUT /serenity/models). Models chosen during a pending save are dropped.Tests
581 passing in the touched suites (+12 new: pending create paths, storage persist/clear, activate fallback + per-market removal). Lint, OpenAPI validation, and elmo tsc/build all clean.
Dependency ordering
Requires both to ship first:
brands.pending_semrush_provisioning: https://github.com/adobe/mysticat-data-service/pull/710pendingSemrushProvisioningattribute: feat(data-access): add brand pendingSemrushProvisioning attribute spacecat-shared#1692 (bump the dependency once published)Reads are forward-safe before then (base collection selects
*;save()only patches dirty attributes), but the pending-create write needs the column to exist.UI companion (already updated): https://github.com/adobe/project-elmo-ui/pull/2078
🤖 Generated with Claude Code