Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 27 additions & 19 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7857,6 +7857,14 @@ V2BrandInput:
type: array
items:
$ref: '#/V2BrandCompetitor'
baseSiteId:
type: string
format: uuid
description: >-
Id of the brand's primary SpaceCat Site — an existing onboarded site or one
onboarded inline. Written to `brands.site_id` as the brand's primary-URL
anchor. Sent on every create (a Serenity brand is anchored by BOTH its
sub-workspace and this primary Site).

V2BrandUpdateInput:
type: object
Expand Down Expand Up @@ -11280,25 +11288,27 @@ SerenityMarketDetail:
SerenityActivateRequest:
type: object
description: |
Activates a brand into sub-workspace mode: ensures the brand's own
Semrush sub-workspace exists (creating + resourcing it on first call),
then per supplied market creates-or-resumes a draft and publishes it once.

All fields are optional. A pending (draft) brand activated from the wizard
sends an EMPTY body `{}` and every value is read from its stashed
`pending_semrush_provisioning` (primary URL, markets, generatePrompts). A
reactivation of an already-live brand re-supplies them in the body (the body
wins when present; there is no stored market memory otherwise). When no
primary URL/domain is available at all, a sub-workspace-only brand is
activated (no project). With a domain but no market, a single US/EN fallback
project is provisioned.
Activates a brand into sub-workspace mode.

A PENDING (draft) brand activates SUB-WORKSPACE-ONLY: it ensures the brand's
own Semrush sub-workspace and flips the brand active, with NO project. The
wizard's approve sends an EMPTY body `{}`; any `markets`/`brandDomain` (or the
legacy `pending_semrush_provisioning` stash) are IGNORED for provisioning. The
brand is anchored by its primary site (`brands.site_id`, set at create), and
markets are added afterwards from the Markets tab.

An ALREADY-ACTIVE brand (reactivation / onboarding API) provisions the markets
supplied in the body: per market it creates-or-resumes a draft project and
publishes it once. With a `brandDomain` but no `markets`, a single US/EN
fallback project is provisioned. An active brand is never downgraded on a
partial failure (207 Multi-Status). All fields are optional.
properties:
generatePrompts:
type: boolean
description: >-
Whether to generate topics/prompts for the provisioned project(s).
Overrides the stashed value; defaults to the stash (else false). Requires
a primary URL/domain (a project) — true with no domain is rejected (400).
Whether to generate topics/prompts for a supplied market's project
(already-active brand only). Ignored for a pending brand, which activates
sub-workspace-only with no project.
brandDomain:
type: string
description: Primary domain for the brand's upstream projects.
Expand Down Expand Up @@ -11337,10 +11347,8 @@ SerenityActivateRequest:
items:
type: string
description: >-
Optional AI model (LLM) ids to attach to this market's project.
When omitted here, the markets stashed on the pending brand supply
them (a draft activated from the wizard carries per-market LLMs in
pending_semrush_provisioning).
Optional AI model (LLM) ids to attach to this market's project
(already-active brand reactivation).

SerenityActivateResponse:
type: object
Expand Down
72 changes: 38 additions & 34 deletions docs/serenity.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,14 @@ them before a workspace (or the flag) exists.
The same helper gates the Semrush **side-effects** on the v2 brand endpoints
(`src/controllers/brands.js`), so an inactive org operates as plain backend CRUD:

- `POST /v2/orgs/:org/brands` — a **Semrush-mode** create (`semrushMarket` /
`generatePrompts`, i.e. one that would provision a sub-workspace) is rejected
with `403 Serenity is not active for this organization` while the flag is off.
A plain (flat) create is unaffected.
- `POST /v2/orgs/:org/brands` — **Semrush mode is decided by the org serenity flag,
not the request body** (LLMO-6405). In a serenity-active org every brand create is
a Semrush create: it provisions the brand's sub-workspace — a **bare** sub-workspace
when no market is supplied (markets are added later from the Markets tab), or the
initial project when a `semrushMarket` is supplied. While the flag is **off**, a
create that carries a `semrushMarket` or `generatePrompts: true` is rejected with
`403 Serenity is not active for this organization`; a plain (flat) create —
including a bare `generatePrompts: false` — is unaffected.
- `PATCH /v2/orgs/:org/brands/:brandId` — an edit that would **re-sync to
Semrush** (URL / competitor / alias change on a brand that has a
`semrush_workspace_id`) is rejected `403` while the flag is off, before the
Expand Down Expand Up @@ -148,7 +152,7 @@ All endpoints require `Authorization: Bearer <ims_user_token>` and `organization
| PATCH | `/serenity/tags/:tagId` | Rename and/or re-parent a tag by its upstream id. `name` is a bare value. `parentId`: an id RE-PARENTS within the tag's own dimension, omitted preserves the current parent, and an explicit `null` is rejected — the root level is reserved for the four dimension roots. The new parent may be neither the tag itself nor one of its descendants (400): upstream stores a parent pointer rather than a tree and would accept the edge, leaving the tag's subtree reachable from no root, and so unreachable and unrepairable through this API. The proxy always re-sends a parent upstream, because a PATCH that omits one promotes the tag to a root. A dimension root (400), a closed dimension's value (400), and an unknown id (404 `tagNotFound`) are all refused. | `updateSerenityTag` |
| GET | `/serenity/models?geoTargetId=&languageCode=` | AI models for one slice (catalog mode when no params) | `listSerenityModels` |
| PUT | `/serenity/models` | Replace the AI-model set for one slice (publishes after change) | `updateSerenityModels` |
| POST | `/serenity/activate` | Activate the brand into sub-workspace mode (ensure sub-workspace + publish supplied markets) | `activateSerenityBrand` |
| POST | `/serenity/activate` | Activate into sub-workspace mode. A **pending** brand activates sub-workspace-only (ensure sub-workspace + flip active, no markets); an already-**active** brand's body-supplied markets are provisioned (reactivation) | `activateSerenityBrand` |
| POST | `/serenity/deactivate` | Deactivate: decommission the sub-workspace + disconnect the brand back to flat mode | `deactivateSerenityBrand` |

The above are prefixed with `/v2/orgs/:spaceCatId/brands/:brandId`.
Expand Down Expand Up @@ -207,51 +211,51 @@ For backwards compatibility and integrations, every Semrush market (project) is
- The Site is linked to the owning brand via a **`brand_sites` row tagged `type='serenity'`** (`src/support/serenity/site-linkage.js` → `ensureMarketSite`; the marker names the owning feature, not the provider). The marker is load-bearing:
- **`syncBrandSites` preserves it.** That function rebuilds `brand_sites` from `brand.urls` on every brand edit (delete-all-then-reinsert). A market's domain is generally **not** in `brand.urls`, so an unmarked row would be silently deleted on the next edit. The marker excludes these rows from the delete and keeps their type from being downgraded on re-upsert.
- **`mapDbBrandToV2` excludes it.** A market's domain is not a brand URL, so `type='serenity'` rows never surface in the brand V2 response (`urls[]` / `siteIds`). Integrations resolve them via the `sites` / `brand_sites` tables directly.
- **Lifecycle:** the Site (+ link) is ensured on **brand creation** (the provisioned domain), **activation** (the activated markets' domain), and **market creation in sub-workspace mode** (that market's domain — `ensureMarketSite` runs only on the `subworkspace` branch of `POST /serenity/markets`). A **flat-mode** brand is **not** mirrored on market creation; it gets a Site only once activation promotes it to sub-workspace mode. The Site is **never auto-deleted** — market deletion leaves the Site and its link in place. A market-mirror site's **`baseURL` is immutable at the API**: a `PATCH /sites` that changes it is rejected (the domain is the Semrush project anchor; `isSemrushMarketMirrorSite` gates the guard).
- **Best-effort, except on activation:** on the brand-create and market-create paths the Semrush project is the primary outcome and has already succeeded when mirroring runs, so a Site/link failure is logged and swallowed (never fails a live market). On **activation** the link is instead a **required** step — if the markets are live but the mirror fails, the brand stays `pending` and returns 502 (see Activate below). `Site.create` uses `deliveryType: 'other'` (not an AEM target).
- **Lifecycle:** the market Site (+ link) is ensured wherever a **market** is provisioned — a **brand create that supplies a market** (that market's domain), an **already-active brand's activation** (the activated markets' domain), and **market creation in sub-workspace mode** (that market's domain — `ensureMarketSite` runs only on the `subworkspace` branch of `POST /serenity/markets`). A **bare** brand create (no market — the LLMO-6405 default) and a **pending** brand's sub-workspace-only activation mirror **no** market Site; the brand's own primary site is recorded as `brands.site_id` from the selected `baseSiteId` at create (a Semrush brand is anchored by BOTH its sub-workspace and its primary Site). A **flat-mode** brand is **not** mirrored on market creation. The Site is **never auto-deleted** — market deletion leaves the Site and its link in place. A market-mirror site's **`baseURL` is immutable at the API**: a `PATCH /sites` that changes it is rejected (the domain is the Semrush project anchor; `isSemrushMarketMirrorSite` gates the guard).
- **Best-effort, except on active-brand activation:** on the brand-create and market-create paths the Semrush project is the primary outcome and has already succeeded when mirroring runs, so a Site/link failure is logged and swallowed (never fails a live market). On an **already-active brand's activation** (reactivation) the site mirror is part of the all-or-nothing success gate — if the markets are live but the mirror fails, the brand is not marked fully-succeeded and returns **207** (it stays `active`, never downgraded; see Activate below). `Site.create` uses `deliveryType: 'other'` (not an AEM target).

## Activate / deactivate (sub-workspace dual-mode)

A brand runs in one of two modes, decided entirely by `brands.semrush_workspace_id`:
- **flat** (pointer NULL): markets resolve through the shared org parent workspace via the `BrandSemrushProject` mapping.
- **subworkspace** (pointer set): the brand has its own Semrush sub-workspace; markets resolve live from it via `listProjects`.

`POST /serenity/activate` moves a brand into sub-workspace mode. Two shapes:
`POST /serenity/activate` moves a brand into sub-workspace mode. There are two
paths, keyed on the brand's current status (LLMO-6405):

- **Sub-workspace-only (no resolved `brandDomain`):** a brand with no primary URL
has nothing to provision a project against, so activate only ensures the
sub-workspace (which IS the active-brand anchor) and flips the brand active —
**HTTP 200 with an empty `markets[]`**, no project, no Site mirror. The user
adds markets later from the Markets tab. `generatePrompts` is rejected here
(no project to attach prompts to). NOTE: this is the only path that does NOT
require `brandDomain` — see the resolution rules below.
- **Project activation (resolved `brandDomain`):**
**Pending brand → sub-workspace-only.** Activating a *pending* (draft) brand ONLY
ensures its Semrush sub-workspace (the active-brand anchor), clears any legacy
provisioning stash, and flips the brand `active` — **HTTP 200 with an empty
`markets[]`**, no project, no Site mirror. Markets are Semrush projects the user
adds afterwards from the Markets tab (the wizard's approve sends an empty body).
Any `markets` / `brandDomain` / stash on a pending brand is **ignored for
provisioning** — the brand is anchored by its primary site (`brands.site_id`, set at
create). If the sub-workspace is ensured upstream but the `active` flip fails to
persist, the brand stays `pending` and returns **502 `serenityActivationIncomplete`**
(idempotent to retry — the sub-workspace 409s on the next attempt).

**Already-active brand → body-driven market provisioning (reactivation / onboarding
API).** For a brand that is already `active`, the body's markets are provisioned:

```
1. ensure the sub-workspace ONCE for the whole batch (create + settle, or re-grant)
2. for each market (resolved below; empty -> a single US/en fallback project):
create-or-resume a draft project, attach models + generated topic prompts +
brand URLs + competitor benchmarks, then publish
2. for each market (from the body; empty + a resolved brandDomain -> one US/en
fallback project): create-or-resume a draft project, attach models + generated
topic prompts + brand URLs + competitor benchmarks, then publish
3. mirror every live market as a Site + brand_sites row (type='serenity')
4. ALL-OR-NOTHING: set brands.status = 'active' ONLY when EVERY market is live
AND the brand_sites mirror succeeded. If any step fails, a pending brand
STAYS pending (stash + workspace pointer left intact for an idempotent retry).
4. the brand is NEVER downgraded — a partial failure returns 207 Multi-Status
while the brand stays active.
```

All markets in one activate batch share the single resolved `brandDomain`, so
they collapse to one Site mirror; the all-or-nothing site-link guarantee is
therefore satisfied by that one mirror. (Distinct-domain markets under one brand
are not produced by this path today; if that changes, step 4 must require a
All markets in one activate batch share the single resolved `brandDomain`, so they
collapse to one Site mirror. (Distinct-domain markets under one brand are not
produced by this path today; if that changes, the site-link step must require a
linked Site per distinct market domain.)

- Body: `{ brandDomain?, brandNames?, brandDisplayName?, markets?: [{ market, languageCode, name? }] }`. **All body fields are optional** (the schema no longer marks any required): a pending-draft activation can send an empty body and resolve everything from the stash (see below). `markets` is **capped at 50** (400 above that) — each market is a sequential upstream create+publish.
- **Deferred-provisioning fallback (pending/draft brands).** `markets` and `brandDomain` are optional in the body: a brand saved via the wizard's "Save as pending" path carries its intended market(s) and primary URL in `brands.pending_semrush_provisioning`. When the body omits them, activate falls back to the stash — `markets` come from `pendingSemrushProvisioning.markets`, and `brandDomain` is derived from `pendingSemrushProvisioning.primaryUrl` (hostname only, via the shared `hostnameFromUrlString`). The body always wins when both are present. Resolution rules after the fallback:
- **No URL/domain supplied at all** (neither body nor stash): the brand activates **sub-workspace-only** (HTTP 200, empty `markets[]`, no project) — see the sub-workspace-only shape above. This is NOT a 400.
- **A URL/domain was supplied but did not resolve to a hostname**: 400 (`brandDomain is required to provision a Semrush market`) — a typo must not silently strand the user with a project-less brand.
- **`markets` empty after resolution** (but `brandDomain` resolved): a single `US`/`en` fallback project is provisioned (matching the direct-create default). There is no longer a 400 for empty `markets`.
- **Stash lifecycle.** The deferred-provisioning stash is cleared (column set to `null`) **only on a fully-succeeded activation** (every market live AND the brand_sites mirror linked), saved atomically with the `active` flip. If any market fails — or every market is live but the site mirror fails — a pending brand STAYS pending and its stash (with the primaryUrl) is left intact for an idempotent retry; live markets return 409 `sliceExists` on the retry and the site-link + stash-clear re-run.
- Response: **200** when fully succeeded (pending brand flips to `active`). **502 `serenityActivationIncomplete`** when a *pending* brand's activation did not complete every step — any market failed, OR all markets are live but the `brand_sites` mirror failed; the brand stays `pending` and `markets[]` carries the per-market outcomes for a retry. **207 Multi-Status** applies ONLY to an *already-active* brand re-supplying markets where at least one fails — the brand is never downgraded and stays `active`.
- Idempotent: a market already live upstream returns 409 `sliceExists` and still counts as live, so a full re-activate of an already-live brand is a 200.
- Body: `{ brandDomain?, brandNames?, brandDisplayName?, markets?: [{ market, languageCode, name? }] }`. **All body fields are optional.** A pending brand's approve sends an empty body (→ sub-workspace-only). For an active brand, `markets` is **capped at 50** (400 above that); an empty `markets` with a resolved `brandDomain` provisions one `US`/`en` fallback project; a body that resolves no markets and no `brandDomain` is a no-op re-ensure.
- **No stash-driven provisioning (LLMO-6405).** A pending brand activates sub-workspace-only regardless of `brands.pending_semrush_provisioning`; the stash is no longer read to drive market provisioning (it is only *cleared* on that path). The column is slated for removal once existing drafts drain (serenity-docs post-GA cleanup).
- Response: **200** — a pending brand's sub-workspace-only activation (flips to `active`), or a fully-succeeded active reactivation. **502 `serenityActivationIncomplete`** — a pending brand whose sub-workspace ensured upstream but whose `active` flip did not persist (stays `pending`, idempotent retry). **207 Multi-Status** — an *already-active* brand re-supplying markets where ≥1 fails; never downgraded, stays `active`.
- Idempotent: a market already live upstream returns 409 `sliceExists` and still counts as live, so a full re-activate of an already-live active brand is a 200.

`POST /serenity/deactivate` moves a brand back to flat mode:

Expand Down
Loading
Loading