Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
530 changes: 530 additions & 0 deletions docs/elements/brand-presence-stats-plan.md

Large diffs are not rendered by default.

94 changes: 90 additions & 4 deletions docs/index.html

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ paths:
$ref: './serenity-api.yaml#/v2-serenity-deactivate'
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/url-inspector/filter-dimensions:
$ref: './serenity-api.yaml#/v2-serenity-url-inspector-filter-dimensions'
/v2/orgs/{spaceCatId}/brands/{brandId}/serenity/brand-presence/stats:
$ref: './serenity-api.yaml#/v2-serenity-brand-presence-stats'
/organizations/{organizationId}/sites:
$ref: './sites-api.yaml#/sites-for-organization'
/organizations/{organizationId}/entitlements:
Expand Down
46 changes: 46 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11644,6 +11644,52 @@ SerenityUrlInspectorFilterDimensions:
description: Tags grouped by a Semrush tag prefix not covered by the fixed dimensions above.
items: { $ref: '#/SerenityFilterDimensionItem' }

SerenityBrandPresenceStatsValues:
type: object
description: The four aggregated Brand Presence KPI values, shared by the top-level `stats` and each `trends[].data.stats` entry.
required: [total_executions, average_visibility_score, total_mentions, total_citations]
properties:
total_executions:
type: number
description: Total prompt executions in range, from the Semrush Total Executions element.
average_visibility_score:
type: number
description: Average visibility score as a 0-100 percentage (the Semrush Visibility element returns a 0-1 fraction, converted here).
total_mentions:
type: number
description: Total brand mention count, from the Semrush Mentions element.
total_citations:
type: number
description: Total brand citation count, from the Semrush Citations element.

SerenityBrandPresenceStats:
type: object
description: |
Aggregated Brand Presence KPI stats for a brand over a date range, backed by
the Semrush Elements API (Total Executions, Mentions, Visibility, Citations).
required: [stats]
properties:
stats:
allOf:
- $ref: '#/SerenityBrandPresenceStatsValues'
description: Aggregated stats for the full `[startDate, endDate]` range.
trends:
type: array
description: |
Present only when `showTrends`/`show_trends` is truthy. One entry per
week, split backward from `endDate` into up to 8 7-day weeks.
items:
type: object
required: [startDate, endDate, data]
properties:
startDate: { type: string, format: date }
endDate: { type: string, format: date }
data:
type: object
required: [stats]
properties:
stats: { $ref: '#/SerenityBrandPresenceStatsValues' }

# ── Referral Traffic PG schemas ───────────────────────────────────────────────

ReferralTrafficSource:
Expand Down
153 changes: 153 additions & 0 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1138,3 +1138,156 @@ v2-serenity-url-inspector-filter-dimensions:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'500': { $ref: './responses.yaml#/500' }

v2-serenity-brand-presence-stats:
parameters:
- name: spaceCatId
in: path
required: true
description: SpaceCat Organization ID (UUID)
schema:
type: string
format: uuid
- name: brandId
in: path
required: true
description: Brand ID (UUID-only on the /serenity/* surface)
schema:
type: string
format: uuid
get:
tags: [serenity]
summary: Aggregated Brand Presence KPI stats for a brand
description: |
Elements-backed equivalent of the Postgres-RPC `/brand-presence/stats`
endpoint. Returns rolled-up KPI numbers (total executions, average
visibility score, total mentions, total citations) for the selected date
range, sourced from the Semrush Total Executions, Mentions, Visibility,
and Citations elements. Optionally includes per-week trend data
(`trends`) for sparklines, split backward from `endDate` into up to 8
7-day weeks.

The effective `[startDate, endDate]` range (after defaulting) must not
exceed 56 days (8 weeks) — matching the Brand Presence date picker's
maximum selectable range and the trends fan-out cap; a wider range 400s.

`categoryId`/`categoryIds`, `topicIds`, `origin`, `userIntent`, and
`promptBranding` are accepted by the reference Postgres-backed endpoint
but are not yet supported here — the Elements API has no confirmed
filter equivalent for them today; they are currently no-ops.
operationId: getSerenityBrandPresenceStats
security:
- ims_key: []
parameters:
- name: startDate
in: query
required: false
description: Range start (YYYY-MM-DD). Defaults to 28 days before today.
schema: { type: string, format: date }
- name: start_date
in: query
required: false
description: Snake_case alias for `startDate`.
schema: { type: string, format: date }
- name: endDate
in: query
required: false
description: Range end (YYYY-MM-DD). Defaults to today.
schema: { type: string, format: date }
- name: end_date
in: query
required: false
description: Snake_case alias for `endDate`.
schema: { type: string, format: date }
- name: model
in: query
required: false
description: |
AI model to scope stats to. Accepts a Semrush Elements model name
directly, or a SpaceCat/UI platform code (translated server-side).
Defaults to `search-gpt` when omitted or unrecognized.
schema:
type: string
x-canonical-values:
- google-ai-mode
- grok-3
- google-ai-overview
- microsoft-copilot
- open-evidence
- gemini-2.5-flash
- claude-sonnet-4
- gpt-5
- deepseek
- search-gpt
- perplexity
x-known-aliases:
copilot: microsoft-copilot
gemini: gemini-2.5-flash
openai: gpt-5
chatgpt: search-gpt
- name: platform
in: query
required: false
description: Legacy alias for `model`; `model` takes precedence when both are given.
schema: { type: string }
- name: siteId
in: query
required: false
description: Must resolve to the same brand as `:brandId`; 400 if it belongs to a different brand. Used only as a cross-check — does not itself narrow the Semrush query.
schema: { type: string, format: uuid }
- name: site_id
in: query
required: false
description: Snake_case alias for `siteId`.
schema: { type: string, format: uuid }
- name: regionCode
in: query
required: false
description: |
One region+language code (e.g. `US`). Resolves to a single Semrush
project via the Markets element; 404 if it doesn't match any of the
brand's markets. When omitted, aggregates across every project the
brand owns.
schema: { type: string }
- name: region_code
in: query
required: false
description: Snake_case alias for `regionCode`.
schema: { type: string }
- name: region
in: query
required: false
description: Alias for `regionCode`.
schema: { type: string }
- name: showTrends
in: query
required: false
description: When truthy (`true`/`1`), includes the weekly `trends` array.
schema: { type: string }
- name: show_trends
in: query
required: false
description: Snake_case alias for `showTrends`.
schema: { type: string }
responses:
'200':
description: Brand Presence stats retrieved.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityBrandPresenceStats' }
'400': { $ref: './responses.yaml#/400' }
'403':
description: Caller lacks access to the organization.
'404':
description: Organization not found, brand not found for this organization, the brand has no resolvable Semrush workspace, `regionCode` doesn't match any Semrush market, or (when no `regionCode` is given) the brand has no Semrush projects configured at all.
'502':
description: Upstream returned a non-2xx response.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'503':
description: PostgREST client not configured for this deployment.
content:
application/json:
schema: { $ref: './schemas.yaml#/SerenityErrorResponse' }
'500': { $ref: './responses.yaml#/500' }
138 changes: 138 additions & 0 deletions src/controllers/elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ElementsTransportError } from '../support/elements/errors.js';
import { createElementsService } from '../support/elements/elements-service.js';
import { fetchOwnedUrlsTraffic, mergeOwnedUrlsTraffic } from '../support/elements/owned-urls-traffic.js';
import { mapWithConcurrency } from '../support/elements/concurrency.js';
import { addDaysToDate } from '../support/elements/week-utils.js';
import { resolveBrandWorkspace } from '../support/serenity/workspace-resolver.js';
import { cachedOk } from '../support/cached-response.js';
import AccessControlUtil from '../support/access-control-util.js';
Expand Down Expand Up @@ -129,6 +130,19 @@ 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. Uses `addDaysToDate` (anchored to T12:00:00Z) rather than
* `Date#setDate`, which operates in local time before `toISOString()` converts
* to UTC — avoiding a DST-boundary date-shift bug.
*/
function defaultStatsDateRange() {
const endDate = new Date().toISOString().slice(0, 10);
const startDate = addDaysToDate(endDate, -28);
return { startDate, endDate };
}

/**
* Splits a comma-separated query value into a trimmed, non-empty string array.
* `extractQuery` collapses repeated params (last value wins), so multi-valued
Expand All @@ -144,6 +158,27 @@ 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`.
*
* Exported (unlike this file's other private helpers) because `extractQuery`
* always yields string values from `URLSearchParams`, so the boolean/number
* branch below is unreachable through the HTTP query-string path and can only
* be exercised via a direct unit test.
*/
export 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.
Expand Down Expand Up @@ -739,12 +774,115 @@ export default function ElementsController(context, log, env) {
};
/* c8 ignore stop */

/**
* GET /v2/orgs/:spaceCatId/brands/:brandId/serenity/brand-presence/stats
* Elements-backed equivalent of the Postgres-RPC `/stats` endpoint (see
* llmo-brand-presence.js#createBrandPresenceStatsHandler): same response shape
* (`{ 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 {
const auth = await authorizeOrg(ctx);
if (auth.error) {
return auth.error;
}
const { spaceCatId, brandId } = ctx?.params ?? {};
const { workspaceId, 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 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 effectiveStartDate = startDate || defaultRange.startDate;
const effectiveEndDate = endDate || defaultRange.endDate;
// Bound the span (mirrors listOwnedUrls/listDomainUrls): the Brand Presence
// date picker only allows selecting up to 8 weeks, matching the trends
// fan-out cap (splitDateRangeIntoWeeksBackward's TRENDS_MAX_WEEKS), so a
// wider range can only come from a caller bypassing the UI.
const MAX_RANGE_DAYS = 56;
const spanDays = (Date.parse(`${effectiveEndDate}T00:00:00Z`)
- Date.parse(`${effectiveStartDate}T00:00:00Z`)) / 86400000;
if (spanDays > MAX_RANGE_DAYS) {
return badRequest(`Date range must not exceed ${MAX_RANGE_DAYS} days`);
}

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);
// An empty list here must not silently fall through to an unscoped
// (workspace-wide) Semrush query — that would return data for every
// brand/project in the subworkspace, not just this one. Fail explicitly.
if (projectIds.length === 0) {
return notFound(`No Semrush projects configured for brand: ${brandId}`);
}
}

const result = await service.getBrandPresenceStats(workspaceId, {
model: query.model,
platform: query.platform,
startDate: effectiveStartDate,
endDate: effectiveEndDate,
projectId,
projectIds,
brandName: brand.name,
showTrends: parseShowTrends(query),
});

return cachedOk(result);
} catch (e) {
return mapError(e, log);
}
};

return {
listUrlInspectorFilterDimensions,
listWeeks,
listPrompts,
listCitedDomains,
listOwnedUrls,
listDomainUrls,
getStats,
};
}
1 change: 1 addition & 0 deletions src/routes/facs-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,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',
Expand Down
Loading
Loading