Skip to content

Commit 29f2e35

Browse files
refactor: simplify brand-vs-competitors to single view query with aggregate param
Replace the two-step PostgREST pattern (discover dates from brand_presence_executions, then chunked .in() queries against the view) with a single direct query using .gte()/.lte() date-range filters on brand_vs_competitors_by_date. The regular VIEW supports WHERE pushdown into partition-pruned scans, making the separate date discovery step unnecessary. Add aggregate=true query parameter that rolls up across categoryName/regionCode server-side, producing one row per (competitor, executionDate) — the shape the Market Tracking chart needs directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a71fb9 commit 29f2e35

4 files changed

Lines changed: 247 additions & 123 deletions

File tree

docs/llmo-brandalf-apis/brand-vs-competitors-api.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Returns aggregated competitor mention/citation data for a site. Internally perfo
2727
| `model` || string | No | `chatgpt` | LLM model |
2828
| `categoryName` | `category_name` | string | No || Filter by category name |
2929
| `regionCode` | `region_code`, `region` | string | No || Filter by region code |
30+
| `aggregate` || boolean | No | `false` | Roll up across categoryName/regionCode |
3031

3132
---
3233

@@ -42,26 +43,34 @@ GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/brand-vs
4243
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/brand-vs-competitors?siteId=c2473d89-e997-458d-a86d-b4096649c12b&startDate=2026-01-01&endDate=2026-03-31&categoryName=SEO&regionCode=US
4344
```
4445

45-
---
46+
**Aggregated for Market Tracking chart (one row per competitor per week):**
47+
```
48+
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/brand-vs-competitors?siteId=c2473d89-e997-458d-a86d-b4096649c12b&aggregate=true
49+
```
4650

47-
## Internal Queries (PostgREST)
51+
---
4852

49-
This endpoint performs two sequential PostgREST queries:
53+
## Internal Query (PostgREST)
5054

51-
**Step 1 — Discover execution dates:**
52-
- Queries `brand_presence_executions` table
53-
- Selects `execution_date`, filtered by `organization_id`, `site_id`, `model`, date range (`gte`/`lte`), optionally `brand_id`
54-
- Deduplicates dates client-side via `Set`
55+
Single query against the `brand_vs_competitors_by_date` VIEW with date-range filters:
5556

56-
**Step 2 — Query competitor aggregation:**
57-
- Queries `brand_vs_competitors_by_date` VIEW
5857
- Selects: `site_id`, `brand_id`, `brand_name`, `model`, `execution_date`, `category_name`, `region_code`, `competitor`, `total_mentions`, `total_citations`
59-
- Uses `.in('execution_date', dates)` with chunking (50 dates per chunk)
58+
- Filters: `organization_id`, `site_id`, `model`, `execution_date` (gte/lte date range)
6059
- Optional filters: `brand_id`, `category_name`, `region_code`
61-
- Row limit: 5000 per chunk
60+
- Row limit: 5000
61+
62+
The VIEW is a regular (non-materialized) view — PostgreSQL pushes WHERE clauses through the GROUP BY into partition-pruned, index-covered scans on the source tables.
6263

6364
The underlying VIEW aggregates `executions_competitor_data` joined with `brand_presence_executions` and `organizations`, grouping by competitor (using `COALESCE(parent_company, competitor)` for fallback).
6465

66+
### Aggregation mode
67+
68+
By default, the response returns rows at `(competitor, executionDate, categoryName, regionCode)` granularity.
69+
70+
With `aggregate=true`, the server rolls up across `categoryName`/`regionCode` and returns one row per `(competitor, executionDate)` — the shape the **Market Tracking chart** needs directly. Aggregated rows omit `categoryName` and `regionCode`.
71+
72+
Category/region filters still apply *before* aggregation, so `aggregate=true&categoryName=SEO` returns chart-ready totals scoped to the SEO category.
73+
6574
---
6675

6776
## Response Format

docs/openapi/llmo-api.yaml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1966,9 +1966,10 @@ llmo-brand-vs-competitors:
19661966
summary: Get brand vs competitors aggregated data
19671967
description: |
19681968
Returns aggregated competitor mention/citation data for a site.
1969-
Internally performs two PostgREST queries: first discovers execution
1970-
dates from brand_presence_executions, then queries the
1971-
brand_vs_competitors_by_date view with those dates.
1969+
Queries the brand_vs_competitors_by_date view directly with date-range
1970+
filters. Response rows are at (competitor, executionDate, categoryName,
1971+
regionCode) granularity — aggregate across categoryName/regionCode
1972+
client-side for chart totals.
19721973
operationId: getBrandVsCompetitors
19731974
parameters:
19741975
- name: siteId
@@ -2011,6 +2012,16 @@ llmo-brand-vs-competitors:
20112012
description: Filter by region code (e.g. US, EU)
20122013
schema:
20132014
type: string
2015+
- name: aggregate
2016+
in: query
2017+
required: false
2018+
description: >
2019+
When true, rolls up across categoryName/regionCode to return one row
2020+
per (competitor, executionDate). Aggregated rows omit categoryName and
2021+
regionCode. Use for Market Tracking chart totals.
2022+
schema:
2023+
type: boolean
2024+
default: false
20142025
responses:
20152026
'200':
20162027
description: Aggregated competitor data per execution date

src/controllers/llmo/llmo-brand-presence.js

Lines changed: 66 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2537,17 +2537,44 @@ function parseBrandVsCompetitorsParams(context) {
25372537
model: q.model || q.platform,
25382538
categoryName: q.categoryName || q.category_name,
25392539
regionCode: q.regionCode || q.region_code || q.region,
2540+
aggregate: q.aggregate === 'true' || q.aggregate === true,
25402541
};
25412542
}
25422543

2544+
/**
2545+
* Rolls up view rows by (siteId, brandId, brandName, model, executionDate, competitor),
2546+
* summing totalMentions and totalCitations across categoryName/regionCode.
2547+
*/
2548+
function aggregateCompetitorRows(rows) {
2549+
const map = new Map();
2550+
for (const row of rows) {
2551+
const key = `${row.site_id}|${row.brand_id}|${row.model}|${row.execution_date}|${row.competitor}`;
2552+
const existing = map.get(key);
2553+
if (existing) {
2554+
existing.total_mentions += row.total_mentions || 0;
2555+
existing.total_citations += row.total_citations || 0;
2556+
} else {
2557+
map.set(key, {
2558+
site_id: row.site_id,
2559+
brand_id: row.brand_id,
2560+
brand_name: row.brand_name,
2561+
model: row.model,
2562+
execution_date: row.execution_date,
2563+
competitor: row.competitor,
2564+
total_mentions: row.total_mentions || 0,
2565+
total_citations: row.total_citations || 0,
2566+
});
2567+
}
2568+
}
2569+
return [...map.values()];
2570+
}
2571+
25432572
/**
25442573
* Creates the getBrandVsCompetitors handler.
2545-
* Single endpoint that internally makes two PostgREST calls:
2546-
* 1. Query brand_presence_executions to discover execution dates for the site
2547-
* 2. Query brand_vs_competitors_by_date VIEW with those dates
2548-
*
2549-
* This keeps date-range logic in the application layer while the DB provides
2550-
* simple view-based aggregation with partition-pruned queries.
2574+
* Queries the brand_vs_competitors_by_date VIEW directly with date-range filters.
2575+
* The VIEW is a regular (non-materialized) view — PostgreSQL pushes WHERE clauses
2576+
* through the GROUP BY into partition-pruned, index-covered scans on the source
2577+
* tables, so no separate execution-date discovery step is needed.
25512578
* @param {Function} getOrgAndValidateAccess - Async (context) => { organization }
25522579
*/
25532580
export function createBrandVsCompetitorsHandler(getOrgAndValidateAccess) {
@@ -2581,77 +2608,55 @@ export function createBrandVsCompetitorsHandler(getOrgAndValidateAccess) {
25812608
return forbidden('Site does not belong to the organization');
25822609
}
25832610

2584-
// Step 1: Get execution dates for the site
2585-
let datesQuery = client
2586-
.from('brand_presence_executions')
2587-
.select('execution_date')
2611+
// Query the view directly — date-range filters push down through the
2612+
// GROUP BY into partition-pruned scans on the underlying tables.
2613+
let q = client
2614+
.from('brand_vs_competitors_by_date')
2615+
.select('site_id, brand_id, brand_name, model, execution_date, category_name, region_code, competitor, total_mentions, total_citations')
25882616
.eq('organization_id', organizationId)
25892617
.eq('site_id', params.siteId)
25902618
.eq('model', model)
25912619
.gte('execution_date', startDate)
25922620
.lte('execution_date', endDate);
25932621

25942622
if (filterByBrandId) {
2595-
datesQuery = datesQuery.eq('brand_id', filterByBrandId);
2623+
q = q.eq('brand_id', filterByBrandId);
25962624
}
2597-
2598-
// Use high row limit — we only extract distinct dates, but the table may have
2599-
// many rows per date (multiple brands/models/categories). Same rationale as
2600-
// WEEKS_QUERY_LIMIT in createBrandPresenceWeeksHandler.
2601-
const { data: datesData, error: datesError } = await datesQuery.limit(WEEKS_QUERY_LIMIT);
2602-
2603-
if (datesError) {
2604-
ctx.log.error(`Brand vs competitors execution dates error: ${datesError.message}`);
2605-
return badRequest(datesError.message);
2625+
if (shouldApplyFilter(params.categoryName)) {
2626+
q = q.eq('category_name', params.categoryName);
26062627
}
2607-
2608-
const dateSet = new Set();
2609-
(datesData || []).forEach((r) => {
2610-
if (r.execution_date) dateSet.add(String(r.execution_date).slice(0, 10));
2611-
});
2612-
const executionDates = [...dateSet].sort((a, b) => b.localeCompare(a));
2613-
2614-
if (executionDates.length === 0) {
2615-
return ok({ competitorData: [] });
2628+
if (shouldApplyFilter(params.regionCode)) {
2629+
q = q.eq('region_code', params.regionCode);
26162630
}
26172631

2618-
// Step 2: Query the brand_vs_competitors_by_date view with those dates
2619-
const chunks = [];
2620-
for (let i = 0; i < executionDates.length; i += IN_FILTER_CHUNK_SIZE) {
2621-
chunks.push(executionDates.slice(i, i + IN_FILTER_CHUNK_SIZE));
2622-
}
2632+
const { data, error } = await q.limit(QUERY_LIMIT);
26232633

2624-
const results = await Promise.all(chunks.map((chunk) => {
2625-
let q = client
2626-
.from('brand_vs_competitors_by_date')
2627-
.select('site_id, brand_id, brand_name, model, execution_date, category_name, region_code, competitor, total_mentions, total_citations')
2628-
.eq('organization_id', organizationId)
2629-
.eq('model', model)
2630-
.in('execution_date', chunk)
2631-
.eq('site_id', params.siteId);
2632-
2633-
if (filterByBrandId) {
2634-
q = q.eq('brand_id', filterByBrandId);
2635-
}
2636-
if (shouldApplyFilter(params.categoryName)) {
2637-
q = q.eq('category_name', params.categoryName);
2638-
}
2639-
if (shouldApplyFilter(params.regionCode)) {
2640-
q = q.eq('region_code', params.regionCode);
2641-
}
2634+
if (error) {
2635+
ctx.log.error(`Brand vs competitors PostgREST error: ${error.message}`);
2636+
return badRequest(error.message);
2637+
}
26422638

2643-
return q.limit(QUERY_LIMIT);
2644-
}));
2639+
const rows = data || [];
26452640

2646-
const failed = results.find((r) => r.error);
2647-
if (failed) {
2648-
ctx.log.error(`Brand vs competitors PostgREST error: ${failed.error.message}`);
2649-
return badRequest(failed.error.message);
2641+
// When aggregate=true, roll up across categoryName/regionCode to produce
2642+
// one row per (competitor, executionDate) — the shape the Market Tracking
2643+
// chart needs directly.
2644+
if (params.aggregate) {
2645+
const aggregated = aggregateCompetitorRows(rows);
2646+
const competitorData = aggregated.map((row) => ({
2647+
siteId: row.site_id,
2648+
brandId: row.brand_id,
2649+
brandName: row.brand_name,
2650+
model: row.model,
2651+
executionDate: row.execution_date,
2652+
competitor: row.competitor,
2653+
totalMentions: row.total_mentions,
2654+
totalCitations: row.total_citations,
2655+
}));
2656+
return ok({ competitorData });
26502657
}
26512658

2652-
const allRows = results.flatMap((r) => r.data || []);
2653-
2654-
const competitorData = allRows.map((row) => ({
2659+
const competitorData = rows.map((row) => ({
26552660
siteId: row.site_id,
26562661
brandId: row.brand_id,
26572662
brandName: row.brand_name,

0 commit comments

Comments
 (0)