Skip to content

Commit 334d592

Browse files
hjen_adobeclaude
andcommitted
feat(elements): add brand-presence market-tracking-trends endpoint
Add GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends, a Semrush Elements wrapper returning weekly per-competitor mentions + citations for the brand-presence-sr-ui Competitor Comparison chart (CitationsMentionsTrendChart). Mentions come from TRENDS_MV (b5281393); citations from the market-tracking citations element (2e5a6f4e, new MARKET_CITATIONS_TREND constant). Both are weekly line elements that return one series per market participant keyed by legend=name, so competitors arrive natively as tracked benchmarks with no fan-out. The contract was ground-truthed against the live Brand Presence MFE. See docs/elements/market-tracking-trends-plan.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c1ab31 commit 334d592

13 files changed

Lines changed: 877 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
Bucketing is **weekly only** for now (`auto_bucketing: "week"` is hardcoded). Day/month is a
40+
trivial future extension via a `granularity` param.
41+
42+
### Response shape
43+
44+
```json
45+
{
46+
"weeklyTrends": [
47+
{
48+
"week": "2026-07-05",
49+
"weekNumber": 27,
50+
"year": 2026,
51+
"mentions": 900,
52+
"citations": 5000,
53+
"competitors": [
54+
{ "name": "Rival One", "mentions": 150, "citations": 300 },
55+
{ "name": "Rival Two", "mentions": 120, "citations": 250 }
56+
]
57+
}
58+
]
59+
}
60+
```
61+
62+
- One entry per ISO week (`week` = the element's week-start `x`, `YYYY-MM-DD`), sorted ascending.
63+
- `mentions`/`citations` are the **tracked brand's** own totals that week; each competitor's
64+
totals sit in `competitors[]` (sorted by mentions desc).
65+
- Visibility is intentionally **not** included (out of scope for this chart).
66+
67+
### Error responses
68+
69+
| Status | `error` token | Cause |
70+
|---|---|---|
71+
| 400 | `invalidRequest` | `brandId` not a UUID; malformed/out-of-order dates; `siteId` doesn't belong to `:brandId` |
72+
| 401 | `authenticationRequired` | Missing/invalid bearer (non-IMS caller with no `x-promise-token`) |
73+
| 403 | `forbidden` | Caller lacks org access |
74+
| 404 | `notFound` | Org/brand not found; brand has no Semrush workspace; `regionCode` matches no market |
75+
| 502 | `elementsUpstreamError` | Non-auth Semrush upstream failure |
76+
| 500 | `internalServerError` | Unexpected error |
77+
78+
---
79+
80+
## 1. Element mapping (confirmed against live payloads)
81+
82+
Both elements are `type: "line"`, `auto_bucketing: "week"`, with **no brand filter** — that is
83+
what makes the response include one series per tracked competitor (competitors are tracked as
84+
Semrush benchmarks). Each `blocks.lines[]` row is `{ legend, x, y__mentions, … }` where
85+
`legend` is the brand/competitor **name** and `x` is the ISO week-start date.
86+
87+
| Chart metric | Element | Constant | Project filter col | Value field |
88+
|---|---|---|---|---|
89+
| Mentions | `b5281393-ee98-4c38-9ed5-3437b0c450c3` | `TRENDS_MV` (pre-existing) | `CBF_project` (singular) | `y__mentions` |
90+
| Citations | `2e5a6f4e-f287-4951-a7e2-7e29981c86d8` | `MARKET_CITATIONS_TREND` (new) | `CBF_projects` (**plural**) | `y__mentions` |
91+
92+
> ⚠️ **`y__mentions` means different things per element.** In `TRENDS_MV` it is the mention
93+
> count; in `MARKET_CITATIONS_TREND` the *same key* carries the **citation** count (Semrush
94+
> reuses the generic field name). The transform maps `TRENDS_MV.y__mentions → mentions` and
95+
> `MARKET_CITATIONS_TREND.y__mentions → citations`. (The citations element also returns
96+
> `y__prompts_with_mentions` and `y__visibility`, which power the MFE's sibling "Responses with
97+
> Citations" / "Source Visibility" sub-tabs — unused here.)
98+
99+
### Confirmed request payload (both elements)
100+
101+
```json
102+
{
103+
"auto_bucketing": "week",
104+
"filters": {
105+
"simple": { "start_date": "2026-06-16", "end_date": "2026-07-15" },
106+
"advanced": { "op": "and", "filters": [
107+
{ "op": "or", "filters": [{ "op": "eq", "val": "search-gpt", "col": "CBF_model" }] },
108+
{ "op": "or", "filters": [
109+
{ "op": "eq", "val": "<projectId1>", "col": "CBF_project" },
110+
{ "op": "eq", "val": "<projectId2>", "col": "CBF_project" }
111+
] }
112+
] }
113+
}
114+
}
115+
```
116+
117+
Citations differs only in the project column name (`CBF_projects`). Note: unlike the KPI/URL
118+
elements, these trend elements take plain `start_date`/`end_date` in `simple` (not
119+
`CBF_date__start`/`end`) and send **no** `comparison_data_formatting`.
120+
121+
---
122+
123+
## 2. Codebase changes
124+
125+
- **`src/support/elements/element-ids.js`** — new `MARKET_CITATIONS_TREND` constant.
126+
- **`src/support/elements/definitions/market-tracking-trends.js`** (new) —
127+
`buildMarketMentionsTrendPayload` (`CBF_project`), `buildMarketCitationsTrendPayload`
128+
(`CBF_projects`), and `transformMarketTrackingTrends(mentionsRaw, citationsRaw, brandName)`
129+
which groups both `blocks.lines[]` by ISO week, splits the brand line
130+
(`legend === brandName`, case-insensitive) from competitor lines, and defaults a missing
131+
metric to 0. Exported from `definitions/index.js`.
132+
- **`src/support/elements/elements-service.js`**`getMarketTrackingTrends(workspaceId, {...})`
133+
fetches the two elements in parallel and returns `{ weeklyTrends }`. No per-week fan-out (the
134+
elements are already weekly-bucketed) and no per-competitor fan-out (`legend` carries them).
135+
`projectId` (single region) takes precedence over `projectIds` (aggregate all projects).
136+
- **`src/controllers/elements.js`**`getMarketTrackingTrends` handler (mirrors the URL
137+
Inspector handlers: `authorizeOrg`, `siteId` cross-check, optional-date validation with a
138+
28-day default via `defaultTrailingDateRange`, region→`projectId` resolution else all projects,
139+
`cachedOk`).
140+
- **`src/routes/index.js`**, **`src/routes/facs-capabilities.js`** (`llmo/can_view`),
141+
**`src/routes/required-capabilities.js`** (`brand:read`) — route registration + classification.
142+
143+
No new element UUIDs beyond `MARKET_CITATIONS_TREND`; `brandId` is already a classified LLMO
144+
FACS brand resource.
145+
146+
## 3. Tests
147+
148+
- `test/support/elements/definitions/market-tracking-trends.test.js` — payload builders
149+
(singular vs plural project col, no-projectIds branch, model fallback) + transform
150+
(brand/competitor split, week grouping, missing-metric → 0, case-insensitive brand match,
151+
malformed-line skipping).
152+
- `test/support/elements/elements-service.test.js``getMarketTrackingTrends` (both element
153+
calls, merge, `projectId` precedence, error propagation).
154+
- `test/controllers/elements.test.js` — auth reuse, date defaulting/validation, region
155+
resolution + 404, `siteId` cross-check, error mapping.
156+
- `test/routes/index.test.js` — route registration.
157+
158+
## 4. Out of scope
159+
160+
UI wiring; Competitor Summary / Share of Voice / Sentiment endpoints; visibility; day/month
161+
bucketing.

src/controllers/elements.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ function splitCsv(value) {
144144
return value.split(',').map((v) => v.trim()).filter((v) => v.length > 0);
145145
}
146146

147+
/**
148+
* Default 28-day trailing `{ startDate, endDate }` (YYYY-MM-DD), used by endpoints
149+
* whose date range is optional (e.g. market-tracking-trends) when the caller omits it.
150+
*/
151+
function defaultTrailingDateRange() {
152+
const end = new Date();
153+
const start = new Date();
154+
start.setDate(start.getDate() - 28);
155+
return {
156+
startDate: start.toISOString().slice(0, 10),
157+
endDate: end.toISOString().slice(0, 10),
158+
};
159+
}
160+
147161
/**
148162
* Extracts and validates the IMS bearer token from the inbound Authorization header.
149163
* Throws 401 if missing or if the caller authenticated via a non-IMS mechanism.
@@ -739,12 +753,101 @@ export default function ElementsController(context, log, env) {
739753
};
740754
/* c8 ignore stop */
741755

756+
/**
757+
* GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends
758+
* Weekly per-competitor mentions + citations for the Competitor Comparison chart on
759+
* the brand-presence-sr-ui dashboard. Backed by two weekly `line` elements (TRENDS_MV
760+
* for mentions, MARKET_CITATIONS_TREND for citations); competitors come back natively
761+
* as tracked-benchmark legends, so no competitor list is needed as input. See
762+
* docs/elements/market-tracking-trends-plan.md.
763+
*
764+
* Query params (all optional): `startDate`/`start_date` + `endDate`/`end_date`
765+
* (default: 28-day trailing window), `model`/`platform` (default search-gpt),
766+
* `regionCode`/`region_code`/`region` (a single region → its one Semrush project;
767+
* `all`/absent → aggregate across every project the brand owns), `siteId`/`site_id`
768+
* (cross-check only — must belong to `:brandId`).
769+
*/
770+
const getMarketTrackingTrends = async (ctx) => {
771+
try {
772+
const auth = await authorizeOrg(ctx);
773+
if (auth.error) {
774+
return auth.error;
775+
}
776+
const { spaceCatId, brandId } = ctx?.params ?? {};
777+
const { workspaceId, brand } = auth;
778+
const query = extractQuery(ctx);
779+
780+
// The path already names the brand; a siteId filter is only honored when it
781+
// belongs to that brand (mirrors listWeeks / listCitedDomains).
782+
const siteId = query.siteId || query.site_id;
783+
if (hasText(siteId)) {
784+
const postgrestClient = ctx?.dataAccess?.services?.postgrestClient;
785+
const resolved = await getBrandBySite(spaceCatId, siteId, postgrestClient, log);
786+
if (!resolved || resolved.id !== brand.id) {
787+
return badRequest('siteId does not belong to the specified brand');
788+
}
789+
}
790+
791+
// Date range is optional (defaults to a 28-day trailing window); when a value is
792+
// sent it must be a valid YYYY-MM-DD and correctly ordered.
793+
const startDate = query.startDate || query.start_date;
794+
const endDate = query.endDate || query.end_date;
795+
if (hasText(startDate) && !isYmdDate(startDate)) {
796+
return badRequest('startDate must be a valid YYYY-MM-DD date');
797+
}
798+
if (hasText(endDate) && !isYmdDate(endDate)) {
799+
return badRequest('endDate must be a valid YYYY-MM-DD date');
800+
}
801+
if (hasText(startDate) && hasText(endDate) && startDate > endDate) {
802+
return badRequest('startDate must not be after endDate');
803+
}
804+
const defaultRange = defaultTrailingDateRange();
805+
806+
const service = await buildService(ctx);
807+
const { BrandSemrushProject } = ctx?.dataAccess ?? {};
808+
const brandSemrushProjects = await fetchBrandSemrushProjects(BrandSemrushProject, [brand]);
809+
810+
// A single selected region+language → its one Semrush projectId. region=all/absent
811+
// → every project the brand owns (the payload builder ORs them into one call, so
812+
// neither path fans out).
813+
let projectId;
814+
let projectIds;
815+
const region = query.regionCode || query.region_code || query.region;
816+
if (hasText(region) && region.toLowerCase() !== 'all') {
817+
projectId = await service.resolveRegionProjectId(workspaceId, {
818+
brandId, region, brandSemrushProjects,
819+
});
820+
if (!hasText(projectId)) {
821+
return notFound(`No Semrush market found for region: ${region}`);
822+
}
823+
} else {
824+
projectIds = brandSemrushProjects
825+
.map((p) => p.semrushProjectId)
826+
.filter(hasText);
827+
}
828+
829+
const result = await service.getMarketTrackingTrends(workspaceId, {
830+
model: query.model,
831+
platform: query.platform,
832+
startDate: startDate || defaultRange.startDate,
833+
endDate: endDate || defaultRange.endDate,
834+
projectId,
835+
projectIds,
836+
brandName: brand.name,
837+
});
838+
return cachedOk(result);
839+
} catch (e) {
840+
return mapError(e, log);
841+
}
842+
};
843+
742844
return {
743845
listUrlInspectorFilterDimensions,
744846
listWeeks,
745847
listPrompts,
746848
listCitedDomains,
747849
listOwnedUrls,
748850
listDomainUrls,
851+
getMarketTrackingTrends,
749852
};
750853
}

src/routes/facs-capabilities.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,7 @@ const routeFacsCapabilities = {
814814
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'llmo/can_view',
815815
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'llmo/can_view',
816816
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'llmo/can_view',
817+
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends': 'llmo/can_view',
817818
'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': 'llmo/can_view',
818819
// Preflight (site-scoped reads)
819820
'GET /sites/:siteId/preflights': 'llmo/can_view',

src/routes/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export default function getRouteHandlers(
254254
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': elementsController.listCitedDomains,
255255
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': elementsController.listOwnedUrls,
256256
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': elementsController.listDomainUrls,
257+
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends': elementsController.getMarketTrackingTrends,
257258
// Brand-independent Semrush language catalog (add-brand wizard language picker).
258259
'GET /v2/orgs/:spaceCatId/serenity/languages': serenityController.listOrgLanguages,
259260
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': serenityController.activate,

src/routes/required-capabilities.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ const routeRequiredCapabilities = {
310310
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'brand:read',
311311
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'brand:read',
312312
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'brand:read',
313+
'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/market-tracking-trends': 'brand:read',
313314
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'organization:write',
314315
'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'organization:write',
315316
'GET /v2/orgs/:spaceCatId/sites/:siteId/brand': 'organization:read',

src/support/elements/definitions/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,8 @@ export {
2929
transformOwnedUrlsResponse,
3030
} from './owned-urls.js';
3131
export { buildDomainUrlsPayload, transformDomainUrlsResponse } from './domain-urls.js';
32+
export {
33+
buildMarketMentionsTrendPayload,
34+
buildMarketCitationsTrendPayload,
35+
transformMarketTrackingTrends,
36+
} from './market-tracking-trends.js';

0 commit comments

Comments
 (0)