Skip to content

Commit 6425368

Browse files
jjenscodeehjen_adobeclaude
authored
feat(elements): add brand-presence market-tracking-trends endpoint (#2827)
### New endpoint: `GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends` ## Summary Adds a Semrush **Elements**-backed endpoint returning **weekly per-competitor mentions + citations** for the tracked brand and its competitors — the data behind the **Competitor Comparison** chart (`CitationsMentionsTrendChart`) on the `brand-presence-sr-ui` dashboard. Full design notes, the confirmed Semrush payload/response shapes, and resolved decisions are in `docs/elements/market-tracking-trends-plan.md` (modeled on `brand-presence-stats-plan.md` from #2823). All brand names and metrics in the docs/tests are synthetic. > The element mapping was verified by observing the Brand Presence MFE's "Market Tracking" chart request/response shapes (Mentions and Citations tabs) — not inferred from the migration wiki, whose documented citations trend element (`b81af644`, domain-level) is *not* what this chart uses. ## Element mapping Both are `type: "line"`, `auto_bucketing: "week"`, **no brand filter** (so competitors come back natively as tracked-benchmark `legend`s): | Chart metric | Element | Constant | Project filter col | Value field | |---|---|---|---|---| | Mentions | `b5281393` | `TRENDS_MV` (existing) | `CBF_project` (singular) | `y__mentions` | | Citations | `2e5a6f4e` | `MARKET_CITATIONS_TREND` (**new**) | `CBF_projects` (**plural**) | `y__mentions` | ⚠️ Both elements expose the value under the key `y__mentions`; in the citations element that number is the **citation** count (Semrush reuses the generic key). The transform maps each to the right metric. ## What changed - **Route + auth scaffold** (`routes/index.js`, `routes/facs-capabilities.js` → `llmo/can_view`, `routes/required-capabilities.js` → `brand:read`): registers the route under the existing brand-scoped `authorizeOrg` pattern; `brandId` reuses the existing LLMO FACS brand alias. - **Controller** `getMarketTrackingTrends` (`controllers/elements.js`): mirrors the URL-inspector handlers — `authorizeOrg`, `siteId` cross-check, optional dates with a 28-day trailing default + validation, region→`projectId` resolution (else aggregate across all the brand's projects), `cachedOk`. - **Definitions** (`support/elements/definitions/market-tracking-trends.js`, new): weekly payload builders (`CBF_project` vs `CBF_projects`, no brand filter, no `comparison_data_formatting`) + `transformMarketTrackingTrends` — groups both `blocks.lines[]` by ISO week, splits the brand line from competitor lines, defaults a missing metric to 0. - **Service** `getMarketTrackingTrends` (`support/elements/elements-service.js`): fetches the two elements in parallel; no per-week fan-out (elements are pre-bucketed) and no per-competitor fan-out (`legend` carries them). `projectId` (single region) takes precedence over `projectIds` (aggregate). - **Element id** (`support/elements/element-ids.js`): new `MARKET_CITATIONS_TREND`. - **Design doc** (`docs/elements/market-tracking-trends-plan.md`, new). ## Response shape ```json { "weeklyTrends": [ { "week": "2026-07-05", "weekNumber": 27, "year": 2026, "mentions": 900, "citations": 5000, "competitors": [ { "name": "Rival One", "mentions": 150, "citations": 300 } ] } ] } ``` ## Testing - New/added unit suites: definitions (payload builders + transform), service (`getMarketTrackingTrends`), controller (auth, date defaulting/validation, region resolution + 404, `siteId` cross-check, error mapping), and route registration. - Route-param FACS classification + required-capabilities drift suites pass. - Weekly-only bucketing for now; visibility intentionally excluded (out of scope for this chart). ## Out of scope UI wiring (DAL service + hook + `SRCompetitorComparisonSection`); Competitor Summary / Share of Voice / Sentiment endpoints; day/month bucketing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: hjen_adobe <hjen@adobe.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ba37ab5 commit 6425368

14 files changed

Lines changed: 704 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# Implementation Plan — `GET .../brand-presence/market-tracking-trends`
2+
3+
> Status: **Implemented.**
4+
> Endpoint: `GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends`
5+
>
6+
> Companion reference: [`semrush-elements-api-reference.md`](./semrush-elements-api-reference.md)
7+
> — read that first for the shared conventions (transport, definitions, service layer)
8+
> this plan builds on.
9+
10+
Powers the **Competitor Comparison** chart (`CitationsMentionsTrendChart`) on the
11+
`brand-presence-sr-ui` dashboard: a weekly time series of mentions and citations for the
12+
tracked brand and each of its competitors.
13+
14+
> **Verified against the Brand Presence MFE.** The element mapping below was derived by
15+
> observing the request/response shapes of the Semrush micro-frontend's "Market Tracking"
16+
> chart (Mentions and Citations tabs) — not inferred from the migration wiki. The wiki's
17+
> documented citations trend element (`b81af644`, domain-level) is **not** what this chart
18+
> uses. All brand names and metrics in the examples below are synthetic.
19+
20+
---
21+
22+
## 0. API Reference
23+
24+
`GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends`
25+
26+
Auth: same as every other Elements-wrapper route (`authorizeOrg` — IMS/promise-token, org +
27+
brand access, resolves the brand's Semrush workspace). FACS `llmo/can_view`; S2S `brand:read`.
28+
29+
### Query parameters (all optional)
30+
31+
| Param | Aliases | Default | Description |
32+
|---|---|---|---|
33+
| `startDate` | `start_date` | 28 days before today | Range start, `YYYY-MM-DD`. 400 if not a valid calendar date. |
34+
| `endDate` | `end_date` | today | Range end, `YYYY-MM-DD`. 400 if invalid, or before `startDate`. |
35+
| `model` | `platform` | `search-gpt` (via `resolveElementModel`) | AI model/platform filter. Unrecognized values fall back to the default. |
36+
| `regionCode` | `region_code`, `region` || One region+language code (e.g. `US`) → a single Semrush `projectId` via `resolveRegionProjectId`; 404 if it matches no market. `all`/absent → aggregate across **every** project the brand owns. |
37+
| `siteId` | `site_id` || Cross-check only: must resolve (via `getBrandBySite`) to the same brand as `:brandId`; 400 otherwise. Does not itself narrow the query. |
38+
39+
**Both or neither** (mirrors the URL-inspector handlers): if only one of `startDate`/`endDate`
40+
is supplied, both are ignored and the full 28-day default range is used as a unit — a
41+
half-supplied bound never pairs with the default's other half. The resolved span is capped at
42+
**`MAX_RANGE_DAYS = 366`** (400 otherwise) to bound upstream fan-out.
43+
44+
Bucketing is **weekly only** for now (`auto_bucketing: "week"` is hardcoded). Day/month is a
45+
trivial future extension via a `granularity` param.
46+
47+
### Response shape
48+
49+
```json
50+
{
51+
"weeklyTrends": [
52+
{
53+
"week": "2026-07-05",
54+
"weekNumber": 27,
55+
"year": 2026,
56+
"mentions": 900,
57+
"citations": 5000,
58+
"competitors": [
59+
{ "name": "Rival One", "mentions": 150, "citations": 300 },
60+
{ "name": "Rival Two", "mentions": 120, "citations": 250 }
61+
]
62+
}
63+
]
64+
}
65+
```
66+
67+
- One entry per ISO week (`week` = the element's week-start `x`, `YYYY-MM-DD`), sorted ascending.
68+
- `mentions`/`citations` are the **tracked brand's** own totals that week; each competitor's
69+
totals sit in `competitors[]` (sorted by mentions desc).
70+
- Visibility is intentionally **not** included (out of scope for this chart).
71+
72+
### Error responses
73+
74+
| Status | `error` token | Cause |
75+
|---|---|---|
76+
| 400 | `invalidRequest` | `brandId` not a UUID; malformed/out-of-order dates; `siteId` doesn't belong to `:brandId` |
77+
| 401 | `authenticationRequired` | Missing/invalid bearer (non-IMS caller with no `x-promise-token`) |
78+
| 403 | `forbidden` | Caller lacks org access |
79+
| 404 | `notFound` | Org/brand not found; brand has no Semrush workspace; `regionCode` matches no market |
80+
| 502 | `elementsUpstreamError` | Non-auth Semrush upstream failure |
81+
| 500 | `internalServerError` | Unexpected error |
82+
83+
---
84+
85+
## 1. Element mapping (confirmed against live payloads)
86+
87+
Both elements are `type: "line"`, `auto_bucketing: "week"`, with **no brand filter** — that is
88+
what makes the response include one series per tracked competitor (competitors are tracked as
89+
Semrush benchmarks). Each `blocks.lines[]` row is `{ legend, x, y__mentions, … }` where
90+
`legend` is the brand/competitor **name** and `x` is the ISO week-start date.
91+
92+
| Chart metric | Element | Constant | Project filter col | Value field |
93+
|---|---|---|---|---|
94+
| Mentions | `b5281393-ee98-4c38-9ed5-3437b0c450c3` | `TRENDS_MV` (pre-existing) | `CBF_project` (singular) | `y__mentions` |
95+
| Citations | `2e5a6f4e-f287-4951-a7e2-7e29981c86d8` | `MARKET_CITATIONS_TREND` (new) | `CBF_projects` (**plural**) | `y__mentions` |
96+
97+
> ⚠️ **`y__mentions` means different things per element.** In `TRENDS_MV` it is the mention
98+
> count; in `MARKET_CITATIONS_TREND` the *same key* carries the **citation** count (Semrush
99+
> reuses the generic field name). The transform maps `TRENDS_MV.y__mentions → mentions` and
100+
> `MARKET_CITATIONS_TREND.y__mentions → citations`. (The citations element also returns
101+
> `y__prompts_with_mentions` and `y__visibility`, which power the MFE's sibling "Responses with
102+
> Citations" / "Source Visibility" sub-tabs — unused here.)
103+
104+
### Confirmed request payload (both elements)
105+
106+
```json
107+
{
108+
"auto_bucketing": "week",
109+
"filters": {
110+
"simple": { "start_date": "2026-06-16", "end_date": "2026-07-15" },
111+
"advanced": { "op": "and", "filters": [
112+
{ "op": "or", "filters": [{ "op": "eq", "val": "search-gpt", "col": "CBF_model" }] },
113+
{ "op": "or", "filters": [
114+
{ "op": "eq", "val": "<projectId1>", "col": "CBF_project" },
115+
{ "op": "eq", "val": "<projectId2>", "col": "CBF_project" }
116+
] }
117+
] }
118+
}
119+
}
120+
```
121+
122+
Citations differs only in the project column name (`CBF_projects`). Note: unlike the KPI/URL
123+
elements, these trend elements take plain `start_date`/`end_date` in `simple` (not
124+
`CBF_date__start`/`end`) and send **no** `comparison_data_formatting`.
125+
126+
---
127+
128+
## 2. Codebase changes
129+
130+
- **`src/support/elements/element-ids.js`** — new `MARKET_CITATIONS_TREND` constant.
131+
- **`src/support/elements/definitions/market-tracking-trends.js`** (new) —
132+
`buildMarketMentionsTrendPayload` (`CBF_project`), `buildMarketCitationsTrendPayload`
133+
(`CBF_projects`), and `transformMarketTrackingTrends(mentionsRaw, citationsRaw, brandName)`
134+
which groups both `blocks.lines[]` by ISO week, splits the brand line
135+
(`legend === brandName`, case-insensitive) from competitor lines, and defaults a missing
136+
metric to 0. Exported from `definitions/index.js`.
137+
- **`src/support/elements/elements-service.js`**`getMarketTrackingTrends(workspaceId, {...})`
138+
fetches the two elements in parallel and returns `{ weeklyTrends }`. No per-week fan-out (the
139+
elements are already weekly-bucketed) and no per-competitor fan-out (`legend` carries them).
140+
`projectId` (single region) takes precedence over `projectIds` (aggregate all projects).
141+
- **`src/controllers/elements.js`**`getMarketTrackingTrends` handler (mirrors the URL
142+
Inspector handlers: `authorizeOrg`, `siteId` cross-check, optional-date validation with a
143+
28-day default via `defaultTrailingDateRange`, region→`projectId` resolution else all projects,
144+
`cachedOk`).
145+
- **`src/routes/index.js`**, **`src/routes/facs-capabilities.js`** (`llmo/can_view`),
146+
**`src/routes/required-capabilities.js`** (`brand:read`) — route registration + classification.
147+
148+
No new element UUIDs beyond `MARKET_CITATIONS_TREND`; `brandId` is already a classified LLMO
149+
FACS brand resource.
150+
151+
## 3. Tests
152+
153+
- `test/support/elements/definitions/market-tracking-trends.test.js` — payload builders
154+
(singular vs plural project col, no-projectIds branch, model fallback) + transform
155+
(brand/competitor split, week grouping, missing-metric → 0, case-insensitive brand match,
156+
malformed-line skipping).
157+
- `test/support/elements/elements-service.test.js``getMarketTrackingTrends` (both element
158+
calls, merge, `projectId` precedence, error propagation).
159+
- `test/controllers/elements.test.js` — auth reuse, date defaulting/validation, region
160+
resolution + 404, `siteId` cross-check, error mapping.
161+
- `test/routes/index.test.js` — route registration.
162+
163+
## 4. Out of scope
164+
165+
UI wiring; Competitor Summary / Share of Voice / Sentiment endpoints; visibility; day/month
166+
bucketing.

docs/openapi/api.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ paths:
176176
$ref: './serenity-api.yaml#/v2-serenity-deactivate'
177177
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/url-inspector/filter-dimensions:
178178
$ref: './serenity-api.yaml#/v2-serenity-url-inspector-filter-dimensions'
179+
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/market-tracking-trends:
180+
$ref: './serenity-api.yaml#/v2-serenity-market-tracking-trends'
179181
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/sentiment-overview:
180182
$ref: './serenity-api.yaml#/v2-serenity-brand-presence-sentiment-overview'
181183
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/stats:

docs/openapi/schemas.yaml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11717,6 +11717,50 @@ SerenityUrlInspectorFilterDimensions:
1171711717
description: Tags grouped by a Semrush tag prefix not covered by the fixed dimensions above.
1171811718
items: { $ref: '#/SerenityFilterDimensionItem' }
1171911719

11720+
SerenityMarketTrackingTrends:
11721+
type: object
11722+
description: |
11723+
Weekly Market Tracking Trends for the Serenity (Semrush Elements-backed)
11724+
Brand Presence Competitor Comparison chart. Each entry is one ISO week; the
11725+
tracked brand's own totals are at the top level and every tracked competitor
11726+
(a Semrush benchmark) is an entry in `competitors`. Mentions come from the
11727+
Trends Mentions/Visibility element and citations from the Market Tracking
11728+
citations element; both are already weekly-bucketed by Semrush.
11729+
required: [weeklyTrends]
11730+
properties:
11731+
weeklyTrends:
11732+
type: array
11733+
description: One entry per ISO week, sorted ascending by `week`.
11734+
items:
11735+
type: object
11736+
required: [week, weekNumber, year, mentions, citations, competitors]
11737+
properties:
11738+
week:
11739+
type: string
11740+
format: date
11741+
description: ISO week-start date (`YYYY-MM-DD`), from the Semrush bucket's `x`.
11742+
weekNumber:
11743+
type: integer
11744+
description: ISO week number (1–53).
11745+
year:
11746+
type: integer
11747+
description: ISO week-numbering year.
11748+
mentions:
11749+
type: integer
11750+
description: The tracked brand's own mentions in this week.
11751+
citations:
11752+
type: integer
11753+
description: The tracked brand's own citations in this week.
11754+
competitors:
11755+
type: array
11756+
description: Per-competitor mentions + citations for this week, sorted by mentions desc.
11757+
items:
11758+
type: object
11759+
required: [name, mentions, citations]
11760+
properties:
11761+
name: { type: string }
11762+
mentions: { type: integer }
11763+
citations: { type: integer }
1172011764
SerenityBrandPresenceStatsValues:
1172111765
type: object
1172211766
description: The four aggregated Brand Presence KPI values, shared by the top-level `stats` and each `trends[].data.stats` entry.

docs/openapi/serenity-api.yaml

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1404,3 +1404,121 @@ v2-serenity-brand-presence-stats:
14041404
application/json:
14051405
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
14061406
'500': { $ref: './responses.yaml#/500' }
1407+
1408+
v2-serenity-market-tracking-trends:
1409+
parameters:
1410+
- name: spaceCatId
1411+
in: path
1412+
required: true
1413+
description: SpaceCat Organization ID (UUID)
1414+
schema:
1415+
type: string
1416+
format: uuid
1417+
- name: brandId
1418+
in: path
1419+
required: true
1420+
description: Brand ID (UUID-only on the /serenity/* surface)
1421+
schema:
1422+
type: string
1423+
format: uuid
1424+
get:
1425+
tags: [serenity]
1426+
summary: Weekly Market Tracking Trends for the Brand Presence Competitor Comparison chart
1427+
description: |
1428+
Returns a weekly time series of mentions and citations for the tracked
1429+
brand and each of its competitors, sourced from two Semrush Elements
1430+
(Trends Mentions/Visibility + Market Tracking citations) — not from Adobe
1431+
Postgres. Competitors are the brand's tracked Semrush benchmarks; they are
1432+
returned automatically (no competitor list input). Each entry is one ISO
1433+
week; the brand's own totals are at the top level and each competitor is an
1434+
entry in `competitors`. See `SerenityMarketTrackingTrends`.
1435+
1436+
Date range is optional and defaults to a 28-day trailing window. Both
1437+
`startDate` and `endDate` must be supplied together or not at all — a
1438+
half-supplied range falls back to the full default as a unit. The resolved
1439+
span is capped at 366 days (400 otherwise).
1440+
operationId: listSerenityMarketTrackingTrends
1441+
security:
1442+
- ims_key: []
1443+
parameters:
1444+
- name: startDate
1445+
in: query
1446+
required: false
1447+
description: |
1448+
Range start (`YYYY-MM-DD`). Alias `start_date`. Defaults to 28 days
1449+
before today. Must be supplied together with `endDate` (else both are
1450+
ignored and the default range is used).
1451+
schema: { type: string, format: date }
1452+
- name: endDate
1453+
in: query
1454+
required: false
1455+
description: Range end (`YYYY-MM-DD`). Alias `end_date`. Defaults to today.
1456+
schema: { type: string, format: date }
1457+
- name: model
1458+
in: query
1459+
required: false
1460+
description: |
1461+
AI model to scope the trends to. Accepts a Semrush Elements model name
1462+
directly, or a SpaceCat/UI platform code (translated server-side).
1463+
Defaults to `search-gpt` when omitted or unrecognized.
1464+
schema:
1465+
type: string
1466+
x-canonical-values:
1467+
- google-ai-mode
1468+
- grok-3
1469+
- google-ai-overview
1470+
- microsoft-copilot
1471+
- open-evidence
1472+
- gemini-2.5-flash
1473+
- claude-sonnet-4
1474+
- gpt-5
1475+
- deepseek
1476+
- search-gpt
1477+
- perplexity
1478+
x-known-aliases:
1479+
copilot: microsoft-copilot
1480+
gemini: gemini-2.5-flash
1481+
openai: gpt-5
1482+
chatgpt: search-gpt
1483+
- name: platform
1484+
in: query
1485+
required: false
1486+
description: Legacy alias for `model`; `model` takes precedence when both are given.
1487+
schema: { type: string }
1488+
- name: regionCode
1489+
in: query
1490+
required: false
1491+
description: |
1492+
One region+language code (e.g. `US`), resolved to a single Semrush
1493+
project. Aliases `region_code`, `region`. `all` or absent aggregates
1494+
across every project the brand owns.
1495+
schema: { type: string }
1496+
- name: siteId
1497+
in: query
1498+
required: false
1499+
description: |
1500+
Optional cross-check: must resolve to the same brand as `:brandId`
1501+
(400 otherwise). Alias `site_id`. Does not itself narrow the query.
1502+
schema: { type: string }
1503+
responses:
1504+
'200':
1505+
description: Weekly market tracking trends retrieved.
1506+
content:
1507+
application/json:
1508+
schema: { $ref: './schemas.yaml#/SerenityMarketTrackingTrends' }
1509+
'400': { $ref: './responses.yaml#/400' }
1510+
'403':
1511+
description: Caller lacks access to the organization.
1512+
'404':
1513+
description: Organization not found, brand not found for this organization, the brand has no resolvable Semrush workspace, or the requested region matches no market.
1514+
'502':
1515+
description: Upstream returned a non-2xx response.
1516+
content:
1517+
application/json:
1518+
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
1519+
'503':
1520+
description: PostgREST client not configured for this deployment.
1521+
content:
1522+
application/json:
1523+
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
1524+
'500': { $ref: './responses.yaml#/500' }

0 commit comments

Comments
 (0)