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
32 changes: 30 additions & 2 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11262,6 +11262,16 @@ SerenityMarket:
oneOf:
- type: string
- type: 'null'
siteId:
description: |
The SpaceCat Site UUID this market is bound to (LLMO-6405 Phase 2) — the
Site whose base_url is the market's domain. Additive; surfaced in BOTH
flat and sub-workspace modes. Null when the market is not (yet) linked to
a Site (a market predating the site-linkage backfill, or one never linked).
oneOf:
- type: string
format: uuid
- type: 'null'

SerenityMarketListResponse:
type: object
Expand Down Expand Up @@ -11437,7 +11447,15 @@ SerenityDeactivateResponse:

SerenityCreateMarketRequest:
type: object
required: [market, languageCode, brandDomain, brandNames]
description: |
Creates a market (a (geoTargetId, languageCode) Semrush project) for a brand.

Supply EITHER `brandDomain` (a raw domain) OR `siteId` (an already-onboarded
SpaceCat Site whose base_url is the market's domain). When `siteId` is given
and `brandDomain` is omitted, the server derives `brandDomain` from the Site
and, in sub-workspace mode, links THAT Site to the market directly (LLMO-6405
Phase 2). At least one of the two is required.
required: [market, languageCode, brandNames]
properties:
name:
type: string
Expand All @@ -11458,7 +11476,17 @@ SerenityCreateMarketRequest:
pattern: '^[a-z]{2,3}(-[a-z]{2,4})?$'
brandDomain:
type: string
description: Brand domain the upstream should track (e.g. adobe.com).
description: |
Brand domain the upstream should track (e.g. adobe.com). Optional when
`siteId` is supplied — the server derives it from the Site's base_url.
Required when `siteId` is absent.
siteId:
type: string
format: uuid
description: |
SpaceCat Site UUID to bind this market to (LLMO-6405 Phase 2). When
provided, the server derives `brandDomain` from the Site and links the
Site to the market. Supply this OR `brandDomain`.
brandNames:
type: array
minItems: 1
Expand Down
8 changes: 6 additions & 2 deletions docs/serenity.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ All endpoints require `Authorization: Bearer <ims_user_token>` and `organization
| PATCH | `/serenity/prompts/:semrushPromptId` | Update a prompt; body carries slice + text + a non-empty `tagIds`. A `tags` (name-based) key is rejected with 400. | `updateSerenityPrompt` |
| POST | `/serenity/prompts/bulk-delete` | Delete prompts; body is `{ prompts: [{semrushPromptId, geoTargetId, languageCode}] }` | `bulkDeleteSerenityPrompts` |
| GET | `/serenity/markets` | List markets configured for the brand (incl. live `status`) | `listSerenityMarkets` |
| POST | `/serenity/markets` | Onboard a new (brand, geoTargetId, languageCode) slice | `createSerenityMarket` |
| POST | `/serenity/markets` | Onboard a new (brand, geoTargetId, languageCode) slice (accepts `siteId` in place of `brandDomain`) | `createSerenityMarket` |
| DELETE | `/serenity/markets/:geoTargetId/:languageCode` | Remove a slice (idempotent; upstream-first, DB-second) | `deleteSerenityMarket` |
| GET | `/serenity/tags?geoTargetId=&languageCode=` | Unique tag names for one slice. Add `parentId` (present, even empty) to switch to the nested-tree read instead: `parentId=''` returns root categories with `childrenCount`, `parentId=<tagId>` returns that root's children with a `path` breadcrumb. | `listSerenityTags` |
| POST | `/serenity/tags` | Create/resolve a tag on one slice; body is `{ type, name, geoTargetId, languageCode, parentId? }`. `name` is always BARE — a `:` is rejected, as is a reserved dimension-root name. `type` names the dimension the value belongs to: `category` (open — customer-authored at any depth; `parentId` must be the `category` root or one of its descendants, and defaults to the root) or `intent`/`source`/`type` (closed — `name` must match the fixed enum, `parentId` is not allowed, resolve-before-create is idempotent, and the response is `200 { ..., created }` not `201`). | `createSerenityTag` |
Expand Down Expand Up @@ -181,6 +181,8 @@ Two **org-level** catalogue routes are brand-independent (prefixed with `/v2/org

If step 5 or 6 fails, no row is written and the caller may safely retry with the same body. The 409 gate catches the case where a previous attempt succeeded both upstream calls but failed the DB write — extremely unlikely in practice; covered by the integration tests in `test/it/`.

**`brandDomain` OR `siteId` (LLMO-6405 Phase 2).** A market created from an already-onboarded URL can send `siteId` (the SpaceCat Site UUID) instead of a raw `brandDomain`. The server derives the Semrush project domain from that Site's `base_url` (`resolveSiteDomain`, same hostname normalization as every other brand→domain derivation; an unresolvable `siteId` is a 400). At least one of the two is required. In sub-workspace mode a supplied `siteId` also makes the post-201 mirror link **that** Site directly (skipping the domain→Site find-or-create — see below); the linked `siteId` then surfaces on the market DTO (`GET /serenity/markets[/:slice]`, both modes). The flat handler self-derives (it holds `dataAccess.Site`); the sub-workspace handler relies on the controller (its `dataAccess` is narrowed). When `siteId` is absent, behavior is byte-for-byte unchanged.

## Delete-market semantics

`DELETE /serenity/markets/:geoTargetId/:languageCode` removes a slice from the brand. Upstream support was verified 2026-05-28 against `adobe-hackathon.semrush.com`:
Expand All @@ -203,6 +205,8 @@ 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.

**Orphaned-site cleanup (LLMO-6405 R12).** Each delete handler captures the removed market's linked `siteId` (from the mapping row, before the row is removed/tombstoned) and returns it. The controller then reference-counts the brand's remaining LIVE mapping rows: when **zero** remaining markets point at that `siteId` **and** it is not the brand's primary site (`brands.site_id`), the `brand_sites` `type='serenity'` link for it is deleted (`unlinkMarketSiteIfOrphaned`) so a site that no longer backs any market is not left orphaned. The **Site entity itself is never deleted**; only the link is removed. Best-effort: any failure (including an unresolvable primary-site lookup, which fail-safes to *skip* the unlink) is logged under `SERENITY_MARKET_UNLINK_FAILED` and never fails the 204. The brand's primary site is never unlinked here.

## 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:
Expand All @@ -211,7 +215,7 @@ 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 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).
- **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. When a market create supplies a **`siteId`** (LLMO-6405 Phase 2), `ensureMarketSite` links **that** Site directly (skipping the domain→Site find-or-create) and the linked `siteId` is written onto the market's mapping row (`linkSiteToLiveRows`). The Site is **never auto-deleted** — but market **deletion now removes the `brand_sites` link** when the deleted market was the last live one on that (non-primary) site (R12; see Delete-market semantics), leaving the Site itself 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)
Expand Down
Loading
Loading