Skip to content
120 changes: 114 additions & 6 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3849,6 +3849,13 @@ Brand:
type: string
languageCode:
type: string
modelIds:
type: array
items:
type: string
description: >-
AI model (LLM) ids to attach to this market's project at
activation. Optional; omitted when none were chosen.
imsOrgId:
description: The IMS Organization ID of the brand
$ref: '#/ImsOrganizationId'
Expand Down Expand Up @@ -7388,6 +7395,47 @@ V2BrandCompetitor:
description: ISO-3166 country codes
example: ['US']

V2PendingSemrushProvisioning:
type: [object, 'null']
description: >-
Deferred Semrush provisioning data for a pending (draft) brand: the primary
URL + initial markets (each with its chosen AI models/LLMs) collected at
"Save as pending" before the brand's sub-workspace + project exist, plus
whether activation should generate topics/prompts. Activation provisions each
market and removes it from the blob, nulling the field once none remain
(failed markets stay for retry). markets may be empty: with a primaryUrl a
single US/EN fallback project is provisioned; with neither a primaryUrl nor a
market a sub-workspace-only brand is provisioned. On a brand response this is
read-only and null for a non-pending brand. On a PATCH it is honored only
while the brand is (and stays) pending — the draft Markets tab appends a
market / edits a market's LLMs — and ignored otherwise.
properties:
primaryUrl:
type: [string, 'null']
generatePrompts:
type: boolean
description: >-
Whether activation generates topics/prompts for the provisioned
project(s). Defaults to false (project created empty). Requires a
primaryUrl (and thus a project) when true.
markets:
type: array
items:
type: object
required:
- market
- languageCode
properties:
market:
type: string
languageCode:
type: string
modelIds:
type: array
items:
type: string
description: AI model (LLM) ids to attach to this market at activation.

V2Brand:
type: object
description: A brand managed via the v2 PostgREST-backed API
Expand Down Expand Up @@ -7463,6 +7511,13 @@ V2Brand:
type: string
format: uuid
description: IDs of sites linked to this brand (read-only, derived from brand_sites)
pendingSemrushProvisioning:
allOf:
- $ref: '#/V2PendingSemrushProvisioning'
readOnly: true
description: >-
Read-only deferred Semrush provisioning data for a pending (draft)
brand. Null for a non-pending brand. See V2PendingSemrushProvisioning.
updatedAt:
$ref: '#/DateTime'
updatedBy:
Expand Down Expand Up @@ -7624,6 +7679,13 @@ V2BrandUpdateInput:
type: array
items:
$ref: '#/V2BrandCompetitor'
pendingSemrushProvisioning:
allOf:
- $ref: '#/V2PendingSemrushProvisioning'
description: >-
Deferred Semrush provisioning data. Honored only while the brand is (and
stays) pending — the draft Markets tab appends a market / edits a
market's LLMs; silently ignored on a non-pending brand.

V2BrandListResponse:
type: object
Expand Down Expand Up @@ -10666,6 +10728,15 @@ SerenityMarket:
semrushProjectId:
type: string
description: Upstream Semrush AIO project id for this slice (sub-workspace mode only).
domain:
description: |
The market's own domain (its primary URL host, e.g. "example.com").
Each market has its own primary URL/domain — the brand itself has none.
Additive, sub-workspace mode only. Surfaced so the market overview can
display it. Null when the upstream project carries no domain.
oneOf:
- type: string
- type: 'null'

SerenityMarketListResponse:
type: object
Expand Down Expand Up @@ -10708,10 +10779,22 @@ SerenityActivateRequest:
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.
Markets are caller-supplied — reactivation re-supplies them (there is no
stored market memory). Sets the brand status to `active` once ≥1 market is live.
required: [brandDomain, brandNames, markets]

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.
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).
brandDomain:
type: string
description: Primary domain for the brand's upstream projects.
Expand Down Expand Up @@ -10745,6 +10828,15 @@ SerenityActivateRequest:
name:
type: string
description: Optional display name for this market's upstream project.
modelIds:
type: array
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).

SerenityActivateResponse:
type: object
Expand All @@ -10757,8 +10849,24 @@ SerenityActivateResponse:
type: string
enum: [active, pending]
description: |
`active` when at least one market went live; `pending` when none did
(the response is HTTP 207 in that case). Mirrors the persisted brand status.
All-or-nothing. `active` (HTTP 200) only when the FULL provisioning chain
succeeded — sub-workspace ensured, every market's project live, the brand
linked to its sub-workspace, AND every project mirrored as a Site
(`brand_sites` type=`serenity`). Otherwise a pending (draft) brand stays
`pending` and the response is HTTP 502 (incomplete activation; body carries
`error`/`message` and the per-market outcomes — retry to converge). Mirrors
the persisted brand status.
error:
type: string
description: |
Present only on an incomplete activation (HTTP 502, `status: pending`): a
stable token identifying the failure class (e.g. `serenityActivationIncomplete`).
message:
type: string
description: |
Present only on an incomplete activation (HTTP 502): a human-readable
reason naming the failed step (markets vs. site link). Never leaks the
upstream gateway URL.
markets:
type: array
description: Per-market outcome, in request order.
Expand All @@ -10772,7 +10880,7 @@ SerenityActivateResponse:
type: string
status:
type: integer
description: Per-market HTTP-style status (201 live, 409 already-live, 4xx validation).
description: Per-market HTTP-style status (201 live, 409 already-live, 4xx/5xx failure).
body:
type: object
additionalProperties: true
Expand Down
39 changes: 31 additions & 8 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -474,10 +474,26 @@ v2-serenity-activate:
it on first call and persisting `brands.semrush_workspace_id`, which flips
the brand into sub-workspace mode), then per supplied market creates-or-resumes a
draft and publishes it once. Markets are caller-supplied (reactivation
re-supplies them — there is no stored market memory). Idempotent on the
workspace; per-market it is 409-safe (an already-live slice is reported,
not duplicated). Returns 200 when ≥1 market went live (brand set `active`),
or 207 when none did (brand stays `pending`).
re-supplies them — there is no stored market memory); a pending (draft)
brand falls back to its stashed `pending_semrush_provisioning`. Idempotent on
the workspace; per-market it is 409-safe (an already-live slice is reported,
not duplicated).

Project creation is gated on a primary URL/domain, NOT on prompt
generation: with no domain the brand is activated sub-workspace-only (no
project, HTTP 200, empty `markets`), anchored solely by its sub-workspace —
the bare "save & continue later" draft. With a domain but no market, a
single US/EN fallback project is provisioned. `generatePrompts` (body, else
stash, else false) decides whether topics/prompts are generated for the
provisioned project(s); it requires a domain, so true with no domain is a 400.

ALL-OR-NOTHING: the brand flips to `active` (HTTP 200) only when the FULL
chain succeeds — sub-workspace ensured, EVERY market's project live, the
brand linked to its sub-workspace, AND every project mirrored as a Site
(`brand_sites` type=`serenity`). If ANY step fails, a pending brand STAYS
pending and the response is HTTP 502 (its stash + workspace pointer are kept
intact; retry converges idempotently). An already-active brand re-supplying
markets is never downgraded (a partial failure is reported as 207).
operationId: activateSerenityBrand
security:
- ims_key: []
Expand All @@ -488,12 +504,14 @@ v2-serenity-activate:
schema: { $ref: './schemas.yaml#/SerenityActivateRequest' }
responses:
'200':
description: At least one market is live; brand set active.
description: Full chain succeeded; brand set `active`. Body lists per-market outcomes.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityActivateResponse' }
'207':
description: No market went live; brand left pending. Body lists per-market outcomes.
description: >-
An already-active brand re-supplied markets and at least one failed; the
brand is NOT downgraded (stays `active`). Body lists per-market outcomes.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityActivateResponse' }
Expand All @@ -508,10 +526,15 @@ v2-serenity-activate:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'502':
description: Upstream returned a non-2xx response.
description: >-
Incomplete activation — a pending (draft) brand's provisioning chain did
not fully complete (a market failed, or markets are live but could not be
linked as Sites). The brand STAYS `pending`; the body carries
`error`/`message` and the per-market outcomes so the caller can retry.
Also returned when an upstream call returns a non-2xx response.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
schema: { $ref: './schemas.yaml#/SerenityActivateResponse' }
'500': { $ref: './responses.yaml#/500' }

v2-serenity-deactivate:
Expand Down
11 changes: 11 additions & 0 deletions docs/serenity.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,17 @@ Ordering (mirrors the create flow in reverse):

The DELETE is **not soft**. The UI must confirm with the user before invoking — the linked upstream project (and all its prompts) is permanently destroyed.

## SpaceCat Site mirroring (`brand_sites`)

For backwards compatibility and integrations, every Semrush market (project) is mirrored as a SpaceCat **Site** on our side. The domain model is the key thing to hold onto:

- A **brand is a shell** with **no domain of its own** — like its Semrush sub-workspace. **Each market has its own primary URL/domain**, and that domain maps to a single Site (global `sites.base_url` uniqueness ⇒ at most one Site per domain). A brand whose markets span distinct domains therefore owns several market Sites.
- 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** (initial market), **activation** (the activated markets' domain), and **market creation** (that market's domain). It is **never auto-deleted** — market deletion leaves the Site and its link in place.
- **Best-effort:** 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). `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`:
Expand Down
Loading
Loading