From 5ff31120b1538bbf08a61b787238d8b336cd5b91 Mon Sep 17 00:00:00 2001 From: Vivek Singh Date: Wed, 25 Feb 2026 18:54:33 +0530 Subject: [PATCH] feat: llmo mysticat data access layer apis --- package-lock.json | 24 + package.json | 2 +- src/controllers/llmo/llmo-brand-presence.js | 569 ++++++++++++++++++ .../llmo/llmo-mysticat-controller.js | 66 ++ src/index.js | 3 + src/routes/index.js | 17 + .../llmo/llmo-brand-presence.test.js | 429 +++++++++++++ .../llmo/llmo-mysticat-controller.test.js | 130 ++++ test/routes/index.test.js | 30 + 9 files changed, 1269 insertions(+), 1 deletion(-) create mode 100644 src/controllers/llmo/llmo-brand-presence.js create mode 100644 src/controllers/llmo/llmo-mysticat-controller.js create mode 100644 test/controllers/llmo/llmo-brand-presence.test.js create mode 100644 test/controllers/llmo/llmo-mysticat-controller.test.js diff --git a/package-lock.json b/package-lock.json index 607a46a92..993696e03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -805,6 +805,7 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.0.tgz", "integrity": "sha512-3ZfFdjYtpv7RCgul9yyOBsRVsxLNapwt0YjASBhyzJGNjnPxrWDlqDtbpBdwAgA1Nuh9nmjzFDFu8CJWv6BMKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -8584,6 +8585,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -13034,6 +13036,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -13265,6 +13268,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -13471,6 +13475,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -13634,6 +13639,7 @@ "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -15616,6 +15622,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -15922,6 +15929,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -15968,6 +15976,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16443,6 +16452,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -17093,6 +17103,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -19282,6 +19293,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -23265,6 +23277,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -24320,6 +24333,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -27505,6 +27519,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -28174,6 +28189,7 @@ "resolved": "https://registry.npmjs.org/openai/-/openai-5.12.2.tgz", "integrity": "sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==", "license": "Apache-2.0", + "peer": true, "bin": { "openai": "bin/cli" }, @@ -29278,6 +29294,7 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -29288,6 +29305,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -29997,6 +30015,7 @@ "integrity": "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -31458,6 +31477,7 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -32530,6 +32550,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -33254,6 +33275,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -33518,6 +33540,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -33527,6 +33550,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/package.json b/package.json index da4f903ca..b141e824a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@adobe/fetch": "4.2.3", "@adobe/helix-shared-body-data": "2.2.3", "@adobe/helix-shared-bounce": "2.0.29", - "@adobe/spacecat-shared-vault-secrets": "1.0.0", "@adobe/helix-shared-utils": "3.0.2", "@adobe/helix-shared-wrap": "2.0.2", "@adobe/helix-status": "10.1.5", @@ -88,6 +87,7 @@ "@adobe/spacecat-shared-tier-client": "1.3.12", "@adobe/spacecat-shared-tokowaka-client": "1.9.0", "@adobe/spacecat-shared-utils": "1.96.3", + "@adobe/spacecat-shared-vault-secrets": "1.0.0", "@aws-sdk/client-s3": "3.996.0", "@aws-sdk/client-secrets-manager": "3.996.0", "@aws-sdk/client-sfn": "3.996.0", diff --git a/src/controllers/llmo/llmo-brand-presence.js b/src/controllers/llmo/llmo-brand-presence.js new file mode 100644 index 000000000..866a9568a --- /dev/null +++ b/src/controllers/llmo/llmo-brand-presence.js @@ -0,0 +1,569 @@ +/* + * Copyright 2025 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 { ok, badRequest, forbidden } from '@adobe/spacecat-shared-http-utils'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +/** + * Brand Presence API handlers for querying mysticat-data-service PostgreSQL tables. + * All handlers validate LLMO access and use Site.postgrestService for PostgREST queries. + */ + +const SKIP_VALUES = new Set(['all', '', undefined, null]); + +function shouldApplyFilter(value) { + if (value == null) return false; + if (typeof value === 'string' && SKIP_VALUES.has(value.trim())) return false; + return hasText(String(value)); +} + +function parseQueryParams(context) { + const q = context.data || {}; + return { + startDate: q.startDate || q.start_date, + endDate: q.endDate || q.end_date, + model: q.model, + category: q.category, + topic: q.topic, + region: q.region, + origin: q.origin, + promptBranding: q.promptBranding || q.prompt_branding, + sortBy: q.sortBy || q.sort_by || 'topics', + sortOrder: q.sortOrder || q.sort_order || 'asc', + page: Math.max(0, parseInt(q.page, 10) || 0), + pageSize: Math.min( + 100, + Math.max(1, parseInt(q.pageSize, 10) || parseInt(q.page_size, 10) || 25), + ), + q: q.q, + }; +} + +function toISOWeekKey(dateStr) { + const d = new Date(`${dateStr}T12:00:00Z`); + const day = (d.getUTCDay() + 6) % 7; + const thursday = new Date(d); + thursday.setUTCDate(d.getUTCDate() - day + 3); + const week1 = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 4)); + const weekNum = 1 + Math.floor((thursday - week1) / 604800000); + return `${thursday.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; +} + +/** + * Creates brand presence handlers. Requires getSiteAndValidateLlmo. + * getSiteAndValidateLlmo already checks LLMO config and org access. + */ +export function createBrandPresenceHandlers(getSiteAndValidateLlmo) { + const runWithPostgrest = async (context, handler) => { + const { log, dataAccess } = context; + const { Site } = dataAccess; + + if (!Site?.postgrestService) { + log.error('Brand presence APIs require PostgREST (DATA_SERVICE_PROVIDER=postgres)'); + return badRequest('Brand presence data is not available. PostgreSQL data service is required.'); + } + + try { + await getSiteAndValidateLlmo(context); + return await handler(context, Site.postgrestService); + } catch (error) { + if (error.message?.includes('belonging to the organization')) { + return forbidden('Only users belonging to the organization can view its sites'); + } + if (error.message?.includes('LLM Optimizer is not enabled')) { + return badRequest(error.message); + } + log.error(`Brand presence API error: ${error.message}`); + return badRequest(error.message); + } + }; + + const getFilterDimensions = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + let q = client + .from('brand_presence_executions') + .select('category_name, topics, region_code, origin, model') + .eq('site_id', siteId); + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + + const { data, error } = await q.limit(5000); + + if (error) return badRequest(error.message); + + const categories = [...new Set((data || []).map((r) => r.category_name).filter(Boolean))]; + const topics = [...new Set((data || []).flatMap((r) => (r.topics ? r.topics.split(',').map((t) => t.trim()) : [])).filter(Boolean))]; + const regions = [...new Set((data || []).map((r) => r.region_code).filter(Boolean))]; + const origins = [...new Set((data || []).map((r) => r.origin).filter(Boolean))]; + const models = [...new Set((data || []).map((r) => r.model).filter(Boolean))]; + + return ok({ + categories, + topics, + regions, + origins, + models, + }); + }); + return result; + }; + + const getWeeks = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const { data, error } = await client + .from('brand_presence_executions') + .select('execution_date') + .eq('site_id', siteId) + .order('execution_date', { ascending: false }) + .limit(1000); + + if (error) return badRequest(error.message); + + const weekKeys = (data || []).map((r) => toISOWeekKey(r.execution_date)); + const weeks = [...new Set(weekKeys)].slice(0, 52); + + return ok(weeks); + }); + return result; + }; + + const getMetadata = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const { data: totalData } = await client + .from('brand_presence_executions') + .select('id') + .eq('site_id', siteId) + .limit(1); + + const weekAgo = new Date(); + weekAgo.setDate(weekAgo.getDate() - 7); + const weekAgoStr = weekAgo.toISOString().slice(0, 10); + + const { data: lastWeekData } = await client + .from('brand_presence_executions') + .select('id') + .eq('site_id', siteId) + .gte('execution_date', weekAgoStr) + .limit(1); + + return ok({ + has_data: (totalData?.length ?? 0) > 0, + has_data_last_week: (lastWeekData?.length ?? 0) > 0, + }); + }); + return result; + }; + + const getStats = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + let q = client + .from('brand_presence_executions') + .select('visibility_score, mentions, id') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.topic)) q = q.eq('topics', params.topic); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) q = q.eq('origin', params.origin); + + const { data, error } = await q.limit(10000); + + if (error) return badRequest(error.message); + + const records = data || []; + const visibilityScores = records + .filter((r) => r.visibility_score != null) + .map((r) => r.visibility_score); + const avgVisibility = visibilityScores.length + ? Math.round(visibilityScores.reduce((a, b) => a + b, 0) / visibilityScores.length) + : 0; + const brandMentions = records.filter((r) => r.mentions === true).length; + + const execIds = records.map((r) => r.id).filter(Boolean); + const citedExecutionIds = new Set(); + if (execIds.length > 0) { + const { data: sourcesData } = await client + .from('brand_presence_sources') + .select('execution_id') + .in('execution_id', execIds) + .eq('content_type', 'owned'); + (sourcesData || []).forEach((s) => citedExecutionIds.add(s.execution_id)); + } + const citations = records.filter((r) => citedExecutionIds.has(r.id)).length; + + return ok({ + visibility_score: avgVisibility, + brand_mentions: brandMentions, + citations, + top_competitors: [], + top_topics: [], + }); + }); + return result; + }; + + const getSentimentOverview = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + let q = client + .from('brand_presence_executions') + .select('execution_date, sentiment') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.topic)) q = q.eq('topics', params.topic); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) q = q.eq('origin', params.origin); + + const { data, error } = await q.limit(50000); + + if (error) return badRequest(error.message); + + const byWeek = {}; + (data || []).forEach((r) => { + const d = new Date(r.execution_date); + const weekStart = new Date(d); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + const weekKey = weekStart.toISOString().slice(0, 10); + if (!byWeek[weekKey]) { + byWeek[weekKey] = { + week: weekKey, + total_prompts: 0, + prompts_with_sentiment: 0, + positive: 0, + neutral: 0, + negative: 0, + }; + } + byWeek[weekKey].total_prompts += 1; + if (r.sentiment) { + byWeek[weekKey].prompts_with_sentiment += 1; + if (r.sentiment === 'positive') byWeek[weekKey].positive += 1; + else if (r.sentiment === 'neutral') byWeek[weekKey].neutral += 1; + else if (r.sentiment === 'negative') byWeek[weekKey].negative += 1; + } + }); + + const weeks = Object.values(byWeek).sort((a, b) => a.week.localeCompare(b.week)); + return ok(weeks); + }); + return result; + }; + + const getWeeklyTrends = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + let q = client + .from('brand_metrics_weekly') + .select('week, model, brand_name, category_name, region_code, mentions_count, citations_count, prompt_count') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('week', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('week', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + + const { data, error } = await q.order('week', { ascending: true }).limit(500); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getTopics = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + const orderCol = params.sortBy || 'topics'; + const orderAsc = (params.sortOrder || 'asc').toLowerCase() !== 'desc'; + const offset = params.page * params.pageSize; + const limit = params.pageSize; + + let q = client + .from('brand_presence_topics_by_date') + .select('*') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.topic)) q = q.eq('topics', params.topic); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) q = q.eq('origin', params.origin); + + const { data, error } = await q + .order(orderCol, { ascending: orderAsc }) + .range(offset, offset + limit - 1); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getTopicPrompts = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId, topic } = ctx.params; + const params = parseQueryParams(ctx); + + const orderCol = params.sortBy || 'prompt'; + const orderAsc = (params.sortOrder || 'asc').toLowerCase() !== 'desc'; + + let q = client + .from('brand_presence_prompts_by_date') + .select('*') + .eq('site_id', siteId) + .eq('topics', decodeURIComponent(topic)); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) q = q.eq('origin', params.origin); + + const { data, error } = await q.order(orderCol, { ascending: orderAsc }).limit(500); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getSearch = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + const searchTerm = (params.q || '').trim(); + + if (searchTerm.length < 2) { + return badRequest('Search query must be at least 2 characters'); + } + + const limit = params.pageSize; + + let topicsQ = client + .from('brand_presence_topics_by_date') + .select('topics, category_name, region_code, origin, executions_count, mentions_count') + .eq('site_id', siteId) + .ilike('topics', `%${searchTerm}%`); + + if (shouldApplyFilter(params.startDate)) topicsQ = topicsQ.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) topicsQ = topicsQ.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) topicsQ = topicsQ.eq('model', params.model); + if (shouldApplyFilter(params.category)) topicsQ = topicsQ.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) topicsQ = topicsQ.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) topicsQ = topicsQ.eq('origin', params.origin); + + const { data: topicsData, error: topicsError } = await topicsQ.limit(limit); + + if (topicsError) return badRequest(topicsError.message); + + let promptsQ = client + .from('brand_presence_prompts_by_date') + .select('prompt, category_name, region_code, origin') + .eq('site_id', siteId) + .ilike('prompt', `%${searchTerm}%`); + + if (shouldApplyFilter(params.startDate)) promptsQ = promptsQ.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) promptsQ = promptsQ.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) promptsQ = promptsQ.eq('model', params.model); + if (shouldApplyFilter(params.category)) promptsQ = promptsQ.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) promptsQ = promptsQ.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) promptsQ = promptsQ.eq('origin', params.origin); + + const { data: promptsData, error: promptsError } = await promptsQ.limit(limit); + + if (promptsError) return badRequest(promptsError.message); + + const topicResults = (topicsData || []).map((r) => ({ + match_type: 'topic', + matched: r.topics, + category_name: r.category_name, + region_code: r.region_code, + origin: r.origin, + executions_count: r.executions_count, + mentions_count: r.mentions_count, + })); + const promptResults = (promptsData || []).map((r) => ({ + match_type: 'prompt', + matched: r.prompt, + category_name: r.category_name, + region_code: r.region_code, + origin: r.origin, + })); + + const combined = [...topicResults, ...promptResults].slice(0, limit); + return ok(combined); + }); + return result; + }; + + const getShareOfVoice = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + let q = client + .from('executions_competitor_data') + .select('*') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + + const { data, error } = await q.limit(1000); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getCompetitorTrends = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + let q = client + .from('executions_competitor_data') + .select('*') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + + const { data, error } = await q.order('execution_date', { ascending: true }).limit(2000); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getPrompts = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const params = parseQueryParams(ctx); + + const offset = params.page * params.pageSize; + const limit = params.pageSize; + + let q = client + .from('brand_presence_prompts_by_date') + .select('*') + .eq('site_id', siteId); + + if (shouldApplyFilter(params.startDate)) q = q.gte('execution_date', params.startDate); + if (shouldApplyFilter(params.endDate)) q = q.lte('execution_date', params.endDate); + if (shouldApplyFilter(params.model)) q = q.eq('model', params.model); + if (shouldApplyFilter(params.category)) q = q.eq('category_name', params.category); + if (shouldApplyFilter(params.topic)) q = q.eq('topics', params.topic); + if (shouldApplyFilter(params.region)) q = q.eq('region_code', params.region); + if (shouldApplyFilter(params.origin)) q = q.eq('origin', params.origin); + + const { data, error } = await q + .order('topics', { ascending: true }) + .order('prompt', { ascending: true }) + .range(offset, offset + limit - 1); + + if (error) return badRequest(error.message); + + return ok(data || []); + }); + return result; + }; + + const getSources = async (context) => { + const result = await runWithPostgrest(context, async (ctx, client) => { + const { siteId } = ctx.params; + const q = ctx.data || {}; + const executionId = q.executionId || q.execution_id; + + if (shouldApplyFilter(executionId)) { + const { data, error } = await client + .from('brand_presence_sources') + .select('*') + .eq('execution_id', executionId); + + if (error) return badRequest(error.message); + return ok(data || []); + } + + const { data: execs } = await client + .from('brand_presence_executions') + .select('id') + .eq('site_id', siteId); + + if (!execs?.length) return ok([]); + + const execIds = execs.map((e) => e.id).slice(0, 100); + const { data, error } = await client + .from('brand_presence_sources') + .select('*') + .in('execution_id', execIds); + + if (error) return badRequest(error.message); + return ok(data || []); + }); + return result; + }; + + return { + getFilterDimensions, + getWeeks, + getMetadata, + getStats, + getSentimentOverview, + getWeeklyTrends, + getTopics, + getTopicPrompts, + getSearch, + getShareOfVoice, + getCompetitorTrends, + getPrompts, + getSources, + }; +} diff --git a/src/controllers/llmo/llmo-mysticat-controller.js b/src/controllers/llmo/llmo-mysticat-controller.js new file mode 100644 index 000000000..92ac24d94 --- /dev/null +++ b/src/controllers/llmo/llmo-mysticat-controller.js @@ -0,0 +1,66 @@ +/* + * Copyright 2025 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 { Entitlement as EntitlementModel } from '@adobe/spacecat-shared-data-access'; +import AccessControlUtil from '../../support/access-control-util.js'; +import { createBrandPresenceHandlers } from './llmo-brand-presence.js'; + +/** + * Controller for LLMO + Mysticat (mysticat-data-service / PostgreSQL) endpoints. + * Handles Brand Presence APIs that query PostgREST tables. + */ +function LlmoMysticatController(ctx) { + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + const getSiteAndValidateLlmo = async (context) => { + const { siteId } = context.params; + const { dataAccess } = context; + const { Site } = dataAccess; + + const site = await Site.findById(siteId); + const config = site.getConfig(); + const llmoConfig = config.getLlmoConfig(); + + if (!llmoConfig?.dataFolder) { + throw new Error('LLM Optimizer is not enabled for this site, add llmo config to the site'); + } + const hasAccessToElmo = await accessControlUtil.hasAccess( + site, + '', + EntitlementModel.PRODUCT_CODES.LLMO, + ); + if (!hasAccessToElmo) { + throw new Error('Only users belonging to the organization can view its sites'); + } + return { site, config, llmoConfig }; + }; + + const brandPresence = createBrandPresenceHandlers(getSiteAndValidateLlmo); + + return { + getFilterDimensions: brandPresence.getFilterDimensions, + getWeeks: brandPresence.getWeeks, + getMetadata: brandPresence.getMetadata, + getStats: brandPresence.getStats, + getSentimentOverview: brandPresence.getSentimentOverview, + getWeeklyTrends: brandPresence.getWeeklyTrends, + getTopics: brandPresence.getTopics, + getTopicPrompts: brandPresence.getTopicPrompts, + getSearch: brandPresence.getSearch, + getShareOfVoice: brandPresence.getShareOfVoice, + getCompetitorTrends: brandPresence.getCompetitorTrends, + getPrompts: brandPresence.getPrompts, + getSources: brandPresence.getSources, + }; +} + +export default LlmoMysticatController; diff --git a/src/index.js b/src/index.js index 1f56bd33d..c31372dd7 100644 --- a/src/index.js +++ b/src/index.js @@ -71,6 +71,7 @@ import ScrapeController from './controllers/scrape.js'; import ScrapeJobController from './controllers/scrapeJob.js'; import ReportsController from './controllers/reports.js'; import LlmoController from './controllers/llmo/llmo.js'; +import LlmoMysticatController from './controllers/llmo/llmo-mysticat-controller.js'; import UserActivitiesController from './controllers/user-activities.js'; import SiteEnrollmentsController from './controllers/site-enrollments.js'; import TrialUsersController from './controllers/trial-users.js'; @@ -195,6 +196,7 @@ async function run(request, context) { const scrapeJobController = ScrapeJobController(context); const reportsController = ReportsController(context, log, context.env); const llmoController = LlmoController(context); + const llmoMysticatController = LlmoMysticatController(context); const fixesController = new FixesController(context); const userActivitiesController = UserActivitiesController(context); const siteEnrollmentsController = SiteEnrollmentsController(context); @@ -235,6 +237,7 @@ async function run(request, context) { trafficController, fixesController, llmoController, + llmoMysticatController, userActivitiesController, siteEnrollmentsController, trialUsersController, diff --git a/src/routes/index.js b/src/routes/index.js index b6f1ac92d..bc57c0959 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -73,6 +73,7 @@ function isStaticRoute(routePattern) { * @param {Object} trafficController - The traffic controller. * @param {FixesController} fixesController - The fixes controller. * @param {Object} llmoController - The LLMO controller. + * @param {Object} llmoMysticatController - The LLMO Mysticat controller (brand presence APIs). * @param {Object} userActivityController - The user activity controller. * @param {Object} siteEnrollmentController - The site enrollment controller. * @param {Object} trialUserController - The trial user controller. @@ -114,6 +115,7 @@ export default function getRouteHandlers( trafficController, fixesController, llmoController, + llmoMysticatController, userActivityController, siteEnrollmentController, trialUserController, @@ -376,6 +378,21 @@ export default function getRouteHandlers( 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, 'POST /sites/:siteId/llmo/edge-optimize-routing': llmoController.updateEdgeOptimizeCDNRouting, + // Brand Presence (GET only, PostgREST/mysticat-data-service) + 'GET /sites/:siteId/llmo/brand-presence/filter-dimensions': llmoMysticatController.getFilterDimensions, + 'GET /sites/:siteId/llmo/brand-presence/weeks': llmoMysticatController.getWeeks, + 'GET /sites/:siteId/llmo/brand-presence/metadata': llmoMysticatController.getMetadata, + 'GET /sites/:siteId/llmo/brand-presence/stats': llmoMysticatController.getStats, + 'GET /sites/:siteId/llmo/brand-presence/sentiment-overview': llmoMysticatController.getSentimentOverview, + 'GET /sites/:siteId/llmo/brand-presence/weekly-trends': llmoMysticatController.getWeeklyTrends, + 'GET /sites/:siteId/llmo/brand-presence/topics': llmoMysticatController.getTopics, + 'GET /sites/:siteId/llmo/brand-presence/topics/:topic/prompts': llmoMysticatController.getTopicPrompts, + 'GET /sites/:siteId/llmo/brand-presence/search': llmoMysticatController.getSearch, + 'GET /sites/:siteId/llmo/brand-presence/share-of-voice': llmoMysticatController.getShareOfVoice, + 'GET /sites/:siteId/llmo/brand-presence/competitor-trends': llmoMysticatController.getCompetitorTrends, + 'GET /sites/:siteId/llmo/brand-presence/prompts': llmoMysticatController.getPrompts, + 'GET /sites/:siteId/llmo/brand-presence/sources': llmoMysticatController.getSources, + // Tier Specific Routes 'GET /sites/:siteId/user-activities': userActivityController.getBySiteID, 'POST /sites/:siteId/user-activities': userActivityController.createTrialUserActivity, diff --git a/test/controllers/llmo/llmo-brand-presence.test.js b/test/controllers/llmo/llmo-brand-presence.test.js new file mode 100644 index 000000000..94d6f5b74 --- /dev/null +++ b/test/controllers/llmo/llmo-brand-presence.test.js @@ -0,0 +1,429 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { createBrandPresenceHandlers } from '../../../src/controllers/llmo/llmo-brand-presence.js'; + +use(sinonChai); + +function createChainableMock(resolveValue = { data: [], error: null }) { + const c = { + from: sinon.stub().returnsThis(), + select: sinon.stub().returnsThis(), + eq: sinon.stub().returnsThis(), + gte: sinon.stub().returnsThis(), + lte: sinon.stub().returnsThis(), + order: sinon.stub().returnsThis(), + ilike: sinon.stub().returnsThis(), + in: sinon.stub().returnsThis(), + limit: sinon.stub().resolves(resolveValue), + range: sinon.stub().resolves(resolveValue), + then(resolve) { return Promise.resolve(resolveValue).then(resolve); }, + }; + return c; +} + +describe('llmo-brand-presence', () => { + let sandbox; + let getSiteAndValidateLlmo; + let mockContext; + let mockClient; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + getSiteAndValidateLlmo = sandbox.stub().resolves({ site: {}, config: {}, llmoConfig: {} }); + mockContext = { + params: { siteId: '0178a3f0-1234-7000-8000-000000000001', topic: 'test-topic' }, + data: {}, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + dataAccess: { + Site: { + postgrestService: null, + }, + }, + }; + mockClient = createChainableMock(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('runWithPostgrest', () => { + it('returns badRequest when postgrestService is missing', async () => { + mockContext.dataAccess.Site.postgrestService = null; + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getFilterDimensions(mockContext); + + expect(result.status).to.equal(400); + expect(getSiteAndValidateLlmo).not.to.have.been.called; + }); + + it('returns forbidden when user has no org access', async () => { + mockContext.dataAccess.Site.postgrestService = mockClient; + getSiteAndValidateLlmo.rejects(new Error('Only users belonging to the organization can view its sites')); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getFilterDimensions(mockContext); + + expect(result.status).to.equal(403); + }); + + it('returns badRequest when LLMO is not enabled', async () => { + mockContext.dataAccess.Site.postgrestService = mockClient; + getSiteAndValidateLlmo.rejects(new Error('LLM Optimizer is not enabled for this site, add llmo config to the site')); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getFilterDimensions(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns badRequest when generic error is thrown', async () => { + mockContext.dataAccess.Site.postgrestService = mockClient; + getSiteAndValidateLlmo.rejects(new Error('Database connection failed')); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getFilterDimensions(mockContext); + + expect(result.status).to.equal(400); + expect(mockContext.log.error).to.have.been.calledWith('Brand presence API error: Database connection failed'); + }); + }); + + describe('getFilterDimensions', () => { + it('returns ok with categories, topics, regions, origins, models', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [ + { + category_name: 'Cat1', + topics: 't1', + region_code: 'US', + origin: 'human', + model: 'chatgpt', + }, + { + category_name: 'Cat2', + topics: 't2,t3', + region_code: 'DE', + origin: 'ai', + model: 'gemini', + }, + ], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getFilterDimensions(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.categories).to.include('Cat1', 'Cat2'); + expect(body.topics).to.include('t1', 't2', 't3'); + expect(body.regions).to.include('US', 'DE'); + expect(body.origins).to.include('human', 'ai'); + expect(body.models).to.include('chatgpt', 'gemini'); + }); + }); + + describe('getWeeks', () => { + it('returns ok with weeks array', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ execution_date: '2025-01-15' }, { execution_date: '2025-01-20' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getWeeks(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getMetadata', () => { + it('returns has_data and has_data_last_week', async () => { + let limitCalls = 0; + const client = createChainableMock({ data: [], error: null }); + client.limit = sandbox.stub().callsFake(() => { + limitCalls += 1; + return Promise.resolve({ + data: limitCalls === 1 ? [{ id: '1' }] : [], + error: null, + }); + }); + mockContext.dataAccess.Site.postgrestService = client; + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getMetadata(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.have.property('has_data'); + expect(body).to.have.property('has_data_last_week'); + }); + }); + + describe('getStats', () => { + it('returns visibility_score, brand_mentions, citations', async () => { + const execData = { + data: [ + { id: 'exec-1', visibility_score: 80, mentions: true }, + { id: 'exec-2', visibility_score: 60, mentions: false }, + ], + error: null, + }; + const sourcesData = { data: [{ execution_id: 'exec-1' }], error: null }; + let fromCallCount = 0; + const client = { + from: sandbox.stub().callsFake(() => { + fromCallCount += 1; + const resp = fromCallCount === 1 ? execData : sourcesData; + const chain = { + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + gte: sandbox.stub().returnsThis(), + lte: sandbox.stub().returnsThis(), + in: sandbox.stub().returnsThis(), + limit: sandbox.stub().resolves(resp), + get then() { + return (resolve) => Promise.resolve(resp).then(resolve); + }, + }; + chain.from = client.from; + return chain; + }), + }; + + mockContext.dataAccess.Site.postgrestService = client; + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getStats(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.have.property('visibility_score'); + expect(body).to.have.property('brand_mentions'); + expect(body).to.have.property('citations'); + }); + }); + + describe('getSentimentOverview', () => { + it('returns weeks array', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ execution_date: '2025-01-15', sentiment: 'positive' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSentimentOverview(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + + it('aggregates positive, neutral, and negative sentiment by week', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [ + { execution_date: '2025-01-13', sentiment: 'positive' }, + { execution_date: '2025-01-14', sentiment: 'neutral' }, + { execution_date: '2025-01-15', sentiment: 'negative' }, + ], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSentimentOverview(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body).to.have.lengthOf(1); + expect(body[0]).to.have.property('positive', 1); + expect(body[0]).to.have.property('neutral', 1); + expect(body[0]).to.have.property('negative', 1); + expect(body[0].prompts_with_sentiment).to.equal(3); + }); + }); + + describe('getWeeklyTrends', () => { + it('returns weekly metrics', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ week: '2025-W03', mentions_count: 10, citations_count: 5 }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getWeeklyTrends(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getTopics', () => { + it('returns topics', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ topics: 'test', category_name: 'Cat1' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getTopics(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getTopicPrompts', () => { + it('returns prompts for topic', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ prompt: 'What is X?', topics: 'test-topic' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getTopicPrompts(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getSearch', () => { + it('returns badRequest when q is too short', async () => { + mockContext.data = { q: 'a' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock(); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSearch(mockContext); + + expect(result.status).to.equal(400); + }); + + it('returns combined results when q is valid', async () => { + mockContext.data = { q: 'creative' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ topics: 'creative tools', category_name: 'Cat1' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSearch(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getShareOfVoice', () => { + it('returns competitor data', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ competitor: 'Canva', mentions: 5 }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getShareOfVoice(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getCompetitorTrends', () => { + it('returns competitor trends', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ execution_date: '2025-01-15', competitor: 'Figma' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getCompetitorTrends(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getPrompts', () => { + it('returns prompts', async () => { + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ prompt: 'What?', topics: 't1' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getPrompts(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + }); + + describe('getSources', () => { + it('returns sources for executionId', async () => { + mockContext.data = { executionId: '0178a3f2-8f5a-7e04-8d2a-9f3b1c4e5d6e' }; + mockContext.dataAccess.Site.postgrestService = createChainableMock({ + data: [{ url: 'https://example.com', execution_id: '0178a3f2-8f5a-7e04-8d2a-9f3b1c4e5d6e' }], + error: null, + }); + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSources(mockContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(Array.isArray(body)).to.be.true; + }); + + it('returns sources when no executionId (site-level)', async () => { + mockContext.data = {}; + let fromCall = 0; + const client = { + from: sandbox.stub().callsFake(() => { + fromCall += 1; + const resp = fromCall === 1 + ? { data: [{ id: 'exec-1' }], error: null } + : { data: [{ url: 'https://x.com' }], error: null }; + const chain = { + select: sandbox.stub().returnsThis(), + eq: sandbox.stub().returnsThis(), + in: sandbox.stub().returnsThis(), + limit: sandbox.stub().resolves(resp), + range: sandbox.stub().resolves(resp), + then(resolve) { return Promise.resolve(resp).then(resolve); }, + }; + return chain; + }), + }; + mockContext.dataAccess.Site.postgrestService = client; + + const handlers = createBrandPresenceHandlers(getSiteAndValidateLlmo); + const result = await handlers.getSources(mockContext); + + expect(result.status).to.equal(200); + }); + }); +}); diff --git a/test/controllers/llmo/llmo-mysticat-controller.test.js b/test/controllers/llmo/llmo-mysticat-controller.test.js new file mode 100644 index 000000000..ba011234c --- /dev/null +++ b/test/controllers/llmo/llmo-mysticat-controller.test.js @@ -0,0 +1,130 @@ +/* + * Copyright 2025 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. + */ + +/* eslint-env mocha */ +import { expect, use } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +use(sinonChai); + +describe('LlmoMysticatController', () => { + let sandbox; + let mockContext; + let LlmoMysticatController; + + beforeEach(async () => { + sandbox = sinon.createSandbox(); + const mockLlmoConfig = { dataFolder: 'test-folder' }; + const mockConfig = { getLlmoConfig: sandbox.stub().returns(mockLlmoConfig) }; + const mockSite = { + getConfig: sandbox.stub().returns(mockConfig), + getId: sandbox.stub().returns('site-123'), + }; + + const chainableMock = () => { + const c = {}; + c.from = sandbox.stub().returns(c); + c.select = sandbox.stub().returns(c); + c.eq = sandbox.stub().returns(c); + c.gte = sandbox.stub().returns(c); + c.lte = sandbox.stub().returns(c); + c.order = sandbox.stub().returns(c); + c.ilike = sandbox.stub().returns(c); + c.in = sandbox.stub().returns(c); + c.limit = sandbox.stub().resolves({ data: [], error: null }); + c.range = sandbox.stub().resolves({ data: [], error: null }); + c.then = (resolve) => Promise.resolve({ data: [], error: null }).then(resolve); + return c; + }; + + mockContext = { + params: { siteId: '0178a3f0-1234-7000-8000-000000000001' }, + dataAccess: { + Site: { + findById: sandbox.stub().resolves(mockSite), + postgrestService: chainableMock(), + }, + }, + log: { info: sandbox.stub(), error: sandbox.stub(), warn: sandbox.stub() }, + }; + + LlmoMysticatController = await esmock('../../../src/controllers/llmo/llmo-mysticat-controller.js', { + '../../../src/support/access-control-util.js': { + default: { + fromContext: () => ({ + hasAccess: sandbox.stub().resolves(true), + }), + }, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns all brand presence handler methods', () => { + const controller = LlmoMysticatController(mockContext); + + expect(controller.getFilterDimensions).to.be.a('function'); + expect(controller.getWeeks).to.be.a('function'); + expect(controller.getMetadata).to.be.a('function'); + expect(controller.getStats).to.be.a('function'); + expect(controller.getSentimentOverview).to.be.a('function'); + expect(controller.getWeeklyTrends).to.be.a('function'); + expect(controller.getTopics).to.be.a('function'); + expect(controller.getTopicPrompts).to.be.a('function'); + expect(controller.getSearch).to.be.a('function'); + expect(controller.getShareOfVoice).to.be.a('function'); + expect(controller.getCompetitorTrends).to.be.a('function'); + expect(controller.getPrompts).to.be.a('function'); + expect(controller.getSources).to.be.a('function'); + }); + + it('getFilterDimensions validates site and returns data', async () => { + const controller = LlmoMysticatController(mockContext); + const result = await controller.getFilterDimensions(mockContext); + + expect(mockContext.dataAccess.Site.findById).to.have.been.calledWith(mockContext.params.siteId); + expect(result.status).to.equal(200); + }); + + it('getSiteAndValidateLlmo throws when LLMO not enabled', async () => { + const mockConfigNoLlmo = { getLlmoConfig: sandbox.stub().returns({}) }; + const mockSiteNoLlmo = { getConfig: sandbox.stub().returns(mockConfigNoLlmo) }; + mockContext.dataAccess.Site.findById.resolves(mockSiteNoLlmo); + + const controller = LlmoMysticatController(mockContext); + const result = await controller.getFilterDimensions(mockContext); + + expect(result.status).to.equal(400); + }); + + it('getSiteAndValidateLlmo throws when user has no org access', async () => { + LlmoMysticatController = await esmock('../../../src/controllers/llmo/llmo-mysticat-controller.js', { + '../../../src/support/access-control-util.js': { + default: { + fromContext: () => ({ + hasAccess: sandbox.stub().resolves(false), + }), + }, + }, + }); + + const controller = LlmoMysticatController(mockContext); + const result = await controller.getFilterDimensions(mockContext); + + expect(result.status).to.equal(403); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 644994bcf..020f3315e 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -252,6 +252,22 @@ describe('getRouteHandlers', () => { takeScreenshots: () => null, }; + const mockLlmoMysticatController = { + getFilterDimensions: () => null, + getWeeks: () => null, + getMetadata: () => null, + getStats: () => null, + getSentimentOverview: () => null, + getWeeklyTrends: () => null, + getTopics: () => null, + getTopicPrompts: () => null, + getSearch: () => null, + getShareOfVoice: () => null, + getCompetitorTrends: () => null, + getPrompts: () => null, + getSources: () => null, + }; + const mockLlmoController = { getLlmoSheetData: () => null, getLlmoGlobalSheetData: () => null, @@ -365,6 +381,7 @@ describe('getRouteHandlers', () => { mockTrafficController, mockFixesController, mockLlmoController, + mockLlmoMysticatController, mockUserActivityController, mockSiteEnrollmentController, mockTrialUserController, @@ -644,6 +661,19 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-routing', 'GET /sites/:siteId/llmo/strategy', 'PUT /sites/:siteId/llmo/strategy', + 'GET /sites/:siteId/llmo/brand-presence/filter-dimensions', + 'GET /sites/:siteId/llmo/brand-presence/weeks', + 'GET /sites/:siteId/llmo/brand-presence/metadata', + 'GET /sites/:siteId/llmo/brand-presence/stats', + 'GET /sites/:siteId/llmo/brand-presence/sentiment-overview', + 'GET /sites/:siteId/llmo/brand-presence/weekly-trends', + 'GET /sites/:siteId/llmo/brand-presence/topics', + 'GET /sites/:siteId/llmo/brand-presence/topics/:topic/prompts', + 'GET /sites/:siteId/llmo/brand-presence/search', + 'GET /sites/:siteId/llmo/brand-presence/share-of-voice', + 'GET /sites/:siteId/llmo/brand-presence/competitor-trends', + 'GET /sites/:siteId/llmo/brand-presence/prompts', + 'GET /sites/:siteId/llmo/brand-presence/sources', 'GET /consent-banner/:jobId', 'PATCH /sites/:siteId/llmo/cdn-logs-filter', 'POST /sites/:siteId/sandbox/audit',