Skip to content

feat: brand-vs-competitors API endpoint with aggregate mode#2028

Open
rainer-friederich wants to merge 8 commits intomainfrom
feat/second-sheet-api-layer
Open

feat: brand-vs-competitors API endpoint with aggregate mode#2028
rainer-friederich wants to merge 8 commits intomainfrom
feat/second-sheet-api-layer

Conversation

@rainer-friederich
Copy link
Copy Markdown
Contributor

@rainer-friederich rainer-friederich commented Mar 24, 2026

Summary

  • Adds GET /org/:spaceCatId/brands/{all,:brandId}/brand-presence/brand-vs-competitors endpoint
  • Single PostgREST query against brand_vs_competitors_by_date VIEW with date-range filters (.gte()/.lte()) — PostgreSQL pushes WHERE clauses through the GROUP BY into partition-pruned, index-covered scans
  • Accepts siteId (required) + optional startDate/endDate/model/categoryName/regionCode/aggregate, defaulting to a 28-day date range
  • aggregate=true rolls up across categoryName/regionCode server-side, producing one row per (competitor, executionDate) — the shape the Market Tracking chart needs directly. Filters still apply before aggregation.
  • Uses resolveModelFromRequest for model resolution (consistent with all other handlers)
  • Uses WEEKS_QUERY_LIMIT (200K) to avoid silent data truncation for orgs with many competitors/categories/regions
  • Response schema declares required fields so the frontend knows which properties are guaranteed

Depends on

  • data-service PR #206brand_vs_competitors_by_date regular VIEW

CI will fail until #206 is merged, a new data-service image is published (e.g. v1.35.0), and the default image tag in test/it/postgres/docker-compose.yml is updated from v1.29.0 to the new version. The brand-vs-competitors IT tests require the view which only exists in the new image.

Response shape

Default (aggregate omitted or false): rows at (competitor, executionDate, categoryName, regionCode) granularity — includes categoryName and regionCode fields.

aggregate=true: rows at (competitor, executionDate) granularity — omits categoryName and regionCode, sums totalMentions/totalCitations across them.

Test plan

  • 15 unit tests covering auth, validation, single-query flow, camelCase transform, date range defaults, optional filters, aggregate mode, aggregate+filters
  • 10 integration tests against real PostgreSQL + PostgREST (validation, access control, date/category filtering, aggregate rollup, brandId scoping)
  • Route registration tests updated (6806 tests passing)
  • ESLint clean
  • OpenAPI specs valid (npm run docs:lint)

Running IT tests locally

The IT tests require the brand_vs_competitors_by_date view from data-service PR #206. Build the image locally:

cd apps/mysticat-data-service
git fetch origin feat/brand-vs-competitors-view
git worktree add /tmp/ds-view-build FETCH_HEAD
docker build -f /tmp/ds-view-build/docker/Dockerfile -t mysticat-data-service:local-with-view /tmp/ds-view-build
git worktree remove /tmp/ds-view-build

cd ../spacecat-api-service
IT_DATA_SERVICE_IMAGE=mysticat-data-service:local-with-view \
  npx mocha --require test/it/postgres/harness.js --timeout 30000 test/it/postgres/brand-vs-competitors.test.js

Once PR #206 merges and a new ECR image is published, update the default image tag in test/it/postgres/docker-compose.yml.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@rainer-friederich rainer-friederich changed the title feat: add execution-dates and brand-vs-competitors API endpoints feat: add brand-vs-competitors API endpoint Mar 24, 2026
@rainer-friederich
Copy link
Copy Markdown
Contributor Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@rainer-friederich rainer-friederich changed the title feat: add brand-vs-competitors API endpoint feat: brand-vs-competitors API endpoint with aggregate mode Mar 25, 2026
rainer-friederich and others added 6 commits March 25, 2026 08:42
Implements the two-step query pattern for the brand_vs_competitors_by_date
view from data-service PR-206: first get available execution dates for a
site, then query competitor aggregation data for selected dates. This keeps
date-range logic in the application layer while the DB provides simple
view-based aggregation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gREST calls

Replace the two separate endpoints (execution-dates + brand-vs-competitors)
with a single combined endpoint that internally performs both PostgREST
queries: first discovers execution dates from brand_presence_executions,
then queries the brand_vs_competitors_by_date view with those dates.

Accepts siteId (required) + optional startDate/endDate/model/categoryName/
regionCode. Defaults to 28-day date range like other brand-presence endpoints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add 401/500 responses to OpenAPI spec for brand-vs-competitors endpoint
- Add required and additionalProperties: false to BrandVsCompetitorsResponse schema
- Add UUID validation for siteId query parameter
- Add platform alias for model parameter (consistent with other handlers)
- Add missing test for Step 2 view query error path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents silent data truncation when a site has many execution rows
per date (multiple brands/models/categories). Same rationale as the
weeks handler which also needs all rows to extract distinct values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…regate 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>
…aggregate mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@rainer-friederich rainer-friederich force-pushed the feat/second-sheet-api-layer branch from 0b7c27b to d012cdb Compare March 25, 2026 08:42
@rainer-friederich
Copy link
Copy Markdown
Contributor Author

Code review

Found 1 issue:

  1. createBrandVsCompetitorsHandler uses params.model || 'chatgpt' instead of resolveModelFromRequest(params.model). The literal 'chatgpt' is not a valid llm_model enum value -- the correct default is 'chatgpt-free'. All other handlers in this file use resolveModelFromRequest, which maps aliases (e.g. 'chatgpt' -> 'chatgpt-free') and defaults to DEFAULT_MODEL. When no model query param is provided, this handler will send the invalid value 'chatgpt' to PostgREST, likely returning zero rows or an error. (CLAUDE.md says "Follow existing patterns: Examine similar features")

const filterByBrandId = brandId && brandId !== 'all' ? brandId : null;
const model = params.model || 'chatgpt';
const startDate = params.startDate || defaults.startDate;

Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

rainer-friederich and others added 2 commits March 25, 2026 09:07
…ema fields

- Replace `params.model || 'chatgpt'` with `resolveModelFromRequest(params.model)`
  to match all other handlers ('chatgpt' is not a valid llm_model enum value)
- Use WEEKS_QUERY_LIMIT (200K) instead of QUERY_LIMIT (5K) to avoid silent
  truncation — the view can return 60K+ rows for orgs with many competitors,
  categories, and regions
- Add required fields to BrandVsCompetitorsResponse item schema so the
  frontend knows which fields are guaranteed (categoryName/regionCode remain
  optional since they are omitted when aggregate=true)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 IT tests covering: validation (missing/invalid siteId), access control
(site not in org, denied user), date range filtering, category filtering,
empty results, camelCase response shape, aggregate=true rollup with correct
sums, and brandId-scoped queries.

docker-compose.yml now accepts IT_DATA_SERVICE_IMAGE env var to override
the data-service image — build locally from the view branch with:
  cd apps/mysticat-data-service
  git fetch origin feat/brand-vs-competitors-view
  docker build -f docker/Dockerfile -t mysticat-data-service:local-with-view .
  IT_DATA_SERVICE_IMAGE=mysticat-data-service:local-with-view npx mocha ...

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant