|
| 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. |
0 commit comments