From 41be8a4e80e493ce8930eddfe2e239aa5d2ebb06 Mon Sep 17 00:00:00 2001 From: Vivek Singh Date: Tue, 14 Jul 2026 14:58:19 +0530 Subject: [PATCH 1/6] initial commit for brand presence stats api --- src/controllers/elements.js | 64 ++++++++++++++++ src/routes/facs-capabilities.js | 1 + src/routes/index.js | 2 + src/routes/required-capabilities.js | 1 + test/controllers/elements.test.js | 110 ++++++++++++++++++++++++++++ test/routes/index.test.js | 1 + 6 files changed, 179 insertions(+) diff --git a/src/controllers/elements.js b/src/controllers/elements.js index b844516f8..535744aca 100644 --- a/src/controllers/elements.js +++ b/src/controllers/elements.js @@ -144,6 +144,22 @@ function splitCsv(value) { return value.split(',').map((v) => v.trim()).filter((v) => v.length > 0); } +/** + * True when the `showTrends`/`show_trends` query param requests trend data, + * mirroring `llmo-brand-presence.js#parseShowTrends`. + */ +function parseShowTrends(q) { + const v = q?.showTrends ?? q?.show_trends; + if (v === true || v === 1) { + return true; + } + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === 'true' || s === '1'; + } + return false; +} + /** * Extracts and validates the IMS bearer token from the inbound Authorization header. * Throws 401 if missing or if the caller authenticated via a non-IMS mechanism. @@ -739,6 +755,53 @@ export default function ElementsController(context, log, env) { }; /* c8 ignore stop */ + /** + * GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats + * Scaffold for the Elements-backed equivalent of the mysticat + * `GET /org/:spaceCatId/brands/:brandId/brand-presence/stats` endpoint (see + * llmo-brand-presence.js#createBrandPresenceStatsHandler): same response shape + * (`{ stats, trends? }`) and the same query params (startDate/endDate, model/ + * platform, showTrends/show_trends, siteId/site_id, categoryId(s), topicIds, + * regionCode(s), origin), but the Elements aggregation itself is not wired yet + * — always returns zeroed stats (and an empty trends array when requested). + */ + const getStats = async (ctx) => { + try { + const auth = await authorizeOrg(ctx); + if (auth.error) { + return auth.error; + } + const { spaceCatId } = ctx?.params ?? {}; + const { brand } = auth; + const query = extractQuery(ctx); + const siteId = query.siteId || query.site_id; + + if (hasText(siteId)) { + const postgrestClient = ctx?.dataAccess?.services?.postgrestClient; + const resolved = await getBrandBySite(spaceCatId, siteId, postgrestClient, log); + if (!resolved || resolved.id !== brand.id) { + return badRequest('siteId does not belong to the specified brand'); + } + } + + const response = { + stats: { + total_executions: 0, + average_visibility_score: 0, + total_mentions: 0, + total_citations: 0, + }, + }; + if (parseShowTrends(query)) { + response.trends = []; + } + + return cachedOk(response); + } catch (e) { + return mapError(e, log); + } + }; + return { listUrlInspectorFilterDimensions, listWeeks, @@ -746,5 +809,6 @@ export default function ElementsController(context, log, env) { listCitedDomains, listOwnedUrls, listDomainUrls, + getStats, }; } diff --git a/src/routes/facs-capabilities.js b/src/routes/facs-capabilities.js index e16b56994..f657b7887 100644 --- a/src/routes/facs-capabilities.js +++ b/src/routes/facs-capabilities.js @@ -813,6 +813,7 @@ const routeFacsCapabilities = { 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'llmo/can_view', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'llmo/can_view', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'llmo/can_view', + 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats': 'llmo/can_view', 'GET /v2/orgs/:spaceCatId/brands/:brandId/prompts/stats': 'llmo/can_view', // Preflight (site-scoped reads) 'GET /sites/:siteId/preflights': 'llmo/can_view', diff --git a/src/routes/index.js b/src/routes/index.js index 5a4ce9737..5de446dd4 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -252,6 +252,8 @@ export default function getRouteHandlers( 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': elementsController.listCitedDomains, 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': elementsController.listOwnedUrls, 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': elementsController.listDomainUrls, + // eslint-disable-next-line max-len + 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats': elementsController.getStats, // Brand-independent Semrush language catalog (add-brand wizard language picker). 'GET /v2/orgs/:spaceCatId/serenity/languages': serenityController.listOrgLanguages, 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': serenityController.activate, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index 498a819ce..bcc8bc6df 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -311,6 +311,7 @@ const routeRequiredCapabilities = { 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains': 'brand:read', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls': 'brand:read', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls': 'brand:read', + 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats': 'organization:read', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate': 'organization:write', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate': 'organization:write', 'GET /v2/orgs/:spaceCatId/sites/:siteId/brand': 'organization:read', diff --git a/test/controllers/elements.test.js b/test/controllers/elements.test.js index 43ebae913..e53a9dd95 100644 --- a/test/controllers/elements.test.js +++ b/test/controllers/elements.test.js @@ -863,6 +863,116 @@ describe('ElementsController', () => { }); }); + // ─── getStats ───────────────────────────────────────────────────────────── + + describe('getStats', () => { + const statsUrl = (qs = '') => `https://api.example.com/v2/orgs/${ORG_ID}` + + `/brands/${BRAND_ID}/serenity/brand-presence/stats${qs}`; + + it('returns 200 with zeroed stats and no trends key by default', async () => { + const ctx = fakeContext({ url: statsUrl() }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + const body = await readBody(res); + expect(body).to.deep.equal({ + stats: { + total_executions: 0, + average_visibility_score: 0, + total_mentions: 0, + total_citations: 0, + }, + }); + }); + + it('includes an empty trends array when showTrends=true', async () => { + const ctx = fakeContext({ url: statsUrl('?showTrends=true') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + const body = await readBody(res); + expect(body.trends).to.deep.equal([]); + }); + + it('includes an empty trends array when show_trends=1 (snake_case alias)', async () => { + const ctx = fakeContext({ url: statsUrl('?show_trends=1') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + const body = await readBody(res); + expect(body.trends).to.deep.equal([]); + }); + + it('omits trends when showTrends=false', async () => { + const ctx = fakeContext({ url: statsUrl('?showTrends=false') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + const body = await readBody(res); + expect(body).to.not.have.property('trends'); + }); + + it('returns 503 (not a masked 404) when the PostgREST client is not available', async () => { + const ctx = fakeContext({ url: statsUrl(), postgrestClient: null }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(503); + const body = await readBody(res); + expect(body.error).to.equal('configurationError'); + }); + + it('returns 400 when siteId does not resolve to any brand', async () => { + getBrandBySiteStub.resolves(null); + const ctx = fakeContext({ url: statsUrl('?siteId=site-without-brand') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + }); + + it('returns 400 when siteId resolves to a different brand than :brandId', async () => { + getBrandBySiteStub.resolves({ id: 'some-other-brand-id', name: 'Other Brand' }); + const ctx = fakeContext({ url: statsUrl('?siteId=site-of-other-brand') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + const body = await readBody(res); + expect(body.message).to.match(/siteId does not belong to the specified brand/); + }); + + it('proceeds when siteId resolves to the same brand as :brandId', async () => { + getBrandBySiteStub.resolves({ id: BRAND_ID, name: 'Adobe Brand' }); + const ctx = fakeContext({ url: statsUrl('?siteId=site-of-this-brand') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + }); + + it('accepts the site_id snake_case alias for siteId', async () => { + getBrandBySiteStub.resolves({ id: BRAND_ID, name: 'Adobe Brand' }); + const ctx = fakeContext({ url: statsUrl('?site_id=site-of-this-brand') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + expect(getBrandBySiteStub).to.have.been.calledWith(ORG_ID, 'site-of-this-brand'); + }); + + it('returns the auth error when brandId is not a valid UUID', async () => { + const ctx = fakeContext({ url: statsUrl(), params: { brandId: 'not-a-uuid' } }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + }); + + it('returns 404 when the organization is not found', async () => { + const ctx = fakeContext({ + url: statsUrl(), + org: undefined, + }); + ctx.dataAccess.Organization.findById.resolves(null); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(404); + }); + }); + // ─── extractQuery edge cases ────────────────────────────────────────────── describe('extractQuery', () => { diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 8f4a9c919..a9c8b218f 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -914,6 +914,7 @@ describe('getRouteHandlers', () => { 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/cited-domains', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/owned-urls', 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/url-inspector/domain-urls', + 'GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/activate', 'POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/deactivate', 'GET /v2/orgs/:spaceCatId/sites/:siteId/brand', From f6c5148eab350efa840c4b2cebefb756db8b7874 Mon Sep 17 00:00:00 2001 From: Vivek Singh Date: Wed, 15 Jul 2026 19:28:13 +0530 Subject: [PATCH 2/6] hook in elements to get stats --- docs/elements/brand-presence-stats-plan.md | 468 ++++++++++++++++++ src/controllers/elements.js | 90 +++- .../definitions/brand-presence-stats.js | 152 ++++++ src/support/elements/definitions/index.js | 11 + src/support/elements/elements-service.js | 106 ++++ src/support/elements/week-utils.js | 53 ++ test/controllers/elements.test.js | 138 +++++- .../definitions/brand-presence-stats.test.js | 225 +++++++++ .../support/elements/elements-service.test.js | 109 ++++ test/support/elements/week-utils.test.js | 60 +++ 10 files changed, 1374 insertions(+), 38 deletions(-) create mode 100644 docs/elements/brand-presence-stats-plan.md create mode 100644 src/support/elements/definitions/brand-presence-stats.js create mode 100644 test/support/elements/definitions/brand-presence-stats.test.js create mode 100644 test/support/elements/week-utils.test.js diff --git a/docs/elements/brand-presence-stats-plan.md b/docs/elements/brand-presence-stats-plan.md new file mode 100644 index 000000000..90f0cb4f8 --- /dev/null +++ b/docs/elements/brand-presence-stats-plan.md @@ -0,0 +1,468 @@ +# Implementation Plan — `GET .../brand-presence/stats` + +> Status: **Implemented.** +> Endpoint: `GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats` +> +> Companion reference: [`semrush-elements-api-reference.md`](./semrush-elements-api-reference.md) +> — read that first for the shared conventions (transport, definitions, service layer) +> this plan builds on. +> +> **Post-implementation correction:** while wiring this up, we discovered the actual +> reference contract already lives in this codebase — +> `src/controllers/llmo/llmo-brand-presence.js#createBrandPresenceStatsHandler` +> (Postgres-RPC-backed) — not an external "mysticat" endpoint as originally assumed. +> Its real response contract differs from what §1's original draft assumed in two ways, +> both reflected in the final implementation below: +> - **No `has_data_for_last_week` field at all.** Dropped entirely. +> - **Each `trends[]` week returns all 4 stats fields** (`total_executions`, +> `average_visibility_score`, `total_mentions`, `total_citations`), not just +> mentions+visibility. Since no Semrush element returns all 4 pre-bucketed by week, +> the implementation re-runs the same 4 KPI element calls once per week window +> instead of using `TRENDS_MV` — see §3.2 (updated). + +--- + +## 0. API Reference + +`GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats` + +Auth: same as every other Elements-wrapper route (`authorizeOrg` — IMS/promise-token, +org + brand access, resolves the brand's Semrush workspace). + +### Path parameters + +| Param | Required | Description | +|---|---|---| +| `spaceCatId` | Yes | SpaceCat organization UUID. | +| `brandId` | Yes | SpaceCat brand UUID (must be a valid UUID and belong to `spaceCatId`). | + +### Query parameters + +| Param | Aliases | Required | Default | Description | +|---|---|---|---|---| +| `startDate` | `start_date` | No | 28 days before today | Range start, `YYYY-MM-DD`. 400 if not a valid calendar date. | +| `endDate` | `end_date` | No | today | Range end, `YYYY-MM-DD`. 400 if not a valid calendar date, or if before `startDate`. | +| `model` | `platform` | No | `search-gpt` (via `resolveElementModel`) | AI model/platform filter, e.g. `search-gpt`, `gpt-5`, `perplexity`, `microsoft-copilot`. Unrecognized values fall back to the default. | +| `siteId` | `site_id` | No | — | Must resolve (via `getBrandBySite`) to the same brand as `:brandId`; 400 otherwise. Used only as a cross-check today — it does not itself narrow the Semrush query. | +| `regionCode` | `region_code`, `region` | No | — | One region+language code (e.g. `US`). Resolves to a single Semrush `projectId` via `resolveRegionProjectId`; 404 if it doesn't match any of the brand's markets. When omitted, the response aggregates across **every** project the brand owns. | +| `showTrends` | `show_trends` | No | `false` | `true`/`1` (case-insensitive string or boolean/number) → include the weekly `trends[]` array, split backward from `endDate` into up to 8 7-day weeks. | + +**Accepted but not yet implemented** (present in the reference Postgres-backed contract, +no confirmed Semrush Elements equivalent yet — see §2's gap notes): `categoryId(s)`, +`topicIds`, `origin`, `userIntent`, `promptBranding`. These are currently no-ops. + +### Response shape + +```json +{ + "stats": { + "total_executions": 19528, + "total_mentions": 14635, + "average_visibility_score": 48.77, + "total_citations": 158903 + }, + "trends": [ + { + "startDate": "2026-07-01", + "endDate": "2026-07-07", + "data": { + "stats": { + "total_executions": 9764, + "total_mentions": 7318, + "average_visibility_score": 47.2, + "total_citations": 79451 + } + } + }, + { + "startDate": "2026-07-08", + "endDate": "2026-07-14", + "data": { + "stats": { + "total_executions": 9764, + "total_mentions": 7317, + "average_visibility_score": 50.34, + "total_citations": 79452 + } + } + } + ] +} +``` + +- `stats` is always present and covers the full `[startDate, endDate]` range. +- `trends` is present **only** when `showTrends` is truthy; each entry's `data.stats` + has the identical 4-field shape as the top-level `stats`, scoped to that one week. +- No `has_data_for_last_week` field (see §4.2 — dropped, it doesn't exist in the real + reference contract this endpoint mirrors). + +### Error responses + +| Status | `error` token | Cause | +|---|---|---| +| 400 | `invalidRequest` | `brandId` not a UUID; malformed/out-of-order `startDate`/`endDate`; `siteId` doesn't belong to `:brandId`'s brand | +| 401 | `authenticationRequired` | Missing/invalid `Authorization` bearer (non-IMS caller with no `x-promise-token`) | +| 403 | `forbidden` | Caller lacks access to the organization | +| 404 | `notFound` | Organization/brand not found; brand has no resolvable Semrush workspace; `regionCode` doesn't match any market | +| 503 | `configurationError` | PostgREST client unavailable | +| 502 | `elementsUpstreamError` | Non-auth Semrush upstream failure (incl. timeout) | +| 500 | `internalServerError` | Unexpected error | + +--- + +## 1. Goal + +Replace the scaffolded placeholder in `getStats` with real aggregation over 4 Semrush +Elements API calls per date range, matching +`llmo-brand-presence.js#createBrandPresenceStatsHandler`'s response contract: + +```json +{ + "stats": { + "total_executions": 19528, + "average_visibility_score": 48.77, + "total_mentions": 14635, + "total_citations": 158903 + }, + "trends": [ + { + "startDate": "2026-06-28", + "endDate": "2026-07-04", + "data": { "stats": { "total_executions": 4386, "average_visibility_score": 51.83, "total_mentions": 4511, "total_citations": 39000 } } + } + ] +} +``` + +`trends` is included only when `showTrends=true`/`show_trends=1`, split backward from +`endDate` into (up to 8) 7-day weeks — see §3.2. + +--- + +## 2. Element mapping (confirmed against live sample payloads) + +All 5 element UUIDs already exist in `src/support/elements/element-ids.js` — **no new +constants needed**: `TOTAL_EXECUTIONS`, `MENTIONS`, `VISIBILITY`, `CITATIONS_KPI`, `TRENDS_MV`. + +| Stats field | Element (constant) | Response envelope | Extraction | +|---|---|---|---| +| `total_executions` | `TOTAL_EXECUTIONS` (`a4defa1a`) | `simpleNumeric` | `blocks.firstSectionMainValue[0].firstSectionMainValue` | +| `total_mentions` | `MENTIONS` (`e1a6811b`) | `simpleNumeric` | `blocks.firstSectionMainValue[0].firstSectionMainValue` | +| `average_visibility_score` | `VISIBILITY` (`2724878e`) | `simpleNumeric` | `blocks.firstSectionMainValue[0].firstSectionMainValue` **× 100** (see §4.3 — value comes back as a 0–1 fraction, e.g. `0.4877`) | +| `total_citations` | `CITATIONS_KPI` (`588054fe`) | `simpleNumeric` | `blocks.firstSectionMainValue[0].firstSectionMainValue` | +| `trends[]` (mentions + visibility per week) | `TRENDS_MV` (`b5281393`) | `line` | `blocks.lines[]` filtered to `legend === brand.name`, one entry per ISO week `x` | + +> **Note on `comparison_start_date`/`comparison_end_date`:** the captured Mentions/ +> Visibility/Citations sample payloads include these fields (Semrush's built-in +> period-over-period comparison), and the sample responses correspondingly include +> `blocks.firstSectionSecondaryValue[]` (`previous`/`current`). The `/stats` response +> contract has no "compared to previous period" field, so **our payload builders +> should omit `comparison_start_date`/`comparison_end_date`/`comparison_data_formatting` +> entirely** and the transforms should only read `blocks.firstSectionMainValue[0] +> .firstSectionMainValue` — `firstSectionSecondaryValue` is unused. If Semrush requires +> `comparison_data_formatting` to be present even with no comparison dates, verify the +> element tolerates its absence during implementation; fall back to sending it with no +> comparison dates if not. + +### Confirmed request payload shapes + +**Mentions (`MENTIONS`)** — `advanced` filter `CBF_ws_brand` (eq) + `CBF_model` (eq) + +optional `CBF_project` (singular, `or` block, one `eq` per project UUID): +```json +{ + "comparison_data_formatting": "union", + "filters": { + "simple": { "start_date": "...", "end_date": "...", "comparison_start_date": "...", "comparison_end_date": "..." }, + "advanced": { "op": "and", "filters": [ + { "op": "eq", "val": "", "col": "CBF_ws_brand" }, + { "op": "eq", "val": "", "col": "CBF_model" }, + { "op": "or", "filters": [{ "op": "eq", "val": "", "col": "CBF_project" }] } + ] } + } +} +``` + +**Visibility (`VISIBILITY`)** — same `simple` shape; `advanced` differs slightly: `CBF_model` +is *also* wrapped in its own `or` block (not a bare `eq` like Mentions): +```json +"advanced": { "op": "and", "filters": [ + { "op": "eq", "val": "", "col": "CBF_ws_brand" }, + { "op": "or", "filters": [{ "op": "eq", "val": "", "col": "CBF_model" }] }, + { "op": "or", "filters": [ + { "op": "eq", "val": "", "col": "CBF_project" }, + { "op": "eq", "val": "", "col": "CBF_project" } + ] } +] } +``` + +**Citations (`CITATIONS_KPI`)** — **gotcha, differs from Mentions/Visibility in two ways**: +uses `CBF_brand` (not `CBF_ws_brand`) and `CBF_projects` (**plural** column name, not +`CBF_project`): +```json +"advanced": { "op": "and", "filters": [ + { "op": "eq", "val": "", "col": "CBF_brand" }, + { "op": "eq", "val": "", "col": "CBF_model" }, + { "op": "or", "filters": [ + { "op": "eq", "val": "", "col": "CBF_projects" }, + { "op": "eq", "val": "", "col": "CBF_projects" } + ] } +] } +``` + +**Total Executions (`TOTAL_EXECUTIONS`)** — no `advanced.CBF_project*` filter at all; +project scoping is a **top-level `project_id`** (singular value, not an array): +```json +{ + "project_id": "", + "comparison_data_formatting": "union", + "filters": { + "simple": { "start_date": "...", "end_date": "..." }, + "advanced": { "op": "and", "filters": [{ "op": "eq", "val": "", "col": "CBF_model" }] } + } +} +``` +Per the team's clarification (§4.1, resolved): **omitting `project_id` returns the +total automatically combined across all projects in the subworkspace** — this is +exactly what's needed for the aggregate "all regions" view, so no fan-out/summing is +required. + +**Trends — Mentions & Visibility (`TRENDS_MV`)** — `auto_bucketing: "week"`, no brand +filter at all (returns one line-series per brand/competitor found in the subworkspace): +```json +{ + "auto_bucketing": "week", + "filters": { + "simple": { "start_date": "...", "end_date": "..." }, + "advanced": { "op": "and", "filters": [ + { "op": "eq", "val": "", "col": "CBF_model" }, + { "op": "or", "filters": [{ "op": "eq", "val": "", "col": "CBF_project" }] } + ] } + } +} +``` +Response `blocks.lines[]`: one row per `(legend, x)` = `(brand/competitor name, ISO week +start)`, with `y__mentions`, `y__visibility` (0–1 fraction), `y__sov`, `y__position`, +`y__prompts_mentioned`, `y__total_num_prompts`. **Must filter to `legend === brand.name`** +(case-insensitive) to isolate this brand's own series — the subworkspace also returns +tracked competitors. + +--- + +## 3. Codebase changes (following the conventions in `semrush-elements-api-reference.md`) + +No new element UUIDs. New/changed files: + +### 3.1 `src/support/elements/definitions/brand-presence-stats.js` (implemented) + +One pair of `build*`/`transform*` functions per element usage. `TRENDS_MV` is **not** +used — see §3.2 for why: + +```js +export function transformStatsSimpleNumericResponse(raw) { ... } // shared extractor, -> number + +export function buildStatsTotalExecutionsPayload({ model, platform, startDate, endDate, projectId }) { ... } +export const transformStatsTotalExecutionsResponse = transformStatsSimpleNumericResponse; + +// brandName is always included (CBF_ws_brand) — see Open Decision 4.4 (resolved: keep). +export function buildStatsMentionsPayload({ model, platform, startDate, endDate, projectIds, brandName }) { ... } +export const transformStatsMentionsResponse = transformStatsSimpleNumericResponse; + +// brandName is always included (CBF_ws_brand) — see Open Decision 4.4 (resolved: keep). +export function buildStatsVisibilityPayload({ model, platform, startDate, endDate, projectIds, brandName }) { ... } +export function transformStatsVisibilityResponse(raw) { ... } // -> number, ×100 applied here + +// brandName is always included (CBF_brand, NOT CBF_ws_brand — see §2) — see Open +// Decision 4.4 (resolved: keep). +export function buildStatsCitationsPayload({ model, platform, startDate, endDate, projectIds, brandName }) { ... } +export const transformStatsCitationsResponse = transformStatsSimpleNumericResponse; +``` + +Each `build*Payload` accepts `projectIds` as an array — the **common, single-region +case** (a user has picked one region+language on the Brand Presence page) is simply +`projectIds: [oneProjectId]`; the aggregate "all regions" case passes every project id +the brand owns. Mentions/Visibility/Trends OR them under `CBF_project` (singular); +Citations ORs them under `CBF_projects` (plural) — see §2. A single-element `or` block +degrades gracefully to "exactly one project", so no special-casing is needed in the +payload builders themselves for the single-region path. + +Export all six from `definitions/index.js` (barrel). + +### 3.2 `src/support/elements/elements-service.js` — `getBrandPresenceStats` (implemented) + +**Design change from the original draft:** the real contract needs all 4 stats fields +per trend week (§ intro), and no Semrush element returns all 4 pre-bucketed by week — +`TRENDS_MV` only has weekly mentions+visibility. So instead of a dedicated trends +element, `getBrandPresenceStats` factors the 4-element fetch into a +`fetchStatsForRange(rangeStart, rangeEnd)` helper and calls it once for the full +range (`stats`), then once per week (`trends`, only when `showTrends` is true) — +reusing `src/support/elements/week-utils.js#splitDateRangeIntoWeeksBackward` (added +in this implementation, copied verbatim from +`llmo-brand-presence.js#splitDateRangeIntoWeeksBackward` per the existing +support/elements convention of not importing controller code) for identical week +boundaries (backward from `endDate`, max 8 weeks). Per-week fan-out is bounded via +`mapWithConcurrency` (`STATS_TRENDS_WEEK_CONCURRENCY`). + +```js +async getBrandPresenceStats(workspaceId, { + model, platform, startDate, endDate, projectId, projectIds, brandName, showTrends, +}) { + // A single resolved region (projectId) is the common path — every element scopes + // to that one project. The aggregate "all regions" path (projectId absent, + // projectIds = every project the brand owns) needs no fan-out either: Mentions/ + // Visibility/Citations OR all project ids into one call, and Total Executions + // omits project_id entirely (resolved decision — see §4.1). + const resolvedProjectIds = projectId ? [projectId] : projectIds; + + const fetchStatsForRange = async (rangeStart, rangeEnd) => { + const [totalExec, mentions, visibility, citations] = await Promise.all([ + transport.fetchElement(workspaceId, ELEMENT_IDS.TOTAL_EXECUTIONS, + buildStatsTotalExecutionsPayload({ model, platform, startDate: rangeStart, endDate: rangeEnd, projectId })), + transport.fetchElement(workspaceId, ELEMENT_IDS.MENTIONS, + buildStatsMentionsPayload({ + model, platform, startDate: rangeStart, endDate: rangeEnd, projectIds: resolvedProjectIds, brandName, + })), + transport.fetchElement(workspaceId, ELEMENT_IDS.VISIBILITY, + buildStatsVisibilityPayload({ + model, platform, startDate: rangeStart, endDate: rangeEnd, projectIds: resolvedProjectIds, brandName, + })), + transport.fetchElement(workspaceId, ELEMENT_IDS.CITATIONS_KPI, + buildStatsCitationsPayload({ + model, platform, startDate: rangeStart, endDate: rangeEnd, projectIds: resolvedProjectIds, brandName, + })), + ]); + return { + total_executions: transformStatsTotalExecutionsResponse(totalExec), + total_mentions: transformStatsMentionsResponse(mentions), + average_visibility_score: transformStatsVisibilityResponse(visibility), + total_citations: transformStatsCitationsResponse(citations), + }; + }; + + const response = { stats: await fetchStatsForRange(startDate, endDate) }; + + if (showTrends) { + const weeks = splitDateRangeIntoWeeksBackward(startDate, endDate); + const weekStats = await mapWithConcurrency( + weeks, STATS_TRENDS_WEEK_CONCURRENCY, + (week) => fetchStatsForRange(week.startDate, week.endDate), + ); + response.trends = weeks.map((week, i) => ({ + startDate: week.startDate, endDate: week.endDate, data: { stats: weekStats[i] }, + })); + } + + return response; +}, +``` + +**Cost tradeoff (accepted per the "fetch all 4 per week" decision):** with +`showTrends=true` this is up to `4 + (8 weeks × 4) = 36` upstream Semrush calls per +`/stats` request. Noted as a future optimization target if Semrush ever exposes a +single weekly-bucketed element covering all 4 metrics; out of scope for now. + +### 3.3 `src/controllers/elements.js#getStats` — wire the real call + +Region+language scoping is a **first-class, optional** filter here — the Brand +Presence page lets a user pick one region+language combination, which maps 1:1 to a +single Semrush `projectId` within the brand's subworkspace (per product requirement). +So `getBrandPresenceStats` takes an **optional single `projectId`**, not a list: + +Replace the zeroed `response` object with: +1. If `siteId`/`regionCode` is present, resolve it to a single `projectId` (reuse + `resolveRegionProjectId`, already used by `listCitedDomains`/`listOwnedUrls`) and + pass just that one id through — this is the common case and needs exactly one call + per element, no fan-out. +2. If no region/site filter is given (aggregate "all regions" view), resolve **all** + of the brand's project ids via `fetchBrandSemrushProjects` (already imported/used by + `listUrlInspectorFilterDimensions`/`listOwnedUrls`) and fan out (see Open Decision + 4.1 for how each element handles multi-project aggregation). +3. Call `service.getBrandPresenceStats(auth.workspaceId, { ...query, projectId, projectIds, brandName: auth.brand.name, showTrends: parseShowTrends(query) })` — + `projectId` set when step 1 resolved a single region, `projectIds` set when step 2 + fanned out across all of the brand's regions. Exactly one of the two is populated. +4. Return via `cachedOk(result)` (matches the existing pattern for this handler). + +### 3.4 Tests (implemented) + +- `test/support/elements/definitions/brand-presence-stats.test.js` (new) — payload + shape + response transform unit tests per element. +- `test/support/elements/week-utils.test.js` (new) — `addDaysToDate`/ + `splitDateRangeIntoWeeksBackward` (previously untested in this layer). +- `test/support/elements/elements-service.test.js` — added `getBrandPresenceStats` + cases (parallel calls, project_id omission in aggregate mode, per-week trend + fan-out, error propagation). +- `test/controllers/elements.test.js` — replaced the zeroed-stats `getStats` describe + block with stub-driven assertions (region resolution, date validation, param + pass-through, error propagation) mirroring `listWeeks`/`listCitedDomains`. + +--- + +## 4. Decisions (all resolved) + +### 4.1 — How to compute `total_executions` when a brand has multiple projects (regions) — **RESOLVED: omit `project_id`** + +**Decision: when no single region is selected (aggregate "all regions" view), omit +`project_id` from the `TOTAL_EXECUTIONS` payload entirely** — per team confirmation, +Semrush automatically combines data across all projects in the subworkspace's response +when `project_id` is not passed. No fan-out/sum needed; this is a single call in both +the single-region path (`project_id` set) and the aggregate path (`project_id` omitted). +`getTotalExecutions`/`buildStatsTotalExecutionsPayload` (§3.1/§3.2) simplify to: pass +`project_id` only when `projectId` was resolved from a selected region, otherwise omit +the field. + +### 4.2 — `has_data_for_last_week` — **MOOT: field does not exist in the real contract** + +Dropped — see the "Post-implementation correction" note at the top of this doc. The +real reference handler (`llmo-brand-presence.js#createBrandPresenceStatsHandler`) +never returns this field; it was carried over incorrectly from the wiki's UI +description. The implementation does not compute or return it. + +### 4.3 — `average_visibility_score` units — **RESOLVED: multiply by 100** + +**Decision: multiply the Visibility element's raw value by 100** in +`transformStatsVisibilityResponse` — the element returns a 0–1 fraction (e.g. +`0.4877`), and the contract expects a 0–100 percentage. + +### 4.4 — `CBF_ws_brand`/`CBF_brand` filters: keep or drop? — **RESOLVED: keep** + +**Decision: keep the `CBF_ws_brand`/`CBF_brand` filter** (using `auth.brand.name`) on +Mentions/Visibility/Citations, matching the captured samples. Unlike every other +brand-scoped handler in this file (`listWeeks`, `listCitedDomains`, etc.), which +deliberately **omit** brand-name filters and rely on `auth.workspaceId` (the resolved +sub-workspace) to already scope the call to one brand, `/stats` needs the explicit +filter — the captured `TRENDS_MV` sample response includes competitor brands too, so +sub-workspace scoping alone is not sufficient here. +`buildStatsMentionsPayload`/`buildStatsVisibilityPayload`/`buildStatsCitationsPayload` +(§3.1) should accept `brandName` and always include the `CBF_ws_brand` +(Mentions/Visibility) or `CBF_brand` (Citations) filter. + +### 4.5 — Citations/executions trends via per-week fan-out — **SUPERSEDED, see §3.2** + +Originally flagged as "not needed today" on the assumption trends only needed +mentions+visibility (`TRENDS_MV`). Once the real contract (all 4 stats per week) was +found, this became moot: the implementation reuses the same 4 KPI element calls +per-week instead of `TRENDS_MV`, so citations and executions are trivially included in +every trend week — no per-brand citations trend element was ever needed. + +--- + +## 5. Implementation status + +All steps complete: + +1. ✅ `definitions/brand-presence-stats.js` + barrel export + unit tests. +2. ✅ `week-utils.js#addDaysToDate`/`splitDateRangeIntoWeeksBackward` (new) + unit tests. +3. ✅ `elements-service.js#getBrandPresenceStats` (per-range + per-week fan-out) + unit tests. +4. ✅ `controllers/elements.js#getStats` wired to the real service call + (date validation/defaulting, region→projectId resolution, aggregate project-id + fan-out), existing test suite replaced with stub-driven assertions. +5. ✅ `npm test` / `npm run lint` pass with no regressions (coverage for + `elements.js` improved from the scaffold's 98.64%/92.53% to 99.76%/94.04% + stmts/branches). +6. ⏳ **Not done — recommended before merging to prod:** manual verification against + a real brand/subworkspace. In particular, confirm `TOTAL_EXECUTIONS` omitting + `project_id` truly scopes to just this brand's subworkspace projects and not a + wider pool (§4.1), and spot-check the `average_visibility_score` ×100 conversion + against the current UI's expected units (§4.3). +7. Not done — `categoryId(s)`/`topicIds`/`origin` query params are accepted but are + currently no-ops (documented in the `getStats` docstring) pending a confirmed + Semrush filter equivalent — see the wiki gap analysis from the initial research. diff --git a/src/controllers/elements.js b/src/controllers/elements.js index c751fc690..f39df576d 100644 --- a/src/controllers/elements.js +++ b/src/controllers/elements.js @@ -129,6 +129,21 @@ function isYmdDate(value) { return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; } +/** + * Default 28-day trailing date range for `/stats`, mirroring + * `llmo-brand-presence.js#defaultDateRange`, used when the caller omits + * startDate/endDate. + */ +function defaultStatsDateRange() { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 28); + return { + startDate: start.toISOString().slice(0, 10), + endDate: end.toISOString().slice(0, 10), + }; +} + /** * Splits a comma-separated query value into a trimmed, non-empty string array. * `extractQuery` collapses repeated params (last value wins), so multi-valued @@ -757,13 +772,15 @@ export default function ElementsController(context, log, env) { /** * GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats - * Scaffold for the Elements-backed equivalent of the mysticat - * `GET /org/:spaceCatId/brands/:brandId/brand-presence/stats` endpoint (see + * Elements-backed equivalent of the Postgres-RPC `/stats` endpoint (see * llmo-brand-presence.js#createBrandPresenceStatsHandler): same response shape - * (`{ stats, trends? }`) and the same query params (startDate/endDate, model/ - * platform, showTrends/show_trends, siteId/site_id, categoryId(s), topicIds, - * regionCode(s), origin), but the Elements aggregation itself is not wired yet - * — always returns zeroed stats (and an empty trends array when requested). + * (`{ stats, trends? }`) — aggregated `total_executions`, `average_visibility_score`, + * `total_mentions`, `total_citations`, plus an optional weekly `trends` array. See + * docs/elements/brand-presence-stats-plan.md for the full design + resolved decisions. + * + * `categoryId(s)`/`topicIds`/`origin` are accepted by the reference endpoint but are + * NOT yet supported here — the Elements API has no confirmed filter equivalent for + * them (see the plan doc's gap analysis); they are currently no-ops. */ const getStats = async (ctx) => { try { @@ -771,8 +788,8 @@ export default function ElementsController(context, log, env) { if (auth.error) { return auth.error; } - const { spaceCatId } = ctx?.params ?? {}; - const { brand } = auth; + const { spaceCatId, brandId } = ctx?.params ?? {}; + const { workspaceId, brand } = auth; const query = extractQuery(ctx); const siteId = query.siteId || query.site_id; @@ -784,19 +801,54 @@ export default function ElementsController(context, log, env) { } } - const response = { - stats: { - total_executions: 0, - average_visibility_score: 0, - total_mentions: 0, - total_citations: 0, - }, - }; - if (parseShowTrends(query)) { - response.trends = []; + const startDate = query.startDate || query.start_date; + const endDate = query.endDate || query.end_date; + if (hasText(startDate) && !isYmdDate(startDate)) { + return badRequest('startDate must be a valid YYYY-MM-DD date'); + } + if (hasText(endDate) && !isYmdDate(endDate)) { + return badRequest('endDate must be a valid YYYY-MM-DD date'); + } + if (hasText(startDate) && hasText(endDate) && startDate > endDate) { + return badRequest('startDate must not be after endDate'); + } + const defaultRange = defaultStatsDateRange(); + + const service = await buildService(ctx); + const { BrandSemrushProject } = ctx?.dataAccess ?? {}; + const brandSemrushProjects = await fetchBrandSemrushProjects(BrandSemrushProject, [brand]); + + // A single selected region+language maps 1:1 to a Semrush projectId — the + // common case, needing no fan-out. Absent → aggregate "all regions" view, + // scoped to every project this brand owns. + let projectId; + let projectIds; + const regionCode = query.regionCode || query.region_code || query.region; + if (hasText(regionCode)) { + projectId = await service.resolveRegionProjectId(workspaceId, { + brandId, region: regionCode, brandSemrushProjects, + }); + if (!hasText(projectId)) { + return notFound(`No Semrush market found for region: ${regionCode}`); + } + } else { + projectIds = brandSemrushProjects + .map((p) => p.semrushProjectId) + .filter(hasText); } - return cachedOk(response); + const result = await service.getBrandPresenceStats(workspaceId, { + model: query.model, + platform: query.platform, + startDate: startDate || defaultRange.startDate, + endDate: endDate || defaultRange.endDate, + projectId, + projectIds, + brandName: brand.name, + showTrends: parseShowTrends(query), + }); + + return cachedOk(result); } catch (e) { return mapError(e, log); } diff --git a/src/support/elements/definitions/brand-presence-stats.js b/src/support/elements/definitions/brand-presence-stats.js new file mode 100644 index 000000000..d28663bbe --- /dev/null +++ b/src/support/elements/definitions/brand-presence-stats.js @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { resolveElementModel } from '../constants.js'; + +/** + * Payload builders + response transformers backing `GET .../brand-presence/stats` + * (rows 4, 6, 7, 8 — Total Executions, Mentions, Visibility, Citations). See + * docs/elements/brand-presence-stats-plan.md for the full design + resolved + * decisions this file implements. + * + * `comparison_start_date`/`comparison_end_date`/`comparison_data_formatting` are + * intentionally omitted — the `/stats` contract has no period-over-period + * comparison field, so only `blocks.firstSectionMainValue` is read; Semrush's + * `firstSectionSecondaryValue` (previous/current) is unused. + */ + +function orProjectFilter(col, projectIds) { + if (!Array.isArray(projectIds) || projectIds.length === 0) { + return null; + } + return { op: 'or', filters: projectIds.map((val) => ({ op: 'eq', val, col })) }; +} + +/** + * Extracts the single numeric value from a `simpleNumeric` element response. + * @param {object} raw - Raw response from the Elements API. + * @returns {number} + */ +export function transformStatsSimpleNumericResponse(raw) { + const value = raw?.blocks?.firstSectionMainValue?.[0]?.firstSectionMainValue; + return typeof value === 'number' ? value : 0; +} + +/** + * Builds the payload for Total Executions (row 4, `TOTAL_EXECUTIONS`). Project + * scoping is a top-level `project_id` (singular) — omitted entirely for the + * aggregate "all regions" view, where Semrush combines all of the subworkspace's + * projects automatically (resolved decision — see plan §4.1). + * + * @param {object} params + * @param {string} [params.model] / [params.platform] - AI model filter. + * @param {string} params.startDate / params.endDate - YYYY-MM-DD. + * @param {string} [params.projectId] - Single Semrush project UUID (one region selected). + */ +export function buildStatsTotalExecutionsPayload({ + model, platform, startDate, endDate, projectId, +}) { + const resolvedModel = resolveElementModel(model || platform); + const payload = { + filters: { + simple: { start_date: startDate, end_date: endDate }, + advanced: { op: 'and', filters: [{ op: 'eq', val: resolvedModel, col: 'CBF_model' }] }, + }, + }; + if (projectId) { + payload.project_id = projectId; + } + return payload; +} + +export const transformStatsTotalExecutionsResponse = transformStatsSimpleNumericResponse; + +/** + * Builds the payload for Mentions (row 6, `MENTIONS`). Always scopes to the brand + * via `CBF_ws_brand` (resolved decision — see plan §4.4). + * + * @param {object} params + * @param {string} params.brandName - Brand display name (Semrush `CBF_ws_brand` value). + * @param {string} [params.model] / [params.platform] - AI model filter. + * @param {string} params.startDate / params.endDate - YYYY-MM-DD. + * @param {string[]} [params.projectIds] - Semrush project UUIDs to OR together. + */ +export function buildStatsMentionsPayload({ + model, platform, startDate, endDate, projectIds, brandName, +}) { + const resolvedModel = resolveElementModel(model || platform); + const filters = [ + { op: 'eq', val: brandName, col: 'CBF_ws_brand' }, + { op: 'eq', val: resolvedModel, col: 'CBF_model' }, + ]; + const projectFilter = orProjectFilter('CBF_project', projectIds); + if (projectFilter) { + filters.push(projectFilter); + } + return { filters: { simple: { start_date: startDate, end_date: endDate }, advanced: { op: 'and', filters } } }; +} + +export const transformStatsMentionsResponse = transformStatsSimpleNumericResponse; + +/** + * Builds the payload for Visibility (row 7, `VISIBILITY`). Same scoping as + * Mentions; the raw value comes back as a 0-1 fraction — the transform below + * converts it to a 0-100 percentage (resolved decision — see plan §4.3). + * + * @param {object} params - Same shape as {@link buildStatsMentionsPayload}. + */ +export function buildStatsVisibilityPayload({ + model, platform, startDate, endDate, projectIds, brandName, +}) { + const resolvedModel = resolveElementModel(model || platform); + const filters = [ + { op: 'eq', val: brandName, col: 'CBF_ws_brand' }, + { op: 'or', filters: [{ op: 'eq', val: resolvedModel, col: 'CBF_model' }] }, + ]; + const projectFilter = orProjectFilter('CBF_project', projectIds); + if (projectFilter) { + filters.push(projectFilter); + } + return { filters: { simple: { start_date: startDate, end_date: endDate }, advanced: { op: 'and', filters } } }; +} + +/** + * @param {object} raw - Raw response from the Elements API. + * @returns {number} 0-100 percentage (raw value is a 0-1 fraction). + */ +export function transformStatsVisibilityResponse(raw) { + return transformStatsSimpleNumericResponse(raw) * 100; +} + +/** + * Builds the payload for Citations (row 8, `CITATIONS_KPI`). Differs from + * Mentions/Visibility: uses `CBF_brand` (not `CBF_ws_brand`) and `CBF_projects` + * (plural column, not `CBF_project`) — confirmed against live sample payloads. + * + * @param {object} params - Same shape as {@link buildStatsMentionsPayload}. + */ +export function buildStatsCitationsPayload({ + model, platform, startDate, endDate, projectIds, brandName, +}) { + const resolvedModel = resolveElementModel(model || platform); + const filters = [ + { op: 'eq', val: brandName, col: 'CBF_brand' }, + { op: 'eq', val: resolvedModel, col: 'CBF_model' }, + ]; + const projectFilter = orProjectFilter('CBF_projects', projectIds); + if (projectFilter) { + filters.push(projectFilter); + } + return { filters: { simple: { start_date: startDate, end_date: endDate }, advanced: { op: 'and', filters } } }; +} + +export const transformStatsCitationsResponse = transformStatsSimpleNumericResponse; diff --git a/src/support/elements/definitions/index.js b/src/support/elements/definitions/index.js index af26a04dc..5ea155cf8 100644 --- a/src/support/elements/definitions/index.js +++ b/src/support/elements/definitions/index.js @@ -29,3 +29,14 @@ export { transformOwnedUrlsResponse, } from './owned-urls.js'; export { buildDomainUrlsPayload, transformDomainUrlsResponse } from './domain-urls.js'; +export { + transformStatsSimpleNumericResponse, + buildStatsTotalExecutionsPayload, + transformStatsTotalExecutionsResponse, + buildStatsMentionsPayload, + transformStatsMentionsResponse, + buildStatsVisibilityPayload, + transformStatsVisibilityResponse, + buildStatsCitationsPayload, + transformStatsCitationsResponse, +} from './brand-presence-stats.js'; diff --git a/src/support/elements/elements-service.js b/src/support/elements/elements-service.js index 0b7f9eb3a..afda3bfd7 100644 --- a/src/support/elements/elements-service.js +++ b/src/support/elements/elements-service.js @@ -12,6 +12,7 @@ import { ELEMENT_IDS } from './element-ids.js'; import { mapWithConcurrency } from './concurrency.js'; +import { splitDateRangeIntoWeeksBackward } from './week-utils.js'; import { buildBrandsPayload, transformBrandsToFilterDimensions, @@ -34,8 +35,21 @@ import { transformOwnedUrlsResponse, buildDomainUrlsPayload, transformDomainUrlsResponse, + buildStatsTotalExecutionsPayload, + transformStatsTotalExecutionsResponse, + buildStatsMentionsPayload, + transformStatsMentionsResponse, + buildStatsVisibilityPayload, + transformStatsVisibilityResponse, + buildStatsCitationsPayload, + transformStatsCitationsResponse, } from './definitions/index.js'; +// Bounds parallel per-week upstream fan-out for the /stats trends array (up to +// TRENDS_MAX_WEEKS=8 weeks x 4 element calls each) so a wide date range can't +// spawn an unbounded number of parallel Semrush requests. +const STATS_TRENDS_WEEK_CONCURRENCY = 4; + /** * Creates the Elements service that composes transport calls with per-element * payload builders and response transformers. @@ -316,5 +330,97 @@ export function createElementsService(transport) { }); }, /* c8 ignore stop */ + + /** + * Fetches the Brand Presence Stats KPI cards (`GET .../brand-presence/stats`), + * backed by Total Executions (a4defa1a), Mentions (e1a6811b), Visibility + * (2724878e), and Citations (588054fe) — see + * docs/elements/brand-presence-stats-plan.md for the full design. + * + * `projectId` (single region selected) takes precedence over `projectIds` + * (aggregate "all regions" view, every project the brand owns). Exactly one + * should be populated by the caller. + * + * When `showTrends` is true, also fetches all four stats per week (up to 8 + * weeks, built backward from `endDate`, bounded concurrency) — there is no + * Semrush element that returns all four metrics pre-bucketed by week, so each + * week reuses the same four element calls scoped to that week's date range. + * + * @param {string} workspaceId - Semrush workspace UUID. + * @param {object} params + * @param {string} [params.model] / [params.platform] - AI model filter. + * @param {string} params.startDate / params.endDate - Required YYYY-MM-DD. + * @param {string} [params.projectId] - Single Semrush project UUID (one region selected). + * @param {string[]} [params.projectIds] - All of the brand's project UUIDs (aggregate view). + * @param {string} params.brandName - Brand display name (Semrush brand filter value). + * @param {boolean} [params.showTrends] - Whether to include the `trends` array. + * @returns {Promise<{stats: object, trends?: object[]}>} + */ + async getBrandPresenceStats(workspaceId, { + model, platform, startDate, endDate, projectId, projectIds, brandName, showTrends, + }) { + const resolvedProjectIds = projectId ? [projectId] : projectIds; + + const fetchStatsForRange = async (rangeStart, rangeEnd) => { + const totalExecutionsPayload = buildStatsTotalExecutionsPayload({ + model, platform, startDate: rangeStart, endDate: rangeEnd, projectId, + }); + const mentionsPayload = buildStatsMentionsPayload({ + model, + platform, + startDate: rangeStart, + endDate: rangeEnd, + projectIds: resolvedProjectIds, + brandName, + }); + const visibilityPayload = buildStatsVisibilityPayload({ + model, + platform, + startDate: rangeStart, + endDate: rangeEnd, + projectIds: resolvedProjectIds, + brandName, + }); + const citationsPayload = buildStatsCitationsPayload({ + model, + platform, + startDate: rangeStart, + endDate: rangeEnd, + projectIds: resolvedProjectIds, + brandName, + }); + const [totalExec, mentions, visibility, citations] = await Promise.all([ + transport.fetchElement(workspaceId, ELEMENT_IDS.TOTAL_EXECUTIONS, totalExecutionsPayload), + transport.fetchElement(workspaceId, ELEMENT_IDS.MENTIONS, mentionsPayload), + transport.fetchElement(workspaceId, ELEMENT_IDS.VISIBILITY, visibilityPayload), + transport.fetchElement(workspaceId, ELEMENT_IDS.CITATIONS_KPI, citationsPayload), + ]); + return { + total_executions: transformStatsTotalExecutionsResponse(totalExec), + total_mentions: transformStatsMentionsResponse(mentions), + average_visibility_score: transformStatsVisibilityResponse(visibility), + total_citations: transformStatsCitationsResponse(citations), + }; + }; + + const stats = await fetchStatsForRange(startDate, endDate); + const response = { stats }; + + if (showTrends) { + const weeks = splitDateRangeIntoWeeksBackward(startDate, endDate); + const weekStats = await mapWithConcurrency( + weeks, + STATS_TRENDS_WEEK_CONCURRENCY, + (week) => fetchStatsForRange(week.startDate, week.endDate), + ); + response.trends = weeks.map((week, i) => ({ + startDate: week.startDate, + endDate: week.endDate, + data: { stats: weekStats[i] }, + })); + } + + return response; + }, }; } diff --git a/src/support/elements/week-utils.js b/src/support/elements/week-utils.js index 3f917ee53..8d6cf4034 100644 --- a/src/support/elements/week-utils.js +++ b/src/support/elements/week-utils.js @@ -114,3 +114,56 @@ export function generateIsoWeekRange(minDate, maxDate) { return result.sort((a, b) => b.localeCompare(a)); } /* c8 ignore stop */ + +const TRENDS_MAX_WEEKS = 8; +const TRENDS_WEEK_SIZE = 7; + +/** + * Adds days to a YYYY-MM-DD date string. Uses UTC noon to avoid DST edge cases. + * Copied verbatim from `llmo-brand-presence.js#addDaysToDate` (see file header note). + * + * @param {string} dateStr - YYYY-MM-DD + * @param {number} days - Number of days to add (negative to subtract) + * @returns {string} YYYY-MM-DD + */ +export function addDaysToDate(dateStr, days) { + const d = new Date(`${dateStr}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +/** + * Splits a date range into `weekSize`-day weeks, building backward from `endDate`. + * Returns at most `maxWeeks` weeks, ordered oldest-first (chronological). Copied + * verbatim from `llmo-brand-presence.js#splitDateRangeIntoWeeksBackward` (see file + * header note) so the Elements-backed `/stats` trends use identical week boundaries + * to the Postgres-backed handler it mirrors. + * + * @param {string} startDate - YYYY-MM-DD + * @param {string} endDate - YYYY-MM-DD + * @param {number} [weekSize] - Days per week (default 7) + * @param {number} [maxWeeks] - Max weeks to return (default 8) + * @returns {Array<{ startDate: string, endDate: string }>} + */ +export function splitDateRangeIntoWeeksBackward( + startDate, + endDate, + weekSize = TRENDS_WEEK_SIZE, + maxWeeks = TRENDS_MAX_WEEKS, +) { + const weeks = []; + let weekEnd = endDate; + let weekStart = addDaysToDate(weekEnd, -weekSize + 1); + + while (weekEnd >= startDate) { + const actualStart = weekStart < startDate ? startDate : weekStart; + if (actualStart <= weekEnd) { + weeks.push({ startDate: actualStart, endDate: weekEnd }); + } + weekEnd = addDaysToDate(weekStart, -1); + weekStart = addDaysToDate(weekEnd, -weekSize + 1); + } + + weeks.reverse(); + return weeks.slice(-maxWeeks); +} diff --git a/test/controllers/elements.test.js b/test/controllers/elements.test.js index d288e7c76..c1d3a6454 100644 --- a/test/controllers/elements.test.js +++ b/test/controllers/elements.test.js @@ -49,6 +49,14 @@ const PROMPTS_RESULT = { const WEEKS_RESULT = { weeks: [{ week: '2026-W27', startDate: '2026-06-29', endDate: '2026-07-05' }], }; +const STATS_RESULT = { + stats: { + total_executions: 19528, + total_mentions: 14635, + average_visibility_score: 48.77, + total_citations: 158903, + }, +}; function fakeLog() { return { @@ -176,6 +184,8 @@ describe('ElementsController', () => { getUrlInspectorFilterDimensions: sinon.stub().resolves(URL_INSPECTOR_RESULT), getPrompts: sinon.stub().resolves(PROMPTS_RESULT), getWeeks: sinon.stub().resolves(WEEKS_RESULT), + getBrandPresenceStats: sinon.stub().resolves(STATS_RESULT), + resolveRegionProjectId: sinon.stub().resolves(null), }; createElementsServiceStub = sinon.stub().returns(serviceStub); createElementsTransportStub = sinon.stub().returns({ fetchElement: sinon.stub() }); @@ -869,45 +879,127 @@ describe('ElementsController', () => { const statsUrl = (qs = '') => `https://api.example.com/v2/orgs/${ORG_ID}` + `/brands/${BRAND_ID}/serenity/brand-presence/stats${qs}`; - it('returns 200 with zeroed stats and no trends key by default', async () => { + it('returns 200 with the service result by default (aggregate view, no trends)', async () => { const ctx = fakeContext({ url: statsUrl() }); const ctrl = ElementsController(ctx, fakeLog(), ENV); const res = await ctrl.getStats(ctx); expect(res.status).to.equal(200); const body = await readBody(res); - expect(body).to.deep.equal({ - stats: { - total_executions: 0, - average_visibility_score: 0, - total_mentions: 0, - total_citations: 0, - }, - }); + expect(body).to.deep.equal(STATS_RESULT); + const [workspaceId, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(workspaceId).to.equal(SUB_WORKSPACE_ID); + expect(params.brandName).to.equal('Adobe Brand'); + expect(params.projectId).to.equal(undefined); + expect(params.projectIds).to.deep.equal([]); + expect(params.showTrends).to.equal(false); + expect(params.startDate).to.match(/^\d{4}-\d{2}-\d{2}$/); + expect(params.endDate).to.match(/^\d{4}-\d{2}-\d{2}$/); }); - it('includes an empty trends array when showTrends=true', async () => { + it('passes showTrends=true through to the service', async () => { const ctx = fakeContext({ url: statsUrl('?showTrends=true') }); const ctrl = ElementsController(ctx, fakeLog(), ENV); const res = await ctrl.getStats(ctx); expect(res.status).to.equal(200); - const body = await readBody(res); - expect(body.trends).to.deep.equal([]); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.showTrends).to.equal(true); }); - it('includes an empty trends array when show_trends=1 (snake_case alias)', async () => { + it('passes showTrends=true through when show_trends=1 (snake_case alias)', async () => { const ctx = fakeContext({ url: statsUrl('?show_trends=1') }); const ctrl = ElementsController(ctx, fakeLog(), ENV); - const res = await ctrl.getStats(ctx); - const body = await readBody(res); - expect(body.trends).to.deep.equal([]); + await ctrl.getStats(ctx); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.showTrends).to.equal(true); }); - it('omits trends when showTrends=false', async () => { + it('passes showTrends=false through when showTrends=false', async () => { const ctx = fakeContext({ url: statsUrl('?showTrends=false') }); const ctrl = ElementsController(ctx, fakeLog(), ENV); + await ctrl.getStats(ctx); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.showTrends).to.equal(false); + }); + + it('passes explicit startDate/endDate/model/platform through to the service', async () => { + const ctx = fakeContext({ + url: statsUrl('?startDate=2026-07-01&endDate=2026-07-14&model=search-gpt&platform=chatgpt'), + }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); const res = await ctrl.getStats(ctx); - const body = await readBody(res); - expect(body).to.not.have.property('trends'); + expect(res.status).to.equal(200); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.startDate).to.equal('2026-07-01'); + expect(params.endDate).to.equal('2026-07-14'); + expect(params.model).to.equal('search-gpt'); + expect(params.platform).to.equal('chatgpt'); + }); + + it('returns 400 for a malformed startDate', async () => { + const ctx = fakeContext({ url: statsUrl('?startDate=not-a-date&endDate=2026-07-14') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + }); + + it('returns 400 for a malformed endDate', async () => { + const ctx = fakeContext({ url: statsUrl('?startDate=2026-07-01&endDate=not-a-date') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + }); + + it('returns 400 when startDate is after endDate', async () => { + const ctx = fakeContext({ url: statsUrl('?startDate=2026-07-14&endDate=2026-07-01') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(400); + }); + + it('resolves regionCode to a single projectId via resolveRegionProjectId', async () => { + serviceStub.resolveRegionProjectId.resolves('proj-us'); + const ctx = fakeContext({ url: statsUrl('?regionCode=US') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + expect(serviceStub.resolveRegionProjectId).to.have.been.calledWith( + SUB_WORKSPACE_ID, + sinon.match({ brandId: BRAND_ID, region: 'US' }), + ); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.projectId).to.equal('proj-us'); + expect(params.projectIds).to.equal(undefined); + }); + + it('accepts region_code and region as aliases for regionCode', async () => { + serviceStub.resolveRegionProjectId.resolves('proj-us'); + const ctx = fakeContext({ url: statsUrl('?region_code=US') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + await ctrl.getStats(ctx); + expect(serviceStub.resolveRegionProjectId).to.have.been.calledWith( + SUB_WORKSPACE_ID, + sinon.match({ region: 'US' }), + ); + }); + + it('returns 404 when regionCode does not resolve to any Semrush market', async () => { + serviceStub.resolveRegionProjectId.resolves(null); + const ctx = fakeContext({ url: statsUrl('?regionCode=ZZ') }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(404); + }); + + it('resolves projectIds from the brand\'s BrandSemrushProject rows when no region is given', async () => { + const project = makeBrandSemrushProject({ getSemrushProjectId: () => 'proj-1' }); + const ctx = fakeContext({ withBrandSemrushProject: true, brandSemrushProjects: [project] }); + ctx.dataAccess.BrandSemrushProject = { allByBrandId: sinon.stub().resolves([project]) }; + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(200); + const [, params] = serviceStub.getBrandPresenceStats.firstCall.args; + expect(params.projectIds).to.deep.equal(['proj-1']); + expect(params.projectId).to.equal(undefined); }); it('returns 503 (not a masked 404) when the PostgREST client is not available', async () => { @@ -971,6 +1063,14 @@ describe('ElementsController', () => { const res = await ctrl.getStats(ctx); expect(res.status).to.equal(404); }); + + it('propagates upstream errors through mapError', async () => { + serviceStub.getBrandPresenceStats.rejects(new MockElementsTransportError(503, 'upstream down')); + const ctx = fakeContext({ url: statsUrl() }); + const ctrl = ElementsController(ctx, fakeLog(), ENV); + const res = await ctrl.getStats(ctx); + expect(res.status).to.equal(502); + }); }); // ─── extractQuery edge cases ────────────────────────────────────────────── diff --git a/test/support/elements/definitions/brand-presence-stats.test.js b/test/support/elements/definitions/brand-presence-stats.test.js new file mode 100644 index 000000000..d62437bf0 --- /dev/null +++ b/test/support/elements/definitions/brand-presence-stats.test.js @@ -0,0 +1,225 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import { + transformStatsSimpleNumericResponse, + buildStatsTotalExecutionsPayload, + transformStatsTotalExecutionsResponse, + buildStatsMentionsPayload, + transformStatsMentionsResponse, + buildStatsVisibilityPayload, + transformStatsVisibilityResponse, + buildStatsCitationsPayload, + transformStatsCitationsResponse, +} from '../../../../src/support/elements/definitions/brand-presence-stats.js'; + +const SIMPLE_NUMERIC_RESPONSE = { + type: 'simpleNumeric', + blocks: { + firstSectionMainValue: [{ firstSectionMainValue: 14635 }], + firstSectionSecondaryValue: [ + { firstSectionSecondaryValue: 1263, period: 'current' }, + { firstSectionSecondaryValue: 1263, period: 'previous' }, + ], + }, +}; + +describe('brand-presence-stats definitions', () => { + describe('transformStatsSimpleNumericResponse', () => { + it('extracts firstSectionMainValue', () => { + expect(transformStatsSimpleNumericResponse(SIMPLE_NUMERIC_RESPONSE)).to.equal(14635); + }); + + it('returns 0 when the value is missing', () => { + expect(transformStatsSimpleNumericResponse({})).to.equal(0); + expect(transformStatsSimpleNumericResponse(null)).to.equal(0); + expect(transformStatsSimpleNumericResponse(undefined)).to.equal(0); + }); + + it('returns 0 when the value is not a number', () => { + const raw = { blocks: { firstSectionMainValue: [{ firstSectionMainValue: 'oops' }] } }; + expect(transformStatsSimpleNumericResponse(raw)).to.equal(0); + }); + }); + + describe('buildStatsTotalExecutionsPayload', () => { + it('omits project_id when no projectId is given (aggregate view)', () => { + const payload = buildStatsTotalExecutionsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', + }); + expect(payload).to.not.have.property('project_id'); + expect(payload.filters.simple).to.deep.equal({ + start_date: '2026-07-01', + end_date: '2026-07-14', + }); + expect(payload.filters.advanced).to.deep.equal({ + op: 'and', + filters: [{ op: 'eq', val: 'search-gpt', col: 'CBF_model' }], + }); + }); + + it('sets a top-level project_id when a single region is selected', () => { + const payload = buildStatsTotalExecutionsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', projectId: 'proj-1', + }); + expect(payload.project_id).to.equal('proj-1'); + }); + + it('never includes comparison_start_date/comparison_end_date', () => { + const payload = buildStatsTotalExecutionsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', + }); + expect(payload.filters.simple).to.not.have.property('comparison_start_date'); + expect(payload.filters.simple).to.not.have.property('comparison_end_date'); + expect(payload).to.not.have.property('comparison_data_formatting'); + }); + + it('resolves an unknown model to the default model', () => { + const payload = buildStatsTotalExecutionsPayload({ + model: 'not-a-real-model', startDate: '2026-07-01', endDate: '2026-07-14', + }); + expect(payload.filters.advanced.filters[0].val).to.equal('search-gpt'); + }); + }); + + describe('transformStatsTotalExecutionsResponse', () => { + it('extracts firstSectionMainValue', () => { + expect(transformStatsTotalExecutionsResponse(SIMPLE_NUMERIC_RESPONSE)).to.equal(14635); + }); + }); + + describe('buildStatsMentionsPayload', () => { + it('always includes CBF_ws_brand and CBF_model', () => { + const payload = buildStatsMentionsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', + }); + expect(payload.filters.advanced.filters).to.deep.include({ + op: 'eq', val: 'Lovesac', col: 'CBF_ws_brand', + }); + expect(payload.filters.advanced.filters).to.deep.include({ + op: 'eq', val: 'search-gpt', col: 'CBF_model', + }); + }); + + it('omits the project filter when projectIds is empty', () => { + const payload = buildStatsMentionsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', projectIds: [], + }); + expect(payload.filters.advanced.filters).to.have.lengthOf(2); + }); + + it('ORs multiple project ids under CBF_project (singular)', () => { + const payload = buildStatsMentionsPayload({ + model: 'search-gpt', + startDate: '2026-07-01', + endDate: '2026-07-14', + brandName: 'Lovesac', + projectIds: ['proj-1', 'proj-2'], + }); + const projectFilter = payload.filters.advanced.filters.find((f) => f.op === 'or'); + expect(projectFilter).to.deep.equal({ + op: 'or', + filters: [ + { op: 'eq', val: 'proj-1', col: 'CBF_project' }, + { op: 'eq', val: 'proj-2', col: 'CBF_project' }, + ], + }); + }); + }); + + describe('transformStatsMentionsResponse', () => { + it('extracts firstSectionMainValue', () => { + expect(transformStatsMentionsResponse(SIMPLE_NUMERIC_RESPONSE)).to.equal(14635); + }); + }); + + describe('buildStatsVisibilityPayload', () => { + it('always includes CBF_ws_brand, with CBF_model wrapped in its own or block', () => { + const payload = buildStatsVisibilityPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', + }); + expect(payload.filters.advanced.filters).to.deep.include({ + op: 'eq', val: 'Lovesac', col: 'CBF_ws_brand', + }); + expect(payload.filters.advanced.filters).to.deep.include({ + op: 'or', filters: [{ op: 'eq', val: 'search-gpt', col: 'CBF_model' }], + }); + }); + + it('ORs multiple project ids under CBF_project (singular)', () => { + const payload = buildStatsVisibilityPayload({ + model: 'search-gpt', + startDate: '2026-07-01', + endDate: '2026-07-14', + brandName: 'Lovesac', + projectIds: ['proj-1', 'proj-2'], + }); + const projectFilter = payload.filters.advanced.filters.find( + (f) => f.op === 'or' && f.filters.some((sub) => sub.col === 'CBF_project'), + ); + expect(projectFilter.filters).to.deep.equal([ + { op: 'eq', val: 'proj-1', col: 'CBF_project' }, + { op: 'eq', val: 'proj-2', col: 'CBF_project' }, + ]); + }); + }); + + describe('transformStatsVisibilityResponse', () => { + it('converts the 0-1 fraction to a 0-100 percentage', () => { + const raw = { + blocks: { firstSectionMainValue: [{ firstSectionMainValue: 0.4877215460175 }] }, + }; + expect(transformStatsVisibilityResponse(raw)).to.be.closeTo(48.77215460175, 1e-9); + }); + + it('returns 0 when the value is missing', () => { + expect(transformStatsVisibilityResponse({})).to.equal(0); + }); + }); + + describe('buildStatsCitationsPayload', () => { + it('uses CBF_brand (not CBF_ws_brand)', () => { + const payload = buildStatsCitationsPayload({ + model: 'search-gpt', startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', + }); + expect(payload.filters.advanced.filters).to.deep.include({ + op: 'eq', val: 'Lovesac', col: 'CBF_brand', + }); + expect(payload.filters.advanced.filters.some((f) => f.col === 'CBF_ws_brand')).to.equal(false); + }); + + it('ORs multiple project ids under CBF_projects (plural)', () => { + const payload = buildStatsCitationsPayload({ + model: 'search-gpt', + startDate: '2026-07-01', + endDate: '2026-07-14', + brandName: 'Lovesac', + projectIds: ['proj-1', 'proj-2'], + }); + const projectFilter = payload.filters.advanced.filters.find((f) => f.op === 'or'); + expect(projectFilter).to.deep.equal({ + op: 'or', + filters: [ + { op: 'eq', val: 'proj-1', col: 'CBF_projects' }, + { op: 'eq', val: 'proj-2', col: 'CBF_projects' }, + ], + }); + }); + }); + + describe('transformStatsCitationsResponse', () => { + it('extracts firstSectionMainValue', () => { + expect(transformStatsCitationsResponse(SIMPLE_NUMERIC_RESPONSE)).to.equal(14635); + }); + }); +}); diff --git a/test/support/elements/elements-service.test.js b/test/support/elements/elements-service.test.js index c1b01685a..2b1f504b0 100644 --- a/test/support/elements/elements-service.test.js +++ b/test/support/elements/elements-service.test.js @@ -221,4 +221,113 @@ describe('createElementsService', () => { await expect(service.getPrompts('ws-1', {})).to.be.rejectedWith('prompts upstream failure'); }); }); + + describe('getBrandPresenceStats', () => { + const simpleNumeric = (value) => ({ + blocks: { firstSectionMainValue: [{ firstSectionMainValue: value }] }, + }); + + beforeEach(() => { + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.TOTAL_EXECUTIONS, sinon.match.any) + .resolves(simpleNumeric(19528)); + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.MENTIONS, sinon.match.any) + .resolves(simpleNumeric(14635)); + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.VISIBILITY, sinon.match.any) + .resolves(simpleNumeric(0.4877)); + transport.fetchElement + .withArgs('ws-1', ELEMENT_IDS.CITATIONS_KPI, sinon.match.any) + .resolves(simpleNumeric(158903)); + }); + + it('fetches the 4 KPI elements in parallel and returns the combined stats', async () => { + const result = await service.getBrandPresenceStats('ws-1', { + model: 'search-gpt', + startDate: '2026-07-01', + endDate: '2026-07-14', + brandName: 'Lovesac', + projectId: 'proj-1', + }); + expect(result).to.deep.equal({ + stats: { + total_executions: 19528, + total_mentions: 14635, + average_visibility_score: 48.77, + total_citations: 158903, + }, + }); + }); + + it('does not fetch trends when showTrends is falsy', async () => { + await service.getBrandPresenceStats('ws-1', { + startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', projectId: 'proj-1', + }); + expect(transport.fetchElement.callCount).to.equal(4); + }); + + it('passes project_id (singular) to Total Executions, but omits it entirely in aggregate mode', async () => { + await service.getBrandPresenceStats('ws-1', { + startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', projectId: 'proj-1', + }); + const totalExecCall = transport.fetchElement.getCalls() + .find((c) => c.args[1] === ELEMENT_IDS.TOTAL_EXECUTIONS); + expect(totalExecCall.args[2].project_id).to.equal('proj-1'); + + transport.fetchElement.resetHistory(); + await service.getBrandPresenceStats('ws-1', { + startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', projectIds: ['proj-1', 'proj-2'], + }); + const aggregateCall = transport.fetchElement.getCalls() + .find((c) => c.args[1] === ELEMENT_IDS.TOTAL_EXECUTIONS); + expect(aggregateCall.args[2]).to.not.have.property('project_id'); + }); + + it('fetches weekly trends for each week when showTrends is true', async () => { + const result = await service.getBrandPresenceStats('ws-1', { + startDate: '2026-07-01', + endDate: '2026-07-14', + brandName: 'Lovesac', + projectId: 'proj-1', + showTrends: true, + }); + // 4 for the overall range + 4 per week (2 weeks in a 14-day range) = 12 + expect(transport.fetchElement.callCount).to.equal(12); + expect(result.trends).to.deep.equal([ + { + startDate: '2026-07-01', + endDate: '2026-07-07', + data: { + stats: { + total_executions: 19528, + total_mentions: 14635, + average_visibility_score: 48.77, + total_citations: 158903, + }, + }, + }, + { + startDate: '2026-07-08', + endDate: '2026-07-14', + data: { + stats: { + total_executions: 19528, + total_mentions: 14635, + average_visibility_score: 48.77, + total_citations: 158903, + }, + }, + }, + ]); + }); + + it('propagates transport errors', async () => { + transport.fetchElement.withArgs('ws-1', ELEMENT_IDS.MENTIONS, sinon.match.any) + .rejects(new Error('mentions upstream failure')); + await expect(service.getBrandPresenceStats('ws-1', { + startDate: '2026-07-01', endDate: '2026-07-14', brandName: 'Lovesac', projectId: 'proj-1', + })).to.be.rejectedWith('mentions upstream failure'); + }); + }); }); diff --git a/test/support/elements/week-utils.test.js b/test/support/elements/week-utils.test.js new file mode 100644 index 000000000..fb4fa2477 --- /dev/null +++ b/test/support/elements/week-utils.test.js @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect } from 'chai'; +import { + addDaysToDate, + splitDateRangeIntoWeeksBackward, +} from '../../../src/support/elements/week-utils.js'; + +describe('week-utils (trends backward-split helpers)', () => { + describe('addDaysToDate', () => { + it('adds positive days', () => { + expect(addDaysToDate('2026-07-01', 6)).to.equal('2026-07-07'); + }); + + it('subtracts negative days', () => { + expect(addDaysToDate('2026-07-01', -1)).to.equal('2026-06-30'); + }); + + it('handles month/year boundaries', () => { + expect(addDaysToDate('2026-01-01', -1)).to.equal('2025-12-31'); + }); + }); + + describe('splitDateRangeIntoWeeksBackward', () => { + it('splits an exact 14-day range into two 7-day weeks, oldest-first', () => { + const weeks = splitDateRangeIntoWeeksBackward('2026-07-01', '2026-07-14'); + expect(weeks).to.deep.equal([ + { startDate: '2026-07-01', endDate: '2026-07-07' }, + { startDate: '2026-07-08', endDate: '2026-07-14' }, + ]); + }); + + it('clamps the oldest week to startDate when the range is not a multiple of 7', () => { + const weeks = splitDateRangeIntoWeeksBackward('2026-07-03', '2026-07-14'); + expect(weeks[0]).to.deep.equal({ startDate: '2026-07-03', endDate: '2026-07-07' }); + expect(weeks[1]).to.deep.equal({ startDate: '2026-07-08', endDate: '2026-07-14' }); + }); + + it('caps the result at maxWeeks, keeping the most recent weeks', () => { + const weeks = splitDateRangeIntoWeeksBackward('2026-01-01', '2026-07-14', 7, 2); + expect(weeks).to.have.lengthOf(2); + expect(weeks[weeks.length - 1].endDate).to.equal('2026-07-14'); + }); + + it('returns a single week when the range is shorter than one week', () => { + const weeks = splitDateRangeIntoWeeksBackward('2026-07-12', '2026-07-14'); + expect(weeks).to.deep.equal([{ startDate: '2026-07-12', endDate: '2026-07-14' }]); + }); + }); +}); From f709d77000011c6578ce89ad533f00329bf9e5cc Mon Sep 17 00:00:00 2001 From: Vivek Singh Date: Thu, 16 Jul 2026 12:08:39 +0530 Subject: [PATCH 3/6] address review comments --- docs/index.html | 94 ++++++++++++- docs/openapi/api.yaml | 2 + docs/openapi/schemas.yaml | 46 +++++++ docs/openapi/serenity-api.yaml | 149 +++++++++++++++++++++ src/controllers/elements.js | 28 ++-- src/routes/required-capabilities.js | 2 +- test/controllers/elements.test.js | 91 +++++++++++-- test/openapi-contract/serenity-api.test.js | 42 ++++++ 8 files changed, 427 insertions(+), 27 deletions(-) diff --git a/docs/index.html b/docs/index.html index 377fce609..9b91527f9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -489,7 +489,7 @@ " aria-expanded="false" class="sc-tYrig hNMAOR">