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
4 changes: 2 additions & 2 deletions docs/index.html

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions docs/llmo-brandalf-apis/filter-dimensions-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Returns available filter options (brands, categories, topics, origins, regions,
| `model` | — | string | `chatgpt` | LLM model (e.g. chatgpt, gemini, copilot) |
| `siteId` | `site_id` | string (UUID) | — | Filter by site |
| `categoryId` | `category_id` | string (UUID or name) | — | Filter by category. If UUID → `category_id`; if not UUID (e.g. "Acrobat") → `category_name` |
| `topicId` | `topic_id`, `topic`, `topics` | string | — | Filter by topic (exact match on `topics` column) |
| `topicIds` | | string or array | — | Filter by topic UUID(s). Single UUID, comma-separated UUIDs (e.g. `uuid1,uuid2`), or repeated param. Non-UUID values are ignored. Uses `topic_id` column. |
| `regionCode` | `region_code`, `region` | string | — | Filter by region code (e.g. US, DE, WW) |
| `origin` | — | string | — | Filter by origin (exact match, case-insensitive; e.g. `human`, `ai`) |

Expand All @@ -49,12 +49,17 @@ Returns available filter options (brands, categories, topics, origins, regions,
## Sample URL (All Parameters)

```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=google-ai-mode&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=AI
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=google-ai-mode&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicIds=0178a3f0-1234-7000-8000-0000000000aa&regionCode=US&origin=AI
```

**Multiple topicIds (comma-separated):**
```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/all/brand-presence/filter-dimensions?topicIds=uuid1,uuid2,uuid3
```

**Single brand variant:**
```
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=chatgpt&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicId=combine%20pdf&regionCode=US&origin=AI
GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d1176af316/brand-presence/filter-dimensions?startDate=2025-09-27&endDate=2025-09-30&model=chatgpt&siteId=c2473d89-e997-458d-a86d-b4096649c12b&categoryId=Acrobat&topicIds=0178a3f0-1234-7000-8000-0000000000aa&regionCode=US&origin=AI
```

---
Expand All @@ -70,7 +75,7 @@ GET /org/44568c3e-efd4-4a7f-8ecd-8caf615f836c/brands/019cb903-1184-7f92-8325-f9d
{ "id": "Acrobat", "label": "Acrobat" }
],
"topics": [
{ "id": "combine pdf", "label": "combine pdf" }
{ "id": "0178a3f0-1234-7000-8000-0000000000aa", "label": "combine pdf" }
],
"origins": [
{ "id": "ai", "label": "ai" }
Expand All @@ -97,7 +102,7 @@ The API builds a PostgREST query against the `brand_presence_executions` table.
// Base query
client
.from('brand_presence_executions')
.select('brand_id, brand_name, category_name, topics, origin, region_code, site_id')
.select('brand_id, brand_name, category_name, topic_id, topics, origin, region_code, site_id')
.eq('organization_id', organizationId)
.gte('execution_date', startDate)
.lte('execution_date', endDate)
Expand All @@ -107,20 +112,22 @@ client
.eq('site_id', siteId) // if siteId
.eq('brand_id', brandId) // if brandId !== 'all' (path param)
.eq('category_id', categoryId) // if categoryId is valid UUID
.eq('category_name', categoryId) // if categoryId is NOT valid UUID (e.g. "Acrobat")
.eq('topics', topicId) // if topicId
.eq('category_name', categoryId) // if categoryId is NOT valid UUID (e.g. "Acrobat")
.in('topic_id', topicIds) // if topicIds (array of valid UUIDs; single or multiple)
.eq('region_code', regionCode) // if regionCode
.ilike('origin', origin) // if origin (exact match, case-insensitive)

.limit(5000)
```

**Equivalent PostgREST HTTP request** (example with all filters):
**Equivalent PostgREST HTTP request** (example with all filters, including multiple topicIds):
```
GET /brand_presence_executions?select=brand_id,brand_name,category_name,topics,origin,region_code&organization_id=eq.44568c3e-efd4-4a7f-8ecd-8caf615f836c&execution_date=gte.2025-09-27&execution_date=lte.2025-09-30&model=eq.google-ai-mode&site_id=eq.c2473d89-e997-458d-a86d-b4096649c12b&category_name=eq.Acrobat&topics=eq.combine%20pdf&region_code=eq.US&origin=ilike.ai&limit=5000
GET /brand_presence_executions?select=brand_id,brand_name,category_name,topic_id,topics,origin,region_code,site_id&organization_id=eq.44568c3e-efd4-4a7f-8ecd-8caf615f836c&execution_date=gte.2025-09-27&execution_date=lte.2025-09-30&model=eq.google-ai-mode&site_id=eq.c2473d89-e997-458d-a86d-b4096649c12b&category_name=eq.Acrobat&topic_id=in.(uuid1,uuid2)&region_code=eq.US&origin=ilike.ai&limit=5000
```

**Response processing:** The API deduplicates and sorts the results to build `brands`, `categories`, `topics`, `origins`, `regions`, and `page_intents` arrays. Each array is an array of `{ id, label }` objects.
**topicIds parsing:** Accepts `topicIds` as a comma-separated string (`uuid1,uuid2`), an array, or a single UUID. Non-UUID values are filtered out. The filter uses `topic_id IN (...)` (PostgREST `topic_id=in.(...)`).

**Response processing:** The API deduplicates and sorts the results to build `brands`, `categories`, `topics`, `origins`, `regions`, and `page_intents` arrays. Each array is an array of `{ id, label }` objects. For `topics`, `id` is the `topic_id` (UUID) and `label` is the denormalized topic name from the `topics` column; only rows with non-null `topic_id` are included.

---

Expand Down
41 changes: 34 additions & 7 deletions src/controllers/llmo/llmo-brand-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,26 @@ export function toFilterOption(id, label) {
return { id: id ?? '', label: label ?? id ?? '' };
}

/**
* Normalizes topicIds param to an array of valid UUIDs.
* Accepts topicIds as: array, comma-separated string, or single UUID.
* Non-UUID values are filtered out.
* @returns {string[]} Array of valid topic_id UUIDs, empty if none
*/
function parseTopicIds(q) {
const raw = q.topicIds;
if (raw == null) return [];
let arr;
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === 'string') {
arr = raw.split(',').map((s) => s.trim());
} else {
arr = [raw];
}
return arr.filter((id) => id != null && isValidUUID(String(id)));
}

function parseFilterDimensionsParams(context) {
const q = context.data || {};
return {
Expand All @@ -88,7 +108,7 @@ function parseFilterDimensionsParams(context) {
model: q.model,
siteId: q.siteId || q.site_id,
categoryId: q.categoryId || q.category_id,
topicId: q.topicId || q.topic_id || q.topic || q.topics,
topicIds: parseTopicIds(q),
regionCode: q.regionCode || q.region_code || q.region,
origin: q.origin,
user_intent: q.user_intent || q.userIntent,
Expand All @@ -111,12 +131,12 @@ function buildExecutionsQuery(client, organizationId, params, defaults, filterBy
const endDate = params.endDate || defaults.endDate;
const model = params.model || 'chatgpt';
const {
siteId, categoryId, topicId, regionCode, origin,
siteId, categoryId, topicIds, regionCode, origin,
} = params;

let q = client
.from('brand_presence_executions')
.select('brand_id, brand_name, category_name, topics, origin, region_code, site_id')
.select('brand_id, brand_name, category_name, topic_id, topics, origin, region_code, site_id')
.eq('organization_id', organizationId)
.gte('execution_date', startDate)
.lte('execution_date', endDate)
Expand All @@ -131,8 +151,8 @@ function buildExecutionsQuery(client, organizationId, params, defaults, filterBy
if (shouldApplyFilter(categoryId)) {
q = isValidUUID(categoryId) ? q.eq('category_id', categoryId) : q.eq('category_name', categoryId);
}
if (shouldApplyFilter(topicId)) {
q = q.eq('topics', topicId);
if (topicIds?.length > 0) {
q = q.in('topic_id', topicIds);
}
if (shouldApplyFilter(regionCode)) {
q = q.eq('region_code', regionCode);
Expand Down Expand Up @@ -239,8 +259,15 @@ function buildDimensionOptions(rows) {
const catNames = [...new Set(rows.map((r) => r.category_name).filter(Boolean))];
const categories = catNames.toSorted(strCompare).map((c) => toFilterOption(c, c));

const topicVals = [...new Set(rows.map((r) => r.topics).filter(Boolean))];
const topics = topicVals.toSorted(strCompare).map((t) => toFilterOption(t, t));
const topicEntries = new Map();
rows.forEach((r) => {
if (r.topic_id && !topicEntries.has(r.topic_id)) {
topicEntries.set(r.topic_id, r.topics || r.topic_id);
}
});
const topics = [...topicEntries.entries()]
.toSorted((a, b) => strCompare(a[1], b[1]))
.map(([id, label]) => toFilterOption(id, label));

const originVals = [...new Set(
rows.map((r) => r.origin).filter(Boolean).map((o) => o.toLowerCase()),
Expand Down
102 changes: 95 additions & 7 deletions test/controllers/llmo/llmo-brand-presence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ describe('llmo-brand-presence', () => {
model: 'gemini',
site_id: 'cccdac43-1a22-4659-9086-b762f59b9928',
category_id: '0178a3f0-1234-7000-8000-000000000099',
topic_id: 't1',
topicIds: '0178a3f0-1234-7000-8000-0000000000aa',
region_code: 'US',
user_intent: 'TRANSACTIONAL',
prompt_branding: 'true',
Expand All @@ -367,16 +367,20 @@ describe('llmo-brand-presence', () => {
expect(chainMock.lte).to.have.been.calledWith('execution_date', '2025-01-31');
expect(chainMock.eq).to.have.been.calledWith('model', 'gemini');
expect(chainMock.eq).to.have.been.calledWith('site_id', 'cccdac43-1a22-4659-9086-b762f59b9928');
expect(chainMock.in).to.have.been.calledWith('topic_id', ['0178a3f0-1234-7000-8000-0000000000aa']);
expect(chainMock.limit).to.have.been.calledWith(5000);
});

it('returns ok with brands, categories, topics, origins, regions, page_intents', async () => {
const topicId1 = '0178a3f0-1234-7000-8000-0000000000a1';
const topicId2 = '0178a3f0-1234-7000-8000-0000000000a2';
const brandData = {
data: [
{
brand_id: '0178a3f0-1234-7000-8000-000000000002',
brand_name: 'Brand A',
category_name: 'Cat1',
topic_id: topicId1,
topics: 't1',
region_code: 'US',
origin: 'human',
Expand All @@ -386,6 +390,7 @@ describe('llmo-brand-presence', () => {
brand_id: '0178a3f0-1234-7000-8000-000000000003',
brand_name: 'Brand B',
category_name: 'Cat2',
topic_id: topicId2,
topics: 't2',
region_code: 'DE',
origin: 'ai',
Expand Down Expand Up @@ -413,13 +418,50 @@ describe('llmo-brand-presence', () => {
expect(body.brands[0]).to.have.property('label');
expect(body.categories).to.have.lengthOf(2);
expect(body.topics).to.have.lengthOf(2);
expect(body.topics[0]).to.deep.include({ id: topicId1, label: 't1' });
expect(body.topics[1]).to.deep.include({ id: topicId2, label: 't2' });
expect(body.origins).to.have.lengthOf(2);
expect(body.regions).to.have.lengthOf(2);
expect(body.page_intents).to.have.lengthOf(2);
expect(body.page_intents[0]).to.have.property('id');
expect(body.page_intents[0]).to.have.property('label');
});

it('uses topic_id as label when topics is null or empty', async () => {
const topicIdNoLabel = '0178a3f0-1234-7000-8000-0000000000ff';
const brandData = {
data: [
{
brand_id: '0178a3f0-1234-7000-8000-000000000002',
brand_name: 'Brand A',
category_name: 'Cat1',
topic_id: topicIdNoLabel,
topics: null,
region_code: 'US',
origin: 'human',
site_id: 'cccdac43-1a22-4659-9086-b762f59b9928',
},
],
error: null,
};
const pageIntentsData = {
data: [{ page_intent: 'TRANSACTIONAL' }],
error: null,
};
mockContext.dataAccess.Site.postgrestService = createChainableMock(
brandData,
[brandData, pageIntentsData],
);

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
const result = await handler(mockContext);

expect(result.status).to.equal(200);
const body = await result.json();
expect(body.topics).to.have.lengthOf(1);
expect(body.topics[0]).to.deep.include({ id: topicIdNoLabel, label: topicIdNoLabel });
});

it('filters by brandId when single brand route', async () => {
const brandData = {
data: [
Expand Down Expand Up @@ -469,26 +511,72 @@ describe('llmo-brand-presence', () => {
expect(chainMock.eq).to.have.been.calledWith('category_name', 'Acrobat');
});

it('filters by topicId when provided', async () => {
it('filters by topicIds (single UUID) when provided', async () => {
const topicUuid = '0178a3f0-1234-7000-8000-0000000000aa';
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { topicIds: topicUuid };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.in).to.have.been.calledWith('topic_id', [topicUuid]);
});

it('filters by topicIds (comma-separated UUIDs) when provided', async () => {
const topicUuids = [
'0178a3f0-1234-7000-8000-0000000000aa',
'0178a3f0-1234-7000-8000-0000000000bb',
];
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { topicIds: topicUuids.join(',') };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.in).to.have.been.calledWith('topic_id', topicUuids);
});

it('filters by topicIds (array) when provided', async () => {
const topicUuids = [
'0178a3f0-1234-7000-8000-0000000000aa',
'0178a3f0-1234-7000-8000-0000000000bb',
];
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { topicIds: topicUuids };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.in).to.have.been.calledWith('topic_id', topicUuids);
});

it('ignores non-UUID topicIds values', async () => {
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { topicId: 'combine pdf' };
mockContext.data = { topicIds: 'combine pdf' };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.eq).to.have.been.calledWith('topics', 'combine pdf');
const topicIdCalls = chainMock.eq.getCalls().filter((c) => c.args[0] === 'topic_id');
const topicInCalls = chainMock.in.getCalls().filter((c) => c.args[0] === 'topic_id');
expect(topicIdCalls).to.have.lengthOf(0);
expect(topicInCalls).to.have.lengthOf(0);
});

it('accepts topic/topics as fallback for topicId', async () => {
it('does not apply topic filter when topicIds is non-string, non-array value that fails UUID validation', async () => {
const chainMock = createChainableMock({ data: [], error: null });
mockContext.data = { topic: 'combine pdf' };
mockContext.data = { topicIds: 12345 };
mockContext.dataAccess.Site.postgrestService = chainMock;

const handler = createFilterDimensionsHandler(getOrgAndValidateAccess);
await handler(mockContext);

expect(chainMock.eq).to.have.been.calledWith('topics', 'combine pdf');
const topicInCalls = chainMock.in.getCalls().filter((c) => c.args[0] === 'topic_id');
expect(topicInCalls).to.have.lengthOf(0);
});

it('filters by regionCode when provided', async () => {
Expand Down
Loading