From 1bd0a79f6c96920d5731c71d0fdb485b484ee085 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sun, 30 Aug 2026 14:48:02 +1000 Subject: [PATCH 01/10] feat: add AWS-native product analytics --- .environments/.env.dev | 6 +- .environments/.env.prod | 6 +- .environments/.env.qa | 6 +- docs/adr/0002-aws-product-analytics.md | 232 +++++ package.json | 4 +- .../src/components/ChallengeDetailHeader.tsx | 6 + .../ChallengeSubmissionUpload.spec.tsx | 12 + .../components/ChallengeSubmissionUpload.tsx | 9 + .../pages/ChallengeDetailsPage.flows.spec.tsx | 1 + .../src/pages/ChallengeDetailsPage.spec.tsx | 1 + .../src/pages/ChallengeDetailsPage.tsx | 6 + src/apps/platform/src/PlatformApp.tsx | 2 + src/config/environments/default.env.ts | 9 +- .../environments/global-config.model.ts | 4 + .../core/lib/analytics/AnalyticsTracker.tsx | 31 + .../lib/analytics/analytics.functions.spec.ts | 130 +++ .../core/lib/analytics/analytics.functions.ts | 378 ++++++++ src/libs/core/lib/analytics/index.ts | 2 + src/libs/core/lib/index.ts | 1 + .../privacy-policy.content.txt | 10 +- yarn.lock | 833 +++++++++++++++++- 21 files changed, 1645 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0002-aws-product-analytics.md create mode 100644 src/libs/core/lib/analytics/AnalyticsTracker.tsx create mode 100644 src/libs/core/lib/analytics/analytics.functions.spec.ts create mode 100644 src/libs/core/lib/analytics/analytics.functions.ts create mode 100644 src/libs/core/lib/analytics/index.ts diff --git a/.environments/.env.dev b/.environments/.env.dev index ab1b994f6..cfa1ed8bb 100644 --- a/.environments/.env.dev +++ b/.environments/.env.dev @@ -1,5 +1,9 @@ REACT_APP_HOST_ENV=dev +# First-party AWS Clickstream analytics +REACT_APP_AWS_ANALYTICS_APP_ID=topcoder_web +REACT_APP_AWS_ANALYTICS_ENDPOINT=https://analytics.topcoder-dev.com/collect + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +15,6 @@ REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 REACT_APP_MEMBER_VERIFY_LOOKER=3322 -REACT_APP_SPRIG_ENV_ID=bUcousVQ0-yF - # Filestack configuration for uploading Submissions REACT_APP_FILESTACK_API_KEY='AzFINuQoqTmqw0QEoaw9az' REACT_APP_FILESTACK_REGION='us-east-1' diff --git a/.environments/.env.prod b/.environments/.env.prod index 1a460a189..963270bac 100644 --- a/.environments/.env.prod +++ b/.environments/.env.prod @@ -1,5 +1,9 @@ REACT_APP_HOST_ENV=prod +# First-party AWS Clickstream analytics +REACT_APP_AWS_ANALYTICS_APP_ID= +REACT_APP_AWS_ANALYTICS_ENDPOINT= + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +15,6 @@ REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 REACT_APP_MEMBER_VERIFY_LOOKER=3322 -REACT_APP_SPRIG_ENV_ID=a-IZBZ6-r7bU - # Filestack configuration for uploading Submissions REACT_APP_FILESTACK_API_KEY='AzFINuQoqTmqw0QEoaw9az' REACT_APP_FILESTACK_REGION=us-east-1 diff --git a/.environments/.env.qa b/.environments/.env.qa index 96332984b..8e184f63f 100644 --- a/.environments/.env.qa +++ b/.environments/.env.qa @@ -1,5 +1,9 @@ REACT_APP_HOST_ENV=qa +# First-party AWS Clickstream analytics +REACT_APP_AWS_ANALYTICS_APP_ID= +REACT_APP_AWS_ANALYTICS_ENDPOINT= + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +15,6 @@ REACT_APP_DATADOG_PUBLIC_TOKEN=puba0825671e469d16f940c5a30dc738f11 REACT_APP_MEMBER_VERIFY_LOOKER=3322 -REACT_APP_SPRIG_ENV_ID=bUcousVQ0-yF - # Filestack configuration for uploading Submissions REACT_APP_FILESTACK_API_KEY= REACT_APP_FILESTACK_REGION= diff --git a/docs/adr/0002-aws-product-analytics.md b/docs/adr/0002-aws-product-analytics.md new file mode 100644 index 000000000..1d9068f63 --- /dev/null +++ b/docs/adr/0002-aws-product-analytics.md @@ -0,0 +1,232 @@ +# ADR 0002: AWS-native product analytics + +- Status: Accepted +- Date: 2026-08-30 +- Owners: Platform and website engineering + +## Context + +Topcoder needs one first-party analytics path across topcoder-website and +platform-ui. It must attribute page views and clicks to standard UTM values and +measure the same person's progression from a marketing landing page through +challenge registration and submission. The development deployment should +normally remain below USD 200 per month and the production design below USD 400 +per month. + +All prior product-analytics and survey integrations are removed. Existing +operational error logging remains out of scope and is not an event source for +product analytics. + +## Decision + +Use AWS Guidance for Clickstream Analytics on AWS, version 1.2.1, in us-east-1. +The control plane provisions a regional ingestion service on ECS with EC2 +capacity, stores raw events in S3, runs the built-in transformer on a +daily EMR Serverless schedule, loads modeled data into Redshift Serverless, and +exposes direct-query datasets to Amazon QuickSight. + +One Clickstream project and one web app ID are shared by the two UI surfaces in +each environment. The global surface attribute distinguishes platform_ui from +topcoder_website. Production must use a separate project, application, +ingestion endpoint, S3 prefixes, and Redshift namespace; production clients must +never send data to the development project. + +The browser integration uses the AWS Clickstream Web SDK directly in each host +application. Universal Navigation owns only first-touch UTM persistence and +signup-link propagation. It must not initialize another SDK instance because +that would double-count every host page. + +## Development topology and cost controls + +| Layer | Development setting | Production starting point | +| --- | --- | --- | +| Ingestion | One active ECS/EC2 instance, scale to two | Two instances, scale to four | +| Delivery | Direct S3 sink, 10 MB or 300 second buffering | Same | +| Network | Existing VPC and NAT gateway; no Global Accelerator | Reuse an existing production VPC/NAT | +| Logs | Default service logs; no ALB access-log bucket | Enable only when an operational need justifies it | +| Processing | Built-in transform plus user-agent enrichment once per day | Daily; increase only after a freshness review | +| Location enrichment | Disabled | Disabled unless approved for a documented use case | +| Warehouse | Redshift Serverless at the 8 RPU minimum | Start at 8 RPU and observe workload | +| Reporting | One QuickSight Enterprise author, direct query, no SPICE | Add readers/authors only as needed | +| Storage | Expire temporary artifacts; retain raw and modeled data to approved policy | Same with production retention policy | + +The deployed development environment is in AWS account 811668436784 in +us-east-1. Project topcoder_web_dev and app topcoder_web are active at +https://analytics.topcoder-dev.com/collect. The pipeline ID is +ead9a39a35334fafb77428073704ad7b. It uses the existing development VPC, a +dedicated second private ingestion subnet in a supported availability zone, +the existing Redshift/QuickSight subnets, daily processing, and the S3 bucket +topcoder-clickstream-data-dev-811668436784. A synthetic SDK-format event was +accepted by the collector before the client configuration was enabled. A +second event with source codex, medium integration, and campaign aws_analytics +was then processed through S3 and EMR and verified in both Athena and Redshift. + +The development control-plane API has a narrow patch over upstream v1.2.1: +endpoint security-group discovery treats a gateway VPC endpoint without a +Groups array as an empty list. This fixes project provisioning in a VPC that +contains S3 gateway endpoints. CloudFormation does not own this code change, so +an upgrade or stack repair can overwrite it; either carry the patch forward or +upgrade only after the upstream implementation handles gateway endpoints. + +The upstream v1.2.1 Redshift schema also creates six PL/Python UDFs. Redshift +[stopped allowing new PL/Python UDFs on 2025-10-30](https://docs.aws.amazon.com/redshift/latest/mgmt/behavior-changes.html), +so the original schema run failed before creating the merge procedures. The +development database uses the compatibility Lambda +topcoder-clickstream-redshift-udf-dev for only those six preserved functions. +The Redshift-associated role has permission to invoke only that Lambda, and the +Lambda execution role can write only its logs. Redshift administrator access +uses the namespace's managed Secrets Manager secret rather than a copied +password. The secret adds a small fixed monthly charge; Lambda invocation cost +at the daily processing frequency is negligible relative to ingestion and +Redshift. + +The patched 114-statement installer is stored at the version-labeled prefix +s3://topcoder-clickstream-data-dev-811668436784/clickstream/topcoder_web_dev/data/load-workflow/tmp/topcoder_web_dev/sqls/topcoder_web-20260830T030115510Z-lambda-udf-v2/. +The rebuild scripts, deployed package, external-function definitions, +checksums, patched SQL, and runbook are archived at +s3://topcoder-clickstream-templates-dev-811668436784/custom-fixes/redshift-lambda-udf/v1/. +These resources are tagged and encrypted. CloudFormation does not own this +repair; reapply it after a Clickstream schema or application upgrade unless the +new release has removed the PL/Python dependency. Start a new load execution +with a unique name after repair because the v1.2.1 parent redrive reuses child +names and collides with their prior executions. + +At low event volume, the design target is approximately USD 130โ€“190 per month +for development and USD 240โ€“360 per month for production. These are operating +guardrails, not an invoice guarantee: ingestion traffic, EMR runtime, Redshift +query duration, log volume, cross-AZ transfer, and additional QuickSight users +are variable. Resources use Application, Environment, and CostCenter tags. +After the payer account activates those cost-allocation tags, review Cost +Explorer by tag weekly during the first month and create tag-scoped budgets. +The linked development account cannot activate those tags or create the +tag-scoped payer budget itself. Investigate development at USD 160 forecast and +stop nonessential processing before USD 200; use USD 320 and USD 400 as the +equivalent production thresholds. + +Do not increase the ingestion minimum, processing frequency, Redshift base RPU, +or QuickSight user count without recording the expected monthly delta. + +## Identity and attribution + +The clients create a random tc_analytics_id first-party cookie with a one-year +lifetime. On topcoder.com, topcoder-dev.com, and topcoder-qa.com it is scoped to +the registrable domain, allowing a landing-page visit and a later platform-ui +conversion to use the same Clickstream user ID. It is pseudonymous and contains +no member data. + +After authentication, platform-ui adds member_id as a global event attribute. +It does not send handle, name, email, form values, or rendered click text. + +Universal Navigation stores the first valid visit containing any of these +standard parameters in the tc_utm cookie for 30 days: + +- utm_source +- utm_medium +- utm_campaign +- utm_id +- utm_term +- utm_content + +Values are restricted to 100 characters and the characters A-Z, a-z, 0-9, +period, underscore, tilde, and hyphen. The two clients map them to Clickstream's +traffic_source columns, preferring values on the current URL and otherwise using +the first-touch cookie. + +## Event contract + +| Event | Producer | Required dimensions | Meaning | +| --- | --- | --- | --- | +| _page_view | AWS SDK in both apps | surface, environment, traffic source | A browser route became active | +| _user_engagement | AWS SDK in both apps | surface, environment, traffic source | Foreground engagement of at least one second | +| ui_click | Both apps | page_path, element_type, click_x_percent, click_y_percent | A click on an interactive element | +| ui_click | Both apps, when available | element_id, placement, destination_host, destination_path | Semantic click location and query-free destination | +| challenge_registered | platform-ui after API success | challenge_id, challenge_track, member_id | The Submitter resource was successfully created | +| challenge_submitted | platform-ui after API success | challenge_id, challenge_track, member_id, submission_type | The Review API successfully created a submission | + +Clickable conversion controls should have stable data-analytics-id and +data-analytics-placement attributes. Do not derive element_id from rendered +copy, because copy changes and may contain user-provided text. + +The AWS SDK adds its reserved current-page URL to events. UTM query values are +therefore present in raw/modelled events as well as normalized traffic-source +columns. Application routes must not place credentials, email addresses, or +other secrets in query parameters. The custom ui_click destination fields +deliberately omit destination queries. + +Global Privacy Control and Do Not Track prevent SDK initialization. Analytics +errors are swallowed so collection can never block navigation, registration, or +submission. + +## Funnel definition + +The product_analytics_events_v1 Redshift view normalizes the event contract. +The challenge_funnel_daily_v1 view counts distinct coalesced +user_id/user_pseudo_id values, grouped by traffic source and first landing page, +with these ordered stages: + +1. A _page_view event with surface topcoder_website. +2. A ui_click event on that surface. +3. A challenge_registered event after the click. +4. A challenge_submitted event after registration. + +The challenge_conversion_daily_v1 view groups a person's first registration +and later submission by challenge_id. The click_location_daily_v1 view groups +clicks by stable placement and element dimensions, query-free destination, and +10-percentage-point viewport buckets. All three reporting views enforce the +event contract centrally; simple independent event counts would overstate +conversion. + +QuickSight dashboard topcoder_product_analytics_dev_v1 provides landing users, +landing clickers, registrations after a click, submissions after registration, +the ordered UTM funnel, challenge conversion, and click-location tables. It +uses the direct-query datasets topcoder_challenge_funnel_v1, +topcoder_challenge_conversion_v1, and topcoder_click_location_v1, so it does not +create a SPICE copy. The reporting SQL, dataset requests, dashboard request, and +runbook are archived at +s3://topcoder-clickstream-templates-dev-811668436784/custom-assets/product-analytics/v1/. + +## Configuration + +Platform UI reads: + +- REACT_APP_AWS_ANALYTICS_APP_ID +- REACT_APP_AWS_ANALYTICS_ENDPOINT + +topcoder-website reads: + +- NEXT_PUBLIC_AWS_ANALYTICS_APP_ID +- NEXT_PUBLIC_AWS_ANALYTICS_ENDPOINT + +The values are public ingestion configuration, not AWS credentials. Store them +in each deployment environment and leave them empty to disable analytics in +local or unprovisioned environments. Never expose the control-plane login, +Redshift credentials, or AWS credentials to either client. + +Development uses app ID topcoder_web and endpoint +https://analytics.topcoder-dev.com/collect. QA and production remain empty and +disabled until their separate projects and endpoints are provisioned. + +## Validation and operations + +For each environment: + +1. Open a landing URL with a unique test UTM campaign. +2. Confirm tc_utm and tc_analytics_id are first-party cookies. +3. Click a challenge CTA, register, and submit with a test account. +4. After the daily processing run, verify all four ordered stages share the same + user identity and traffic-source values in Redshift. +5. Verify the QuickSight funnel and UTM breakdown against the Redshift counts. +6. Repeat with Global Privacy Control or Do Not Track enabled and confirm no + browser requests reach the ingestion endpoint. +7. Review failed ingestion responses, EMR jobs, Redshift load state, S3 growth, + and the monthly cost forecast. + +The deployment smoke test completed this path on 2026-08-30. Both synthetic +events are present in event_v2; the tagged event retained codex, integration, +and aws_analytics while the control event remained Direct. The default AWS +Clickstream dashboard, Topcoder reporting dashboard, Redshift data source, and +all three custom datasets reported successful creation status. + +If the daily pipeline misses its freshness objective, first inspect failures and +job duration. Increasing processing frequency is a cost-bearing design change, +not the default incident response. diff --git a/package.json b/package.json index 9120e2a6b..73adf3e95 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@assistant-ui/react": "0.15.16", "@assistant-ui/react-ai-sdk": "1.4.7", "@assistant-ui/react-markdown": "0.14.12", + "@aws/clickstream-web": "^0.12.6", "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-java": "^6.0.2", "@codemirror/language": "^6.12.2", @@ -38,7 +39,6 @@ "@highcharts/map-collection": "^2.3.3", "@hookform/resolvers": "^4.1.3", "@popperjs/core": "^2.11.8", - "@sprig-technologies/sprig-browser": "^2.39.0", "@storybook/addon-actions": "7.6.20", "@storybook/react": "7.6.20", "@stripe/react-stripe-js": "1.16.5", @@ -94,7 +94,6 @@ "react-dom": "^18.3.1", "react-dropzone": "^11.7.1", "react-elastic-carousel": "^0.11.5", - "react-gtm-module": "^2.0.11", "react-helmet": "^6.1.0", "react-hook-form": "^7.68.0", "react-markdown": "8.0.7", @@ -166,7 +165,6 @@ "@types/react": "18.3.27", "@types/react-datepicker": "^4.19.6", "@types/react-dom": "^18.3.7", - "@types/react-gtm-module": "^2.0.4", "@types/react-helmet": "^6.1.11", "@types/react-redux-toastr": "^7.6.6", "@types/redux-actions": "2.6.5", diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx index b1d312451..00a56f21c 100644 --- a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx @@ -468,6 +468,8 @@ export const ChallengeDetailHeader: FC = props => { <> +
Analytics
+ + + + ) +} + +export default AnalyticsNav diff --git a/src/apps/analytics/src/lib/components/AnalyticsNav/index.ts b/src/apps/analytics/src/lib/components/AnalyticsNav/index.ts new file mode 100644 index 000000000..ce042c40b --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsNav/index.ts @@ -0,0 +1 @@ +export * from './AnalyticsNav' diff --git a/src/apps/analytics/src/lib/components/MetricCard/MetricCard.module.scss b/src/apps/analytics/src/lib/components/MetricCard/MetricCard.module.scss new file mode 100644 index 000000000..e04bcabbf --- /dev/null +++ b/src/apps/analytics/src/lib/components/MetricCard/MetricCard.module.scss @@ -0,0 +1,38 @@ +.card { + background: #ffffff; + border: 1px solid #dce3e9; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(13, 52, 69, 0.04); + min-height: 124px; + padding: 20px; +} + +.highlight { + border-top: 4px solid #2c95d7; +} + +.success { + border-top: 4px solid #137d60; +} + +.label { + color: #5a6f78; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.value { + color: #0d3445; + font-size: 32px; + font-weight: 800; + line-height: 1.15; + margin-top: 8px; +} + +.context { + color: #617681; + font-size: 13px; + margin-top: 8px; +} diff --git a/src/apps/analytics/src/lib/components/MetricCard/MetricCard.tsx b/src/apps/analytics/src/lib/components/MetricCard/MetricCard.tsx new file mode 100644 index 000000000..49c0d9f75 --- /dev/null +++ b/src/apps/analytics/src/lib/components/MetricCard/MetricCard.tsx @@ -0,0 +1,30 @@ +/** Accessible KPI card for analytics totals and conversion rates. */ +/* eslint-disable ordered-imports/ordered-imports */ +import classNames from 'classnames' +import { FC, ReactNode } from 'react' + +import styles from './MetricCard.module.scss' + +interface MetricCardProps { + context?: ReactNode + label: string + tone?: 'default' | 'highlight' | 'success' + value: ReactNode +} + +/** + * Renders one headline analytic with optional explanatory context. + * + * @param props label, formatted value, context, and visual tone. + * @returns semantic KPI card. + * @throws Does not throw. + */ +export const MetricCard: FC = props => ( +
+
{props.label}
+
{props.value}
+ {props.context &&
{props.context}
} +
+) + +export default MetricCard diff --git a/src/apps/analytics/src/lib/components/MetricCard/index.ts b/src/apps/analytics/src/lib/components/MetricCard/index.ts new file mode 100644 index 000000000..8a8be9419 --- /dev/null +++ b/src/apps/analytics/src/lib/components/MetricCard/index.ts @@ -0,0 +1 @@ +export * from './MetricCard' diff --git a/src/apps/analytics/src/lib/components/ReportError/ReportError.module.scss b/src/apps/analytics/src/lib/components/ReportError/ReportError.module.scss new file mode 100644 index 000000000..2f0ccd7d0 --- /dev/null +++ b/src/apps/analytics/src/lib/components/ReportError/ReportError.module.scss @@ -0,0 +1,25 @@ +.error { + align-items: flex-start; + background: #fff4f4; + border: 1px solid #e57373; + border-radius: 8px; + color: #7f1d1d; + display: flex; + gap: 16px; + padding: 20px; + + > svg { + flex: 0 0 24px; + height: 24px; + width: 24px; + } + + h2 { + font-size: 18px; + margin: 0 0 6px; + } + + p { + margin: 0 0 16px; + } +} diff --git a/src/apps/analytics/src/lib/components/ReportError/ReportError.tsx b/src/apps/analytics/src/lib/components/ReportError/ReportError.tsx new file mode 100644 index 000000000..0544a2553 --- /dev/null +++ b/src/apps/analytics/src/lib/components/ReportError/ReportError.tsx @@ -0,0 +1,33 @@ +/** Retryable analytics request failure state. */ +import { FC } from 'react' + +import { Button, IconOutline } from '~/libs/ui' + +import { AnalyticsRequestError } from '../../models' + +import styles from './ReportError.module.scss' + +interface ReportErrorProps { + error: AnalyticsRequestError + onRetry: () => void +} + +/** + * Renders a sanitized analytics error with an explicit retry action. + * + * @param props safe error and retry callback. + * @returns accessible inline alert. + * @throws Does not throw. + */ +export const ReportError: FC = props => ( +
+
+) + +export default ReportError diff --git a/src/apps/analytics/src/lib/components/ReportError/index.ts b/src/apps/analytics/src/lib/components/ReportError/index.ts new file mode 100644 index 000000000..71ae136f4 --- /dev/null +++ b/src/apps/analytics/src/lib/components/ReportError/index.ts @@ -0,0 +1 @@ +export * from './ReportError' diff --git a/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.module.scss b/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.module.scss new file mode 100644 index 000000000..17d6285e8 --- /dev/null +++ b/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.module.scss @@ -0,0 +1,25 @@ +.figure { + margin: 0; + min-height: 360px; +} + +.empty { + align-items: center; + color: #617681; + display: flex; + justify-content: center; + min-height: 320px; +} + +.screen-reader-only { + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.tsx b/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.tsx new file mode 100644 index 000000000..e65c1731f --- /dev/null +++ b/src/apps/analytics/src/lib/components/TimeSeriesChart/TimeSeriesChart.tsx @@ -0,0 +1,139 @@ +/** Reusable accessible line chart for daily analytics series. */ +/* eslint-disable ordered-imports/ordered-imports */ +import Highcharts from 'highcharts' +import HighchartsReact from 'highcharts-react-official' +import { FC, useMemo } from 'react' + +import styles from './TimeSeriesChart.module.scss' + +export interface TimeSeriesDefinition { + color: string + key: string + label: string +} + +interface TimeSeriesChartProps { + ariaLabel: string + points: object[] + series: TimeSeriesDefinition[] +} + +/** + * Reads a safe finite numeric field from one chart point. + * + * @param point normalized daily analytics point. + * @param key configured metric field. + * @returns finite metric value or zero. + * @throws Does not throw. + */ +function pointValue(point: object, key: string): number { + const value = (point as Record)[key] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** + * Formats an ISO date without allowing local timezone offsets to change the day. + * + * @param value YYYY-MM-DD value. + * @returns compact month/day label. + * @throws Does not throw; malformed values are returned unchanged. + */ +function dateLabel(value: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + return match ? `${match[2]}/${match[3]}` : value +} + +/** + * Renders a multi-series daily line chart and an equivalent screen-reader table. + * + * @param props accessible label, normalized points, and series definitions. + * @returns chart, accessible table, or explicit empty state. + * @throws Does not throw. + */ +export const TimeSeriesChart: FC = props => { + const hasData = props.points.some(point => props.series.some(series => pointValue(point, series.key) > 0)) + const options = useMemo(() => ({ + accessibility: { enabled: false }, + chart: { + animation: false, + backgroundColor: 'transparent', + height: 360, + spacing: [16, 12, 8, 4], + type: 'line', + }, + colors: props.series.map(series => series.color), + credits: { enabled: false }, + exporting: { enabled: false }, + legend: { + align: 'center', + itemStyle: { color: '#0d3445', fontSize: '12px', fontWeight: '600' }, + verticalAlign: 'top', + }, + plotOptions: { + line: { lineWidth: 3 }, + series: { + animation: false, + marker: { enabled: props.points.length <= 45, radius: 3 }, + states: { inactive: { opacity: 1 } }, + }, + }, + series: props.series.map(series => ({ + color: series.color, + data: props.points.map(point => pointValue(point, series.key)), + name: series.label, + type: 'line', + })) as Highcharts.SeriesOptionsType[], + title: { text: undefined }, + tooltip: { + headerFormat: '{point.key}
', + pointFormat: 'โ— {series.name}: {point.y:,.0f}
', + shared: true, + }, + xAxis: { + categories: props.points.map(point => dateLabel(String( + (point as Record).date ?? '', + ))), + labels: { style: { color: '#5a6f78', fontSize: '11px' } }, + lineColor: '#dce3e9', + tickColor: '#dce3e9', + }, + yAxis: { + allowDecimals: false, + gridLineColor: '#e7edf1', + labels: { style: { color: '#5a6f78', fontSize: '11px' } }, + min: 0, + title: { text: undefined }, + }, + }), [props.points, props.series]) + + if (!hasData) { + return
No activity is available for this period.
+ } + + return ( +
+ + + + + + + {props.series.map(series => )} + + + + {props.points.map(point => ( + ).date)}> + + {props.series.map(series => ( + + ))} + + ))} + +
{props.ariaLabel}
Date{series.label}
{String((point as Record).date)}{pointValue(point, series.key)}
+
+ ) +} + +export default TimeSeriesChart diff --git a/src/apps/analytics/src/lib/components/TimeSeriesChart/index.ts b/src/apps/analytics/src/lib/components/TimeSeriesChart/index.ts new file mode 100644 index 000000000..afc433ebc --- /dev/null +++ b/src/apps/analytics/src/lib/components/TimeSeriesChart/index.ts @@ -0,0 +1 @@ +export * from './TimeSeriesChart' diff --git a/src/apps/analytics/src/lib/components/index.ts b/src/apps/analytics/src/lib/components/index.ts new file mode 100644 index 000000000..3ecebddcd --- /dev/null +++ b/src/apps/analytics/src/lib/components/index.ts @@ -0,0 +1,5 @@ +export * from './AnalyticsLayout' +export * from './AnalyticsNav' +export * from './MetricCard' +export * from './ReportError' +export * from './TimeSeriesChart' diff --git a/src/apps/analytics/src/lib/hooks/index.ts b/src/apps/analytics/src/lib/hooks/index.ts new file mode 100644 index 000000000..47c7a1532 --- /dev/null +++ b/src/apps/analytics/src/lib/hooks/index.ts @@ -0,0 +1 @@ +export * from './useAnalyticsResource' diff --git a/src/apps/analytics/src/lib/hooks/useAnalyticsResource.spec.ts b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.spec.ts new file mode 100644 index 000000000..fb52dac44 --- /dev/null +++ b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.spec.ts @@ -0,0 +1,32 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { classifyAnalyticsError } from './useAnalyticsResource' + +describe('Analytics request error classification', () => { + it('does not expose provider error messages', () => { + expect(classifyAnalyticsError({ + message: 'sensitive upstream details', + response: { status: 500 }, + })) + .toEqual({ + kind: 'general', + message: 'Analytics data could not be loaded. Try again.', + status: 500, + }) + }) + + it.each([ + [401, 'authorization'], + [403, 'authorization'], + [429, 'throttled'], + [503, 'throttled'], + [504, 'timeout'], + ])('maps HTTP %s to the %s category', (status, kind) => { + expect(classifyAnalyticsError({ response: { status } }).kind) + .toBe(kind) + }) + + it('reports disabled environment configuration clearly', () => { + expect(classifyAnalyticsError({ message: 'Analytics API is not configured' }).kind) + .toBe('configuration') + }) +}) diff --git a/src/apps/analytics/src/lib/hooks/useAnalyticsResource.ts b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.ts new file mode 100644 index 000000000..0edd2ca2e --- /dev/null +++ b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.ts @@ -0,0 +1,149 @@ +/** Shared request lifecycle for analytics reports and filter options. */ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' + +import { AnalyticsRequestError } from '../models' + +export interface AnalyticsResourceState { + data?: T + error?: AnalyticsRequestError + loading: boolean + refreshing: boolean + refresh: () => void +} + +interface InternalState { + data?: T + error?: AnalyticsRequestError + loading: boolean + refreshing: boolean +} + +/** + * Converts an intercepted analytics request failure into a safe display error. + * + * @param error unknown rejected request value. + * @returns sanitized request category and message. + * @throws Does not throw. + */ +export function classifyAnalyticsError(error: unknown): AnalyticsRequestError { + const candidate = error as { + code?: string + message?: string + status?: number + response?: { status?: number } + } + const status = candidate?.status ?? candidate?.response?.status + + if (candidate?.message?.includes('not configured')) { + return { + kind: 'configuration', + message: 'Analytics has not been configured for this environment.', + status, + } + } + + if (status === 401 || status === 403) { + return { + kind: 'authorization', + message: status === 401 + ? 'Your session is missing or expired. Sign in again to view Analytics.' + : 'The analytics role is required to view this data.', + status, + } + } + + if (status === 504 || candidate?.code === 'ECONNABORTED') { + return { + kind: 'timeout', + message: 'The analytics warehouse is taking longer than expected. Try again.', + status, + } + } + + if (status === 429 || status === 503) { + return { + kind: 'throttled', + message: 'Analytics is temporarily busy. Wait a moment and try again.', + status, + } + } + + return { + kind: 'general', + message: 'Analytics data could not be loaded. Try again.', + status, + } +} + +/** + * Loads a read-only analytics resource with stale-response suppression. + * + * @param key stable request identity, or undefined to disable loading. + * @param request function performing the authenticated GET request. + * @returns current data/error/loading state and explicit refresh action. + * @throws Does not throw; failures are returned as safe state. + */ +export function useAnalyticsResource( + key: string | undefined, + request: () => Promise, +): AnalyticsResourceState { + const requestRef = useRef(request) + requestRef.current = request + const requestSequence = useRef(0) + const activeKey = useRef(undefined) + const [revision, setRevision]: [number, Dispatch>] = useState(0) + const [state, setState] = useState>({ + loading: Boolean(key), + refreshing: false, + }) + + useEffect(() => { + if (!key) { + activeKey.current = undefined + setState({ loading: false, refreshing: false }) + return undefined + } + + const keyChanged = activeKey.current !== key + + activeKey.current = key + const sequence = requestSequence.current + 1 + requestSequence.current = sequence + setState(previous => ({ + data: keyChanged ? undefined : previous.data, + error: undefined, + loading: keyChanged || !previous.data, + refreshing: !keyChanged && Boolean(previous.data), + })) + requestRef.current() + .then(data => { + if (requestSequence.current !== sequence) return + setState({ data, loading: false, refreshing: false }) + }) + .catch((error: unknown) => { + if (requestSequence.current !== sequence) return + setState(previous => ({ + data: previous.data, + error: classifyAnalyticsError(error), + loading: false, + refreshing: false, + })) + }) + return () => { + if (requestSequence.current === sequence) requestSequence.current += 1 + } + }, [key, revision]) + + const refresh = useCallback(() => { + if (key) setRevision(current => current + 1) + }, [key]) + + return { ...state, refresh } +} diff --git a/src/apps/analytics/src/lib/models/analytics.models.ts b/src/apps/analytics/src/lib/models/analytics.models.ts new file mode 100644 index 000000000..f94ed3a8e --- /dev/null +++ b/src/apps/analytics/src/lib/models/analytics.models.ts @@ -0,0 +1,158 @@ +/** Query parameters shared by both analytics report tabs. */ +export interface AnalyticsDateRange { + from: string + to: string +} + +/** Campaign funnel filters supported by the read-only API. */ +export interface CampaignFilters extends AnalyticsDateRange { + campaign?: string + campaignId?: string + medium?: string + source?: string +} + +/** General site analytics filters supported by the read-only API. */ +export interface GeneralFilters extends AnalyticsDateRange { + surface?: string +} + +/** Server-provided bounded filter options and data freshness. */ +export interface AnalyticsFilterOptions { + campaigns: string[] + campaignIds: string[] + sources: string[] + mediums: string[] + surfaces: string[] + generatedAt: string + minDate?: string + maxDate?: string + dataThrough?: string +} + +/** Ordered funnel totals for the selected campaign cohort. */ +export interface CampaignTotals { + landingUsers: number + landingClickers: number + registrations: number + submissions: number + clickThroughPercent: number + clickToRegistrationPercent: number + registrationToSubmissionPercent: number + landingToSubmissionPercent: number +} + +/** One daily point in the ordered campaign funnel. */ +export interface CampaignSeriesPoint { + date: string + landingUsers: number + landingClickers: number + registrations: number + submissions: number +} + +/** One first-touch campaign breakdown row. */ +export interface CampaignBreakdown { + campaign: string + campaignId?: string + source: string + medium: string + landingUsers: number + landingClickers: number + registrations: number + submissions: number +} + +/** One landing-page funnel breakdown row. */ +export interface LandingPageBreakdown { + path: string + landingUsers: number + landingClickers: number + registrations: number + submissions: number +} + +/** Privacy-safe aggregate click-location row. */ +export interface ClickLocation { + pagePath: string + placement?: string + elementId?: string + elementType?: string + destinationHost?: string + destinationPath?: string + xBucket?: number + yBucket?: number + clicks: number + clickers: number +} + +/** Complete ordered campaign report returned by analytics-api. */ +export interface CampaignReport { + generatedAt: string + dataThrough?: string + filters: Required + totals: CampaignTotals + series: CampaignSeriesPoint[] + campaigns: CampaignBreakdown[] + landingPages: LandingPageBreakdown[] + clickLocations: ClickLocation[] +} + +/** General engagement totals across Topcoder web surfaces. */ +export interface GeneralTotals { + pageViews: number + visitors: number + clicks: number + clickers: number +} + +/** One daily general engagement point. */ +export interface GeneralSeriesPoint { + date: string + pageViews: number + visitors: number + clicks: number + clickers: number +} + +/** Page-level general analytics breakdown. */ +export interface PageBreakdown { + surface: string + path: string + pageViews: number + visitors: number +} + +/** Traffic-source general analytics breakdown. */ +export interface SourceBreakdown { + source: string + pageViews: number + visitors: number +} + +/** Application-surface general analytics breakdown. */ +export interface SurfaceBreakdown { + surface: string + pageViews: number + visitors: number + clicks: number +} + +/** Complete general site report returned by analytics-api. */ +export interface GeneralReport { + generatedAt: string + dataThrough?: string + filters: Required + totals: GeneralTotals + series: GeneralSeriesPoint[] + pages: PageBreakdown[] + sources: SourceBreakdown[] + surfaces: SurfaceBreakdown[] +} + +/** Safe request-error category rendered by the analytics UI. */ +export interface AnalyticsRequestError { + kind: 'authorization' | 'configuration' | 'general' | 'timeout' | 'throttled' + message: string + status?: number +} diff --git a/src/apps/analytics/src/lib/models/index.ts b/src/apps/analytics/src/lib/models/index.ts new file mode 100644 index 000000000..0739e65a0 --- /dev/null +++ b/src/apps/analytics/src/lib/models/index.ts @@ -0,0 +1 @@ +export * from './analytics.models' diff --git a/src/apps/analytics/src/lib/services/analytics.service.spec.ts b/src/apps/analytics/src/lib/services/analytics.service.spec.ts new file mode 100644 index 000000000..741656264 --- /dev/null +++ b/src/apps/analytics/src/lib/services/analytics.service.spec.ts @@ -0,0 +1,73 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { xhrGetAsync } from '~/libs/core' + +import { + buildAnalyticsUrl, + getAnalyticsFilters, + getCampaignReport, + getGeneralReport, +} from './analytics.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + ANALYTICS: { API_URL: 'https://analytics-api.example.com/v1/analytics/' }, + }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + xhrGetAsync: jest.fn(), +}), { virtual: true }) + +const mockedXhrGetAsync = xhrGetAsync as jest.Mock + +describe('Analytics API service', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedXhrGetAsync.mockResolvedValue({}) + }) + + it('builds encoded URLs and omits empty filters', () => { + expect(buildAnalyticsUrl('campaign', { + campaign: 'launch 2026', + from: '2026-08-01', + source: '', + to: '2026-08-30', + })) + .toBe( + 'https://analytics-api.example.com/v1/analytics/campaign' + + '?from=2026-08-01&to=2026-08-30&campaign=launch+2026', + ) + }) + + it('loads every report through the authenticated global XHR client', async () => { + await getAnalyticsFilters() + await getCampaignReport({ + campaign: 'launch', + from: '2026-08-01', + to: '2026-08-30', + }) + await getGeneralReport({ + from: '2026-08-01', + surface: 'platform_ui', + to: '2026-08-30', + }) + + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 1, + 'https://analytics-api.example.com/v1/analytics/filters', + ) + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 2, + 'https://analytics-api.example.com/v1/analytics/campaign' + + '?from=2026-08-01&to=2026-08-30&campaign=launch', + ) + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 3, + 'https://analytics-api.example.com/v1/analytics/general' + + '?from=2026-08-01&to=2026-08-30&surface=platform_ui', + ) + }) +}) diff --git a/src/apps/analytics/src/lib/services/analytics.service.ts b/src/apps/analytics/src/lib/services/analytics.service.ts new file mode 100644 index 000000000..380519acf --- /dev/null +++ b/src/apps/analytics/src/lib/services/analytics.service.ts @@ -0,0 +1,83 @@ +/** Authenticated read-only client for the AWS analytics API. */ +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync } from '~/libs/core' + +import { + AnalyticsFilterOptions, + CampaignFilters, + CampaignReport, + GeneralFilters, + GeneralReport, +} from '../models' + +export const ANALYTICS_API_BASE = EnvironmentConfig.ANALYTICS.API_URL.replace(/\/$/, '') + +type AnalyticsQueryValues = Partial + +const ANALYTICS_QUERY_KEYS: Array = [ + 'from', + 'to', + 'campaign', + 'campaignId', + 'source', + 'medium', + 'surface', +] + +/** + * Builds an analytics API URL from an allowlisted path and scalar filters. + * + * @param path API path relative to the configured analytics base. + * @param values allowlisted scalar filters. + * @returns absolute URL with encoded query values. + * @throws Error when the analytics API has not been configured for the environment. + */ +export function buildAnalyticsUrl( + path: string, + values: AnalyticsQueryValues = {}, +): string { + if (!ANALYTICS_API_BASE) { + throw new Error('Analytics API is not configured for this environment') + } + + const query = new URLSearchParams() + ANALYTICS_QUERY_KEYS + .forEach(key => { + const value = values[key] + if (value) query.set(key, value) + }) + const encodedQuery = query.toString() + return `${ANALYTICS_API_BASE}/${path}${encodedQuery ? `?${encodedQuery}` : ''}` +} + +/** + * Loads server-approved campaign and surface filter options. + * + * @returns filter values plus available data dates. + * @throws Rejects when configuration, authentication, authorization, or the API request fails. + */ +export function getAnalyticsFilters(): Promise { + return xhrGetAsync(buildAnalyticsUrl('filters')) +} + +/** + * Loads an ordered campaign funnel with click-location detail. + * + * @param filters validated UI date and UTM filters. + * @returns campaign totals, series, and breakdowns. + * @throws Rejects when configuration, authentication, authorization, or the API request fails. + */ +export function getCampaignReport(filters: CampaignFilters): Promise { + return xhrGetAsync(buildAnalyticsUrl('campaign', filters)) +} + +/** + * Loads general Topcoder site engagement analytics. + * + * @param filters validated UI date and optional surface filter. + * @returns general totals, series, and breakdowns. + * @throws Rejects when configuration, authentication, authorization, or the API request fails. + */ +export function getGeneralReport(filters: GeneralFilters): Promise { + return xhrGetAsync(buildAnalyticsUrl('general', filters)) +} diff --git a/src/apps/analytics/src/lib/services/index.ts b/src/apps/analytics/src/lib/services/index.ts new file mode 100644 index 000000000..93b055f35 --- /dev/null +++ b/src/apps/analytics/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './analytics.service' diff --git a/src/apps/analytics/src/lib/styles/index.scss b/src/apps/analytics/src/lib/styles/index.scss new file mode 100644 index 000000000..f3f2e60a7 --- /dev/null +++ b/src/apps/analytics/src/lib/styles/index.scss @@ -0,0 +1,5 @@ +@import '@libs/ui/styles/includes'; + +body.analytics-app { + background: #f5f7fa; +} diff --git a/src/apps/analytics/src/lib/utils/analytics.utils.spec.ts b/src/apps/analytics/src/lib/utils/analytics.utils.spec.ts new file mode 100644 index 000000000..21f08d073 --- /dev/null +++ b/src/apps/analytics/src/lib/utils/analytics.utils.spec.ts @@ -0,0 +1,73 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { + analyticsRequestKey, + defaultAnalyticsDateRange, + formatAnalyticsFreshness, + formatAnalyticsInteger, + formatAnalyticsPercent, + formatAnalyticsSurface, + formatClickBucket, + validateAnalyticsDateRange, +} from './analytics.utils' + +describe('Analytics utilities', () => { + it('creates a deterministic inclusive 30-day UTC default', () => { + expect(defaultAnalyticsDateRange(new Date('2026-08-30T23:59:59Z'))) + .toEqual({ from: '2026-08-01', to: '2026-08-30' }) + }) + + it('validates strict, ordered, bounded date ranges', () => { + const reference = new Date('2026-08-30T23:59:59Z') + + expect(validateAnalyticsDateRange( + { from: '2026-02-30', to: '2026-03-01' }, + reference, + )) + .toBe('Choose a valid start and end date.') + expect(validateAnalyticsDateRange( + { from: '2026-08-31', to: '2026-08-30' }, + reference, + )) + .toBe('The start date must not be after the end date.') + expect(validateAnalyticsDateRange( + { from: '2026-08-30', to: '2026-08-31' }, + reference, + )) + .toBe('The end date must not be in the future.') + expect(validateAnalyticsDateRange( + { from: '2025-08-29', to: '2026-08-30' }, + reference, + )) + .toBe('Analytics date ranges cannot exceed 366 days.') + expect(validateAnalyticsDateRange( + { from: '2025-08-30', to: '2026-08-30' }, + reference, + )) + .toBeUndefined() + }) + + it('formats aggregate values and safe dimensions', () => { + expect(formatAnalyticsInteger(12345)) + .toBe('12,345') + expect(formatAnalyticsPercent(12.345)) + .toBe('12.35%') + expect(formatAnalyticsSurface('topcoder_website')) + .toBe('Topcoder Website') + expect(formatClickBucket(20, 90)) + .toBe('x 20โ€“29%, y 90โ€“99%') + expect(formatClickBucket(undefined, 90)) + .toBe('Unknown') + expect(formatAnalyticsFreshness('2026-08-30')) + .toBe('Aug 30, 2026') + expect(formatAnalyticsFreshness('not-a-date')) + .toBe('not-a-date') + }) + + it('creates a stable key regardless of filter property insertion order', () => { + expect(analyticsRequestKey('campaign', { + from: '2026-08-01', + to: '2026-08-30', + })) + .toBe('campaign:from=2026-08-01&to=2026-08-30') + }) +}) diff --git a/src/apps/analytics/src/lib/utils/analytics.utils.ts b/src/apps/analytics/src/lib/utils/analytics.utils.ts new file mode 100644 index 000000000..225bb300b --- /dev/null +++ b/src/apps/analytics/src/lib/utils/analytics.utils.ts @@ -0,0 +1,164 @@ +import { AnalyticsDateRange } from '../models' + +const integerFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const calendarDateFormatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeZone: 'UTC', +}) +const timestampFormatter = new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', +}) + +/** + * Returns the default inclusive 30-day analytics range in UTC. + * + * @param reference optional reference timestamp, primarily for deterministic tests. + * @returns YYYY-MM-DD from/to values. + * @throws Does not throw. + */ +export function defaultAnalyticsDateRange(reference: Date = new Date()): AnalyticsDateRange { + const end = new Date(Date.UTC( + reference.getUTCFullYear(), + reference.getUTCMonth(), + reference.getUTCDate(), + )) + const start = new Date(end) + start.setUTCDate(start.getUTCDate() - 29) + return { + from: start.toISOString() + .slice(0, 10), + to: end.toISOString() + .slice(0, 10), + } +} + +/** + * Validates the UI's inclusive analytics date range before requesting the API. + * + * @param range candidate from/to values. + * @param reference optional current timestamp, primarily for deterministic tests. + * @returns validation message or undefined when the range is valid and at most 366 days. + * @throws Does not throw. + */ +export function validateAnalyticsDateRange( + range: AnalyticsDateRange, + reference: Date = new Date(), +): string | undefined { + const from = parseIsoDate(range.from) + const to = parseIsoDate(range.to) + if (!from || !to) return 'Choose a valid start and end date.' + if (from.getTime() > to.getTime()) return 'The start date must not be after the end date.' + const today = Date.UTC( + reference.getUTCFullYear(), + reference.getUTCMonth(), + reference.getUTCDate(), + ) + if (to.getTime() > today) return 'The end date must not be in the future.' + const days = Math.floor((to.getTime() - from.getTime()) / 86_400_000) + 1 + return days > 366 ? 'Analytics date ranges cannot exceed 366 days.' : undefined +} + +/** + * Formats an aggregate count with locale separators. + * + * @param value finite aggregate count. + * @returns formatted integer string. + * @throws Does not throw. + */ +export function formatAnalyticsInteger(value: number): string { + return integerFormatter.format(Number.isFinite(value) ? value : 0) +} + +/** + * Formats a conversion percentage to at most two decimal places. + * + * @param value finite percent value. + * @returns percent string including the percent sign. + * @throws Does not throw. + */ +export function formatAnalyticsPercent(value: number): string { + const safeValue = Number.isFinite(value) ? value : 0 + return `${safeValue.toLocaleString('en-US', { maximumFractionDigits: 2 })}%` +} + +/** + * Formats API freshness metadata in the viewer's local timezone. + * + * @param value ISO timestamp, YYYY-MM-DD, or absent value. + * @returns display label or "Not available". + * @throws Does not throw for malformed inputs. + */ +export function formatAnalyticsFreshness(value?: string): string { + if (!value) return 'Not available' + const isCalendarDate = /^\d{4}-\d{2}-\d{2}$/.test(value) + const candidate = isCalendarDate ? `${value}T00:00:00Z` : value + const parsed = new Date(candidate) + if (Number.isNaN(parsed.getTime())) return value + return isCalendarDate + ? calendarDateFormatter.format(parsed) + : timestampFormatter.format(parsed) +} + +/** + * Converts a safe internal surface identifier into a readable label. + * + * @param value analytics surface identifier. + * @returns title-cased label. + * @throws Does not throw. + */ +export function formatAnalyticsSurface(value: string): string { + return value + .split('_') + .filter(Boolean) + .map(part => `${part.charAt(0) + .toUpperCase()}${part.slice(1)}`) + .join(' ') || 'Unknown' +} + +/** + * Formats one privacy-safe ten-percentage-point click bucket. + * + * @param x horizontal bucket start or undefined. + * @param y vertical bucket start or undefined. + * @returns readable coarse position. + * @throws Does not throw. + */ +export function formatClickBucket(x?: number, y?: number): string { + if (x === undefined || y === undefined) return 'Unknown' + return `x ${x}โ€“${Math.min(100, x + 9)}%, y ${y}โ€“${Math.min(100, y + 9)}%` +} + +/** + * Builds a stable request identity from applied report filters. + * + * @param prefix report type. + * @param filters scalar applied filters. + * @returns stable key suitable for a request hook dependency. + * @throws Does not throw. + */ +export function analyticsRequestKey( + prefix: string, + filters: AnalyticsDateRange, +): string { + return `${prefix}:${Object.entries(filters) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value ?? ''}`) + .join('&')}` +} + +/** + * Parses a strict calendar date at UTC midnight. + * + * @param value YYYY-MM-DD input. + * @returns valid Date or undefined. + * @throws Does not throw. + */ +function parseIsoDate(value: string): Date | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined + const parsed = new Date(`${value}T00:00:00Z`) + return Number.isNaN(parsed.getTime()) || parsed.toISOString() + .slice(0, 10) !== value + ? undefined + : parsed +} diff --git a/src/apps/analytics/src/lib/utils/index.ts b/src/apps/analytics/src/lib/utils/index.ts new file mode 100644 index 000000000..10bfbfcdf --- /dev/null +++ b/src/apps/analytics/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './analytics.utils' diff --git a/src/apps/analytics/src/pages/AnalyticsPages.module.scss b/src/apps/analytics/src/pages/AnalyticsPages.module.scss new file mode 100644 index 000000000..90af87560 --- /dev/null +++ b/src/apps/analytics/src/pages/AnalyticsPages.module.scss @@ -0,0 +1,258 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0d3445; + display: flex; + flex-direction: column; + gap: $sp-6; +} + +.page-header { + align-items: flex-start; + display: flex; + gap: $sp-6; + justify-content: space-between; + + h1 { + font-size: 32px; + line-height: 1.2; + margin: 2px 0 $sp-2; + } + + p:not(.eyebrow) { + color: #5a6f78; + margin: 0; + max-width: 760px; + } +} + +.eyebrow { + color: #137d60; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + margin: 0; + text-transform: uppercase; +} + +.filters { + align-items: end; + background: #ffffff; + border: 1px solid #dce3e9; + border-radius: 10px; + display: grid; + gap: $sp-4; + grid-template-columns: repeat(4, minmax(145px, 1fr)); + padding: $sp-5; + + label { + display: flex; + flex-direction: column; + gap: 7px; + } + + label > span { + color: #455b65; + font-size: 12px; + font-weight: 800; + } + + input, + select { + background: #ffffff; + border: 1px solid #aebbc2; + border-radius: 6px; + color: #0d3445; + font: inherit; + height: 42px; + min-width: 0; + padding: 0 11px; + + &:focus { + border-color: #137d60; + box-shadow: 0 0 0 2px rgba(19, 125, 96, 0.2); + outline: none; + } + } +} + +.filter-heading { + grid-column: 1 / -1; + + h2 { + font-size: 18px; + margin: 0; + } + + span { + color: #71838b; + font-size: 13px; + } +} + +.filter-actions { + display: flex; + gap: $sp-3; +} + +.filter-error { + color: #b42318; + font-size: 13px; + grid-column: 1 / -1; + margin: 0; +} + +.freshness { + color: #617681; + font-size: 13px; + text-align: right; +} + +.stale-warning { + background: #fff8e1; + border: 1px solid #e9b949; + border-radius: 6px; + color: #704d00; + padding: $sp-3 $sp-4; +} + +.metric-grid { + display: grid; + gap: $sp-4; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.panel { + background: #ffffff; + border: 1px solid #dce3e9; + border-radius: 10px; + box-shadow: 0 2px 8px rgba(13, 52, 69, 0.035); + min-width: 0; + padding: $sp-5; +} + +.panel-header { + align-items: flex-start; + display: flex; + gap: $sp-4; + justify-content: space-between; + margin-bottom: $sp-4; + + h2 { + font-size: 20px; + margin: 0 0 3px; + } + + p { + color: #617681; + font-size: 13px; + margin: 0; + } + + > strong { + color: #137d60; + font-size: 14px; + white-space: nowrap; + } +} + +.two-column-grid { + display: grid; + gap: $sp-5; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.table-scroll { + overflow-x: auto; + + table { + border-collapse: collapse; + font-size: 13px; + min-width: 100%; + text-align: left; + } + + th, + td { + border-bottom: 1px solid #e4eaee; + padding: 12px 14px; + vertical-align: top; + } + + thead th { + background: #f2f6f8; + color: #455b65; + font-size: 11px; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; + } + + tbody th { + color: #0d3445; + font-weight: 700; + } + + tbody tr:last-child th, + tbody tr:last-child td { + border-bottom: 0; + } +} + +.primary-cell, +.secondary-cell { + display: block; +} + +.secondary-cell { + color: #71838b; + font-size: 11px; + font-weight: 400; + margin-top: 3px; +} + +.empty-table { + color: #71838b; + padding: $sp-8 !important; + text-align: center; +} + +@include ltemd { + .page-header { + align-items: stretch; + flex-direction: column; + + h1 { + font-size: 27px; + } + } + + .filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .metric-grid, + .two-column-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@include ltesm { + .filters, + .metric-grid, + .two-column-grid { + grid-template-columns: 1fr; + } + + .panel { + padding: $sp-4; + } + + .panel-header { + flex-direction: column; + } + + .freshness { + text-align: left; + } +} diff --git a/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx b/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx new file mode 100644 index 000000000..ce77426f9 --- /dev/null +++ b/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx @@ -0,0 +1,432 @@ +/** Campaign efficiency dashboard from landing page through challenge submission. */ +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useMemo, + useState, +} from 'react' + +import { + Button, + IconOutline, + LoadingSpinner, + PageTitle, +} from '~/libs/ui' + +import { + MetricCard, + ReportError, + TimeSeriesChart, +} from '../lib/components' +import { useAnalyticsResource } from '../lib/hooks' +import { + AnalyticsFilterOptions, + CampaignFilters, + CampaignReport, +} from '../lib/models' +import { + getAnalyticsFilters, + getCampaignReport, +} from '../lib/services' +import { + analyticsRequestKey, + defaultAnalyticsDateRange, + formatAnalyticsFreshness, + formatAnalyticsInteger, + formatAnalyticsPercent, + formatClickBucket, + validateAnalyticsDateRange, +} from '../lib/utils' + +import styles from './AnalyticsPages.module.scss' + +const campaignSeries = [ + { color: '#2c95d7', key: 'landingUsers', label: 'Landing visitors' }, + { color: '#6f42c1', key: 'landingClickers', label: 'Clicked' }, + { color: '#f59e0b', key: 'registrations', label: 'Registered' }, + { color: '#137d60', key: 'submissions', label: 'Submitted' }, +] + +/** + * Creates the complete default campaign filter state. + * + * @returns thirty-day range with no UTM restriction. + * @throws Does not throw. + */ +function initialCampaignFilters(): CampaignFilters { + return { + ...defaultAnalyticsDateRange(), + campaign: '', + campaignId: '', + medium: '', + source: '', + } +} + +interface FilterSelectProps { + label: string + name: string + onChange: (event: ChangeEvent) => void + options?: string[] + value?: string +} + +/** + * Renders one server-populated optional campaign filter. + * + * @param props label, form name, options, value, and change callback. + * @returns accessible select with an all-values option. + * @throws Does not throw. + */ +const FilterSelect: FC = props => ( + +) + +/** + * Renders a consistent empty message spanning an analytics table. + * + * @param props number of columns spanned by the message. + * @returns table row empty state. + * @throws Does not throw. + */ +const EmptyTableRow: FC<{ columns: number }> = props => ( + + + No data matches these filters. + + +) + +/** + * Renders campaign filters, funnel KPIs, daily graph, and safe breakdown tables. + * + * @returns role-gated Campaign Analytics page. + * @throws Does not throw; request failures are rendered inline. + */ +export const CampaignAnalyticsPage: FC = () => { + const initialFilters = useMemo(initialCampaignFilters, []) + const [draftFilters, setDraftFilters] = useState(initialFilters) + const [appliedFilters, setAppliedFilters] = useState(initialFilters) + const [filterError, setFilterError] = useState() + const filterOptions = useAnalyticsResource('analytics-filters', getAnalyticsFilters) + const reportKey = analyticsRequestKey('campaign', appliedFilters) + const report = useAnalyticsResource( + reportKey, + useCallback(() => getCampaignReport(appliedFilters), [appliedFilters]), + ) + + const updateFilter = useCallback((event: ChangeEvent) => { + const { name, value }: { name: string; value: string } = event.target + setDraftFilters(current => ({ ...current, [name]: value })) + }, []) + + const applyFilters = useCallback((event: FormEvent) => { + event.preventDefault() + const error = validateAnalyticsDateRange(draftFilters) + setFilterError(error) + if (!error) setAppliedFilters({ ...draftFilters }) + }, [draftFilters]) + + const resetFilters = useCallback(() => { + const next = initialCampaignFilters() + setDraftFilters(next) + setAppliedFilters(next) + setFilterError(undefined) + }, []) + + const data = report.data + return ( + <> + Campaign Analytics + {(report.loading || report.refreshing) && ( + + )} +
+
+
+

Campaign efficiency

+

Landing page to submission

+

+ Measure first-touch campaign engagement through click, registration, + and successful challenge submission. +

+
+ +
+ +
+
+

Filters

+ First-touch UTM attribution +
+ + + + + + +
+ + +
+ {filterError && ( +

{filterError}

+ )} + + + {report.error && !data && } + {data && ( + <> +
+ Data through + {' '} + {formatAnalyticsFreshness(data.dataThrough)} + {' ยท Daily processing'} +
+ {report.error && ( +
{report.error.message}
+ )} +
+ + + + +
+ +
+
+
+

Engagement over time

+

Daily first-touch cohorts for each ordered funnel stage.

+
+ + {`${formatAnalyticsPercent( + data.totals.landingToSubmissionPercent, + )} overall conversion`} + +
+ +
+ +
+
+
+

Campaign performance

+

First-touch attribution grouped by campaign, source, medium, and UTM ID.

+
+
+
+ + + + + + + + + + + + + + {data.campaigns.map(row => ( + + + + + + + + + + ))} + {data.campaigns.length === 0 && } + +
CampaignUTM IDSource / mediumLandingClickedRegisteredSubmitted
{row.campaign}{row.campaignId || 'โ€”'}{`${row.source} / ${row.medium}`}{formatAnalyticsInteger(row.landingUsers)}{formatAnalyticsInteger(row.landingClickers)}{formatAnalyticsInteger(row.registrations)}{formatAnalyticsInteger(row.submissions)}
+
+
+ +
+
+
+
+

Landing pages

+

Entry pages contributing to the selected funnel.

+
+
+
+ + + + + + + + + + {data.landingPages.map(row => ( + + + + + + ))} + {data.landingPages.length === 0 && } + +
PathVisitorsSubmitted
{row.path}{formatAnalyticsInteger(row.landingUsers)}{formatAnalyticsInteger(row.submissions)}
+
+
+ +
+
+
+

Where people clicked

+

Safe element, placement, destination, and coarse viewport location.

+
+
+
+ + + + + + + + + + + {data.clickLocations.map(row => ( + + + + + + + ))} + {data.clickLocations.length === 0 && } + +
Page / elementPositionClicksPeople
+ {row.pagePath} + + {[row.placement, row.elementId || row.elementType] + .filter(Boolean) + .join(' ยท ') || 'Unlabelled interactive element'} + + {formatClickBucket(row.xBucket, row.yBucket)}{formatAnalyticsInteger(row.clicks)}{formatAnalyticsInteger(row.clickers)}
+
+
+
+ + )} +
+ + ) +} + +export default CampaignAnalyticsPage diff --git a/src/apps/analytics/src/pages/GeneralAnalyticsPage.tsx b/src/apps/analytics/src/pages/GeneralAnalyticsPage.tsx new file mode 100644 index 000000000..5369a6131 --- /dev/null +++ b/src/apps/analytics/src/pages/GeneralAnalyticsPage.tsx @@ -0,0 +1,330 @@ +/** General Topcoder site engagement dashboard. */ +import { + ChangeEvent, + FC, + FormEvent, + useCallback, + useMemo, + useState, +} from 'react' + +import { + Button, + IconOutline, + LoadingSpinner, + PageTitle, +} from '~/libs/ui' + +import { + MetricCard, + ReportError, + TimeSeriesChart, +} from '../lib/components' +import { useAnalyticsResource } from '../lib/hooks' +import { + AnalyticsFilterOptions, + GeneralFilters, + GeneralReport, +} from '../lib/models' +import { + getAnalyticsFilters, + getGeneralReport, +} from '../lib/services' +import { + analyticsRequestKey, + defaultAnalyticsDateRange, + formatAnalyticsFreshness, + formatAnalyticsInteger, + formatAnalyticsSurface, + validateAnalyticsDateRange, +} from '../lib/utils' + +import styles from './AnalyticsPages.module.scss' + +const generalSeries = [ + { color: '#2c95d7', key: 'pageViews', label: 'Page views' }, + { color: '#6f42c1', key: 'visitors', label: 'Visitors' }, + { color: '#137d60', key: 'clicks', label: 'Clicks' }, +] + +/** + * Creates the complete default general analytics filter state. + * + * @returns thirty-day range across every surface. + * @throws Does not throw. + */ +function initialGeneralFilters(): GeneralFilters { + return { ...defaultAnalyticsDateRange(), surface: '' } +} + +/** + * Renders a consistent empty message spanning a general analytics table. + * + * @param props number of table columns spanned by the message. + * @returns table row empty state. + * @throws Does not throw. + */ +const EmptyTableRow: FC<{ columns: number }> = props => ( + + + No data matches these filters. + + +) + +/** + * Renders general site totals, engagement graph, and page/source/surface tables. + * + * @returns role-gated General Analytics page. + * @throws Does not throw; request failures are rendered inline. + */ +export const GeneralAnalyticsPage: FC = () => { + const initialFilters = useMemo(initialGeneralFilters, []) + const [draftFilters, setDraftFilters] = useState(initialFilters) + const [appliedFilters, setAppliedFilters] = useState(initialFilters) + const [filterError, setFilterError] = useState() + const filterOptions = useAnalyticsResource('analytics-filters', getAnalyticsFilters) + const reportKey = analyticsRequestKey('general', appliedFilters) + const report = useAnalyticsResource( + reportKey, + useCallback(() => getGeneralReport(appliedFilters), [appliedFilters]), + ) + + const updateFilter = useCallback((event: ChangeEvent) => { + const { name, value }: { name: string; value: string } = event.target + setDraftFilters(current => ({ ...current, [name]: value })) + }, []) + const applyFilters = useCallback((event: FormEvent) => { + event.preventDefault() + const error = validateAnalyticsDateRange(draftFilters) + setFilterError(error) + if (!error) setAppliedFilters({ ...draftFilters }) + }, [draftFilters]) + const resetFilters = useCallback(() => { + const next = initialGeneralFilters() + setDraftFilters(next) + setAppliedFilters(next) + setFilterError(undefined) + }, []) + + const data = report.data + return ( + <> + General Analytics + {(report.loading || report.refreshing) && ( + + )} +
+
+
+

Topcoder.com engagement

+

General site analytics

+

See traffic, visitors, clicks, and the pages people visit over time.

+
+ +
+ +
+
+

Filters

+ Daily site engagement +
+ + + +
+ + +
+ {filterError && ( +

{filterError}

+ )} +
+ + {report.error && !data && } + {data && ( + <> +
+ Data through + {' '} + {formatAnalyticsFreshness(data.dataThrough)} + {' ยท Daily processing'} +
+ {report.error && ( +
{report.error.message}
+ )} +
+ + + + +
+ +
+
+
+

Site engagement over time

+

Daily views, visitors, and interactions across the selected surface.

+
+
+ +
+ +
+
+
+

Most visited pages

+

Query-free paths grouped by application surface.

+
+
+
+ + + + + + + + + + + {data.pages.map(row => ( + + + + + + + ))} + {data.pages.length === 0 && } + +
SurfacePage pathPage viewsVisitors
{formatAnalyticsSurface(row.surface)}{row.path}{formatAnalyticsInteger(row.pageViews)}{formatAnalyticsInteger(row.visitors)}
+
+
+ +
+
+
+
+

Traffic sources

+

First-touch source for viewed pages.

+
+
+
+ + + + + + + + + + {data.sources.map(row => ( + + + + + + ))} + {data.sources.length === 0 && } + +
SourcePage viewsVisitors
{row.source}{formatAnalyticsInteger(row.pageViews)}{formatAnalyticsInteger(row.visitors)}
+
+
+ +
+
+
+

Application surfaces

+

Engagement split between instrumented Topcoder web applications.

+
+
+
+ + + + + + + + + + + {data.surfaces.map(row => ( + + + + + + + ))} + {data.surfaces.length === 0 && } + +
SurfaceViewsVisitorsClicks
{formatAnalyticsSurface(row.surface)}{formatAnalyticsInteger(row.pageViews)}{formatAnalyticsInteger(row.visitors)}{formatAnalyticsInteger(row.clicks)}
+
+
+
+ + )} +
+ + ) +} + +export default GeneralAnalyticsPage diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 1035c8b4d..1a4250829 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -21,6 +21,7 @@ import { statusRoutes } from '~/apps/status' import { supportRoutes } from '~/apps/support' import { thriveRoutes } from '~/apps/thrive' import { legacyOpportunityRoutes, opportunitiesRoutes } from '~/apps/opportunities' +import { analyticsRoutes } from '~/apps/analytics' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -55,6 +56,7 @@ export const platformRoutes: Array = [ ...calendarRoutes, ...engagementsRoutes, ...procurementRoutes, + ...analyticsRoutes, ...statusRoutes, ...supportRoutes, ...thriveRoutes, diff --git a/src/config/constants.ts b/src/config/constants.ts index ccc73ae55..a246b4a76 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,5 +1,6 @@ export enum AppSubdomain { accounts = 'account-settings', + analytics = 'analytics', devCenter = 'devcenter', earn = 'earn', profiles = 'profiles', @@ -24,6 +25,7 @@ export enum AppSubdomain { export enum ToolTitle { accounts = 'Account Settings', + analytics = 'Analytics', devCenter = 'Dev Center', earn = 'Opportunity Feed', profiles = 'Profiles', diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 4d6b1f3fe..990c74eaf 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -170,6 +170,7 @@ export const AUTH = { } export const ANALYTICS = { + API_URL: getReactEnv('ANALYTICS_API_URL', ''), APP_ID: getReactEnv('AWS_ANALYTICS_APP_ID', ''), ENDPOINT: getReactEnv('AWS_ANALYTICS_ENDPOINT', ''), } diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index df4e8245a..d6053690c 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -13,6 +13,7 @@ export type ChallengeTypeNamesByTrackConfig = Record export interface GlobalConfig { ANALYTICS: { + API_URL: string APP_ID: string ENDPOINT: string } diff --git a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts index 93272e0d1..7ba708805 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum.ts @@ -1,4 +1,5 @@ export enum UserRole { + analytics = 'analytics', gamificationAdmin = 'Gamification Admin', customer = 'Self-Service Customer', member = 'Topcoder User', From b4ea3410070006540a5bd2612d07b4bc6f8bd1bb Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 05:57:27 +1000 Subject: [PATCH 03/10] fix: support unset analytics report filters --- infrastructure/analytics-api/src/handler.py | 24 ++++++++++--------- .../analytics-api/tests/test_handler.py | 18 ++++++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py index c42c0dd34..f8acb3498 100644 --- a/infrastructure/analytics-api/src/handler.py +++ b/infrastructure/analytics-api/src/handler.py @@ -23,6 +23,7 @@ REPORT_CACHE_SECONDS = 60 FILTER_CACHE_SECONDS = 300 SAFE_FILTER_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{1,100}$") +NO_FILTER_PARAMETER = "*" _redshift_data = boto3.client("redshift-data") _cache: dict[str, tuple[float, dict[str, Any]]] = {} @@ -89,10 +90,10 @@ SELECT * FROM topcoder_web.challenge_funnel_daily_v1 WHERE cohort_date BETWEEN CAST(:from_date AS date) AND CAST(:to_date AS date) - AND (:campaign = '' OR utm_campaign = :campaign) - AND (:campaign_id = '' OR utm_campaign_id = :campaign_id) - AND (:source = '' OR utm_source = :source) - AND (:medium = '' OR utm_medium = :medium) + AND (:campaign = '*' OR utm_campaign = :campaign) + AND (:campaign_id = '*' OR utm_campaign_id = :campaign_id) + AND (:source = '*' OR utm_source = :source) + AND (:medium = '*' OR utm_medium = :medium) ), filtered_clicks AS ( SELECT @@ -109,10 +110,10 @@ FROM topcoder_web.product_analytics_events_v1 WHERE event_name = 'ui_click' AND event_date BETWEEN CAST(:from_date AS date) AND CAST(:to_date AS date) - AND (:campaign = '' OR utm_campaign = :campaign) - AND (:campaign_id = '' OR utm_campaign_id = :campaign_id) - AND (:source = '' OR utm_source = :source) - AND (:medium = '' OR utm_medium = :medium) + AND (:campaign = '*' OR utm_campaign = :campaign) + AND (:campaign_id = '*' OR utm_campaign_id = :campaign_id) + AND (:source = '*' OR utm_source = :source) + AND (:medium = '*' OR utm_medium = :medium) ), summary_row AS ( SELECT @@ -256,7 +257,7 @@ SELECT * FROM topcoder_web.product_analytics_events_v1 WHERE event_date BETWEEN CAST(:from_date AS date) AND CAST(:to_date AS date) - AND (:surface = '' OR surface = :surface) + AND (:surface = '*' OR surface = :surface) ), summary_row AS ( SELECT @@ -792,7 +793,8 @@ def _sql_parameters(filters: dict[str, str]) -> list[dict[str, str]]: filters: Validated campaign or general filter dictionary. Returns: - Data API parameter objects for keys referenced by the SQL template. + Data API parameter objects for keys referenced by the SQL template. Empty optional filters use + a non-empty sentinel that cannot pass public filter validation because Data API rejects empty values. Raises: Does not raise. @@ -808,7 +810,7 @@ def _sql_parameters(filters: dict[str, str]) -> list[dict[str, str]]: "surface": "surface", } return [ - {"name": names[key], "value": value} + {"name": names[key], "value": value or NO_FILTER_PARAMETER} for key, value in filters.items() if key in names ] diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py index 421875c1f..cf929c77a 100644 --- a/infrastructure/analytics-api/tests/test_handler.py +++ b/infrastructure/analytics-api/tests/test_handler.py @@ -192,6 +192,24 @@ def test_rejects_unsafe_utm_values_before_querying(self) -> None: self.assertEqual(400, response["statusCode"]) execute.assert_not_called() + def test_unset_filters_use_nonempty_data_api_parameters(self) -> None: + """Optional filters use an unreachable sentinel because Data API rejects empty values.""" + + parameters = self.module._sql_parameters({ + "from": "2026-08-01", + "to": "2026-08-30", + "campaign": "", + "source": "newsletter", + }) + values = {parameter["name"]: parameter["value"] for parameter in parameters} + + self.assertEqual("*", values["campaign"]) + self.assertEqual("newsletter", values["source"]) + self.assertTrue(all(parameter["value"] for parameter in parameters)) + self.assertIsNone(self.module.SAFE_FILTER_PATTERN.fullmatch(self.module.NO_FILTER_PARAMETER)) + self.assertIn(":campaign = '*' OR", self.module.CAMPAIGN_SQL) + self.assertIn(":surface = '*' OR", self.module.GENERAL_SQL) + def test_shapes_campaign_funnel_and_click_location(self) -> None: """Campaign rows become totals, conversion rates, series, and safe click dimensions.""" From d055b69fa25ad72b8063bda0760a6beada9da8b7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 06:03:35 +1000 Subject: [PATCH 04/10] fix: use Redshift-compatible click coordinate SQL --- infrastructure/analytics-api/src/handler.py | 4 ++-- infrastructure/analytics-api/tests/test_handler.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py index f8acb3498..50a46440c 100644 --- a/infrastructure/analytics-api/src/handler.py +++ b/infrastructure/analytics-api/src/handler.py @@ -244,8 +244,8 @@ element_type, destination_host, destination_path, - CONCAT(COALESCE(CAST(click_x_bucket AS varchar), ''), ':', - COALESCE(CAST(click_y_bucket AS varchar), '')), + COALESCE(CAST(click_x_bucket AS varchar), '') || ':' || + COALESCE(CAST(click_y_bucket AS varchar), ''), click_count, click_users, NULL, NULL, NULL, NULL, NULL, NULL FROM click_rows ORDER BY row_type, date_value, metric_1 DESC diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py index cf929c77a..8822f5ae6 100644 --- a/infrastructure/analytics-api/tests/test_handler.py +++ b/infrastructure/analytics-api/tests/test_handler.py @@ -210,6 +210,12 @@ def test_unset_filters_use_nonempty_data_api_parameters(self) -> None: self.assertIn(":campaign = '*' OR", self.module.CAMPAIGN_SQL) self.assertIn(":surface = '*' OR", self.module.GENERAL_SQL) + def test_campaign_sql_uses_redshift_coordinate_concatenation(self) -> None: + """Click-coordinate buckets use Redshift-compatible two-operand concatenation.""" + + self.assertNotIn("CONCAT(", self.module.CAMPAIGN_SQL) + self.assertIn("|| ':' ||", self.module.CAMPAIGN_SQL) + def test_shapes_campaign_funnel_and_click_location(self) -> None: """Campaign rows become totals, conversion rates, series, and safe click dimensions.""" From 25dd9a6f228fb47fdfd1ad5acc329e35cfbfe307 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 06:37:49 +1000 Subject: [PATCH 05/10] fix: align analytics API template with provider state --- infrastructure/analytics-api/template.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/infrastructure/analytics-api/template.yaml b/infrastructure/analytics-api/template.yaml index 72e703202..f77d0d0e0 100644 --- a/infrastructure/analytics-api/template.yaml +++ b/infrastructure/analytics-api/template.yaml @@ -161,8 +161,8 @@ Resources: ProtocolType: HTTP CorsConfiguration: AllowHeaders: - - Authorization - - Content-Type + - authorization + - content-type AllowMethods: - GET AllowOrigins: @@ -226,12 +226,13 @@ Resources: DefaultStage: Type: AWS::ApiGatewayV2::Stage + DependsOn: AnalyticsApiAccessLogGroup Properties: ApiId: !Ref AnalyticsHttpApi StageName: $default AutoDeploy: true AccessLogSettings: - DestinationArn: !GetAtt AnalyticsApiAccessLogGroup.Arn + DestinationArn: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/apigateway/topcoder-analytics-api-dev' Format: >- {"requestId":"$context.requestId","routeKey":"$context.routeKey","status":"$context.status","responseLength":"$context.responseLength","integrationError":"$context.integrationErrorMessage"} DefaultRouteSettings: From 1fb427d9983ca17ac5d34f50ad478be1cd269e33 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 06:50:00 +1000 Subject: [PATCH 06/10] fix: retry transient analytics queries --- infrastructure/analytics-api/README.md | 3 +- infrastructure/analytics-api/src/handler.py | 43 ++++++++------- .../analytics-api/tests/test_handler.py | 52 +++++++++++++++++++ 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/infrastructure/analytics-api/README.md b/infrastructure/analytics-api/README.md index 4dfbbaaaf..6ef1608d2 100644 --- a/infrastructure/analytics-api/README.md +++ b/infrastructure/analytics-api/README.md @@ -23,7 +23,8 @@ API named parameters; callers cannot provide SQL, object names, sort clauses, or result limits. Reports are limited to 366 inclusive days and 2,000 decoded rows. Query waits -leave time for a sanitized response, concurrency and API throttles cap warehouse +leave time for a sanitized response. A failed or aborted statement is retried +once within the same deadline, concurrency and API throttles cap warehouse pressure, and successful responses use `Cache-Control: private, no-store`. Logs contain request IDs and service-owned error categories only. diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py index 50a46440c..997efa554 100644 --- a/infrastructure/analytics-api/src/handler.py +++ b/infrastructure/analytics-api/src/handler.py @@ -20,6 +20,7 @@ MAX_DATE_RANGE_DAYS = 366 MAX_RESULT_ROWS = 2_000 +MAX_QUERY_ATTEMPTS = 2 REPORT_CACHE_SECONDS = 60 FILTER_CACHE_SECONDS = 300 SAFE_FILTER_PATTERN = re.compile(r"^[A-Za-z0-9._~-]{1,100}$") @@ -821,7 +822,7 @@ def _execute_query( parameters: list[dict[str, str]], context: Any, ) -> list[dict[str, Any]]: - """Execute a fixed parameterized query and decode its bounded result. + """Execute a fixed parameterized query with one bounded provider retry. Args: sql: Server-owned SQL template. @@ -832,7 +833,8 @@ def _execute_query( Query rows keyed by Redshift column name. Raises: - QueryFailure for provider failure or excess output and QueryTimeout when the deadline expires. + QueryFailure after repeated provider failure or excess output and + QueryTimeout when the shared deadline expires. """ request: dict[str, Any] = { @@ -844,23 +846,28 @@ def _execute_query( } if parameters: request["Parameters"] = parameters - statement_id = _redshift_data.execute_statement(**request)["Id"] deadline = time.monotonic() + _query_wait_seconds(context) - delay = 0.2 - while time.monotonic() < deadline: - status = _redshift_data.describe_statement(Id=statement_id) - if status["Status"] == "FINISHED": - return _statement_rows(statement_id) - if status["Status"] in {"FAILED", "ABORTED"}: - raise QueryFailure("Redshift reporting query failed") - time.sleep(delay) - delay = min(delay * 1.5, 1.0) - - try: - _redshift_data.cancel_statement(Id=statement_id) - except Exception: - pass - raise QueryTimeout("Redshift reporting query timed out") + for attempt in range(MAX_QUERY_ATTEMPTS): + statement_id = _redshift_data.execute_statement(**request)["Id"] + delay = 0.2 + while time.monotonic() < deadline: + status = _redshift_data.describe_statement(Id=statement_id) + if status["Status"] == "FINISHED": + return _statement_rows(statement_id) + if status["Status"] in {"FAILED", "ABORTED"}: + if attempt + 1 < MAX_QUERY_ATTEMPTS: + break + raise QueryFailure("Redshift reporting query failed") + time.sleep(delay) + delay = min(delay * 1.5, 1.0) + else: + try: + _redshift_data.cancel_statement(Id=statement_id) + except Exception: + pass + raise QueryTimeout("Redshift reporting query timed out") + + raise QueryFailure("Redshift reporting query failed") def _query_wait_seconds(context: Any) -> float: diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py index 8822f5ae6..5a407eb78 100644 --- a/infrastructure/analytics-api/tests/test_handler.py +++ b/infrastructure/analytics-api/tests/test_handler.py @@ -216,6 +216,58 @@ def test_campaign_sql_uses_redshift_coordinate_concatenation(self) -> None: self.assertNotIn("CONCAT(", self.module.CAMPAIGN_SQL) self.assertIn("|| ':' ||", self.module.CAMPAIGN_SQL) + def test_retries_one_failed_redshift_statement(self) -> None: + """A transient failed statement is retried once inside the request deadline.""" + + context = Mock() + context.get_remaining_time_in_millis.return_value = 28_000 + with ( + patch.object( + self.module._redshift_data, + "execute_statement", + side_effect=[{"Id": "failed"}, {"Id": "finished"}], + ) as execute, + patch.object( + self.module._redshift_data, + "describe_statement", + side_effect=[{"Status": "FAILED"}, {"Status": "FINISHED"}], + ), + patch.object( + self.module._redshift_data, + "get_statement_result", + return_value={ + "ColumnMetadata": [{"name": "value"}], + "Records": [[{"stringValue": "recovered"}]], + }, + ), + ): + rows = self.module._execute_query("SELECT 1", [], context) + + self.assertEqual([{"value": "recovered"}], rows) + self.assertEqual(2, execute.call_count) + + def test_stops_after_bounded_redshift_retries(self) -> None: + """Repeated failed statements surface an error after two attempts.""" + + context = Mock() + context.get_remaining_time_in_millis.return_value = 28_000 + with ( + patch.object( + self.module._redshift_data, + "execute_statement", + side_effect=[{"Id": "failed-1"}, {"Id": "failed-2"}], + ) as execute, + patch.object( + self.module._redshift_data, + "describe_statement", + side_effect=[{"Status": "FAILED"}, {"Status": "FAILED"}], + ), + self.assertRaises(self.module.QueryFailure), + ): + self.module._execute_query("SELECT 1", [], context) + + self.assertEqual(2, execute.call_count) + def test_shapes_campaign_funnel_and_click_location(self) -> None: """Campaign rows become totals, conversion rates, series, and safe click dimensions.""" From 469d06f620243faea09b7ed8cc2568014ca38aa0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 10:23:46 +1000 Subject: [PATCH 07/10] Deploy branch --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0cc0d57f5..e8412969a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -230,7 +230,7 @@ workflows: - dev - copilot_reviewer - PM-5460 - - opportunities-v6 + - aws_analytics tags: only: /^dev-.*/ From f342dd4114940d5175dea31e0bb4ea8f35efcbb7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 31 Aug 2026 11:25:07 +1000 Subject: [PATCH 08/10] fix: align analytics API routing and roles --- .environments/.env.dev | 2 +- docs/adr/0002-aws-product-analytics.md | 8 +- infrastructure/analytics-api/README.md | 30 ++++--- infrastructure/analytics-api/src/handler.py | 78 ++++++++++++++++--- infrastructure/analytics-api/template.yaml | 75 +++++++++++++++++- .../analytics-api/tests/test_handler.py | 39 +++++++++- src/apps/analytics/README.md | 4 +- .../lib/services/analytics.service.spec.ts | 10 +-- .../src/pages/CampaignAnalyticsPage.tsx | 5 +- 9 files changed, 212 insertions(+), 39 deletions(-) diff --git a/.environments/.env.dev b/.environments/.env.dev index 265b718f6..38b99ddbb 100644 --- a/.environments/.env.dev +++ b/.environments/.env.dev @@ -1,7 +1,7 @@ REACT_APP_HOST_ENV=dev # First-party AWS Clickstream analytics -REACT_APP_ANALYTICS_API_URL=https://analytics-api.topcoder-dev.com/v1/analytics +REACT_APP_ANALYTICS_API_URL=https://api.topcoder-dev.com/v1/analytics REACT_APP_AWS_ANALYTICS_APP_ID=topcoder_web REACT_APP_AWS_ANALYTICS_ENDPOINT=https://events.topcoder-dev.com/collect diff --git a/docs/adr/0002-aws-product-analytics.md b/docs/adr/0002-aws-product-analytics.md index feab4e8e2..611df82e7 100644 --- a/docs/adr/0002-aws-product-analytics.md +++ b/docs/adr/0002-aws-product-analytics.md @@ -193,11 +193,11 @@ s3://topcoder-clickstream-templates-dev-811668436784/custom-assets/product-analy Platform UI also provides an operator-facing application at https://analytics.topcoder-dev.com. Every app route requires an authenticated profile with the exact analytics role. Its read-only HTTP API is exposed at -https://analytics-api.topcoder-dev.com/v1/analytics. API Gateway validates the +https://api.topcoder-dev.com/v1/analytics. API Gateway validates the development Auth0 issuer and human-client audience; Lambda independently checks -the https://topcoder-dev.com/roles claim before issuing fixed, parameterized, -bounded Redshift Data API queries through the analytics_api_reader database -role. The API returns aggregate data only and marks every response private and +a verified Topcoder roles claim before issuing fixed, parameterized, bounded +Redshift Data API queries through the analytics_api_reader database role. The +API returns aggregate data only and marks every response private and non-cacheable. The Campaigns tab exposes the ordered landing, click, registration, and diff --git a/infrastructure/analytics-api/README.md b/infrastructure/analytics-api/README.md index 6ef1608d2..b6f1e5b9d 100644 --- a/infrastructure/analytics-api/README.md +++ b/infrastructure/analytics-api/README.md @@ -8,7 +8,8 @@ Clickstream Redshift reporting views; it is not an ingestion endpoint. ```text Platform UI - -> API Gateway HTTP API JWT authorizer + -> api. shared CloudFront/API Gateway HTTP API + -> analytics-route JWT authorizer -> Lambda exact analytics-role check and fixed queries -> Redshift Data API -> analytics_api_reader database role @@ -16,8 +17,8 @@ Platform UI ``` API Gateway validates the configured Auth0 issuer, audience, signature, and -standard JWT time claims. Lambda then requires `analytics` in the configured -namespaced roles claim. The handler accepts only three fixed `GET` routes, +standard JWT time claims. Lambda then requires `analytics` in a verified +Topcoder roles claim. The handler accepts only three fixed `GET` routes, strict dates, and bounded UTM/surface tokens. SQL is server-owned and uses Data API named parameters; callers cannot provide SQL, object names, sort clauses, or result limits. @@ -30,8 +31,9 @@ Logs contain request IDs and service-owned error categories only. ## Files -- `template.yaml` provisions the API, JWT authorizer, Lambda, least-privilege - query role, logs, custom domain, and Route 53 record. +- `template.yaml` registers protected analytics routes on the shared API and + provisions the JWT authorizer, Lambda, least-privilege query role, and logs. + The former dedicated API remains during the cutover observation window. - `src/handler.py` validates and shapes filter, campaign, and general reports. - `bootstrap.sql` creates the read-only Redshift database role and grants only the reporting objects required by the handler. @@ -53,8 +55,9 @@ secret values into parameters, source files, or shell history. preflight/request to `/collect` before changing either client configuration. 4. Package `src/handler.py` as a versioned zip in the encrypted Clickstream templates bucket. -5. Deploy `template.yaml` with `CAPABILITY_NAMED_IAM` and the exact workgroup, - wildcard certificate, public hosted zone, code bucket, and code key. +5. Deploy `template.yaml` with `CAPABILITY_NAMED_IAM`, the shared HTTP API ID, + and the exact workgroup, wildcard certificate, public hosted zone, code + bucket, and code key. 6. Run `bootstrap.sql` through the Data API using the Redshift namespace's managed administrator secret. On reapplication, omit `CREATE ROLE` if the role already exists and run the idempotent `GRANT` statements. @@ -93,13 +96,18 @@ valid token without analytics -> 403 from Lambda valid token with analytics -> 200 aggregate JSON ``` +The canonical development endpoint is +`https://api.topcoder-dev.com/v1/analytics`. Preserve existing shared-stage +route settings when adding 5 requests/second, burst 10, detailed metrics for +the three `GET` routes and the public `OPTIONS /v1/analytics/{proxy+}` route. + Also verify an invalid date returns `400`, an unsupported route returns `404`, responses are `private, no-store`, and CloudWatch logs do not contain tokens, filters, SQL, or record values. ## Production promotion -Provision production as a separate stack and database role. Change the domain, -hosted zone, certificate, workgroup, database, Auth0 issuer, audience, and role -claim to production values. Do not reuse the development Clickstream project, -app ID, S3 prefixes, Redshift namespace, Lambda role, or collector hostname. +Provision production as a separate stack and database role. Change the shared +API ID/domain, workgroup, database, Auth0 issuer, audience, and role claim to +production values. Do not reuse the development Clickstream project, app ID, +S3 prefixes, Redshift namespace, Lambda role, or collector hostname. diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py index 997efa554..9cce2d8c4 100644 --- a/infrastructure/analytics-api/src/handler.py +++ b/infrastructure/analytics-api/src/handler.py @@ -388,10 +388,13 @@ def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: request_id = _request_id(event, context) try: + route_key = str(event.get("routeKey", "")) + if route_key.startswith("OPTIONS "): + return _preflight_response() + if not _has_required_role(event): return _response(403, {"message": "Analytics access is not permitted", "requestId": request_id}) - route_key = str(event.get("routeKey", "")) query = event.get("queryStringParameters") or {} if route_key == "GET /v1/analytics/filters": @@ -940,7 +943,7 @@ def _field_value(field: dict[str, Any]) -> Any: def _has_required_role(event: dict[str, Any]) -> bool: - """Require the exact configured role from API Gateway-verified JWT claims. + """Require the exact role from supported API Gateway-verified Topcoder claims. Args: event: API Gateway event containing JWT authorizer claims. @@ -953,21 +956,74 @@ def _has_required_role(event: dict[str, Any]) -> bool: """ claims = (((event.get("requestContext") or {}).get("authorizer") or {}).get("jwt") or {}).get("claims") or {} - raw_roles = claims.get(os.environ.get("HUMAN_ROLE_CLAIM", "https://topcoder-dev.com/roles")) + if not isinstance(claims, dict): + return False + + configured_claim = os.environ.get("HUMAN_ROLE_CLAIM", "https://topcoder-dev.com/roles") + claim_names = [configured_claim] + claim_names.extend( + key for key in claims + if isinstance(key, str) + and key != configured_claim + and (key == "roles" or key.endswith("/roles")) + ) roles: list[str] = [] + for claim_name in claim_names: + roles.extend(_role_values(claims.get(claim_name))) + required = os.environ.get("REQUIRED_ROLE", "analytics").strip() + return required in {role.strip() for role in roles} + + +def _role_values(raw_roles: Any) -> list[str]: + """Normalize one verified JWT role-claim representation. + + Args: + raw_roles: List or string claim supplied by the API Gateway JWT authorizer. + + Returns: + String role values, preserving their original case for exact matching. + + Raises: + Does not raise; malformed claim values produce an empty list. + """ + if isinstance(raw_roles, list): - roles = [role for role in raw_roles if isinstance(role, str)] - elif isinstance(raw_roles, str): + return [role for role in raw_roles if isinstance(role, str)] + if isinstance(raw_roles, str): try: parsed = json.loads(raw_roles) if isinstance(parsed, list): - roles = [role for role in parsed if isinstance(role, str)] - else: - roles = [raw_roles] + return [role for role in parsed if isinstance(role, str)] + if isinstance(parsed, str): + return [parsed] except json.JSONDecodeError: - roles = [part for part in re.split(r"[\s,]+", raw_roles) if part] - required = os.environ.get("REQUIRED_ROLE", "analytics").strip() - return required in {role.strip() for role in roles} + return [ + part.strip("[]\"'") + for part in re.split(r"[\s,]+", raw_roles) + if part.strip("[]\"'") + ] + return [] + + +def _preflight_response() -> dict[str, Any]: + """Build the empty response used by the shared API's analytics preflight route. + + Returns: + HTTP API v2 response; the shared CloudFront response policy adds the + environment's public CORS headers. + + Raises: + Does not raise. + """ + + return { + "statusCode": 204, + "headers": { + "Cache-Control": "no-store", + "Vary": "Origin", + }, + "body": "", + } def _response(status_code: int, body: dict[str, Any]) -> dict[str, Any]: diff --git a/infrastructure/analytics-api/template.yaml b/infrastructure/analytics-api/template.yaml index f77d0d0e0..de4c1e528 100644 --- a/infrastructure/analytics-api/template.yaml +++ b/infrastructure/analytics-api/template.yaml @@ -29,6 +29,14 @@ Parameters: HumanRoleClaim: Type: String Default: https://topcoder-dev.com/roles + SharedApiDomainName: + Type: String + Default: api.topcoder-dev.com + Description: Canonical public API hostname backed by the shared HTTP API. + SharedApiId: + Type: String + Default: oid1rrke97 + Description: Existing environment HTTP API behind the public CloudFront distribution. WorkgroupArn: Type: String Description: Exact Redshift Serverless workgroup ARN allowed by the query role. @@ -252,6 +260,71 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${AnalyticsHttpApi}/*/GET/v1/analytics/*' + SharedAnalyticsJwtAuthorizer: + Type: AWS::ApiGatewayV2::Authorizer + Properties: + ApiId: !Ref SharedApiId + AuthorizerType: JWT + IdentitySource: + - $request.header.Authorization + JwtConfiguration: + Audience: + - !Ref HumanJwtAudience + Issuer: !Ref HumanJwtIssuer + Name: topcoder-human-jwt-analytics + + SharedAnalyticsIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref SharedApiId + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt AnalyticsFunction.Arn + PayloadFormatVersion: '2.0' + TimeoutInMillis: 29000 + + SharedFiltersRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref SharedApiId + AuthorizationType: JWT + AuthorizerId: !Ref SharedAnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/filters + Target: !Sub 'integrations/${SharedAnalyticsIntegration}' + + SharedCampaignRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref SharedApiId + AuthorizationType: JWT + AuthorizerId: !Ref SharedAnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/campaign + Target: !Sub 'integrations/${SharedAnalyticsIntegration}' + + SharedGeneralRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref SharedApiId + AuthorizationType: JWT + AuthorizerId: !Ref SharedAnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/general + Target: !Sub 'integrations/${SharedAnalyticsIntegration}' + + SharedPreflightRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref SharedApiId + AuthorizationType: NONE + RouteKey: OPTIONS /v1/analytics/{proxy+} + Target: !Sub 'integrations/${SharedAnalyticsIntegration}' + + SharedAnalyticsInvokePermission: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref AnalyticsFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub 'arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${SharedApiId}/*/*/v1/analytics/*' + AnalyticsApiDomain: Type: AWS::ApiGatewayV2::DomainName Properties: @@ -286,7 +359,7 @@ Resources: Outputs: ApiEndpoint: - Value: !Sub 'https://${DomainName}/v1/analytics' + Value: !Sub 'https://${SharedApiDomainName}/v1/analytics' FunctionArn: Value: !GetAtt AnalyticsFunction.Arn FunctionRoleName: diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py index 5a407eb78..131c082c5 100644 --- a/infrastructure/analytics-api/tests/test_handler.py +++ b/infrastructure/analytics-api/tests/test_handler.py @@ -85,6 +85,7 @@ def _event( route_key: str, roles: object = None, query: dict[str, str] | None = None, + role_claim: str = "https://topcoder-dev.com/roles", ) -> dict[str, object]: """Build one API Gateway v2 event. @@ -92,6 +93,7 @@ def _event( route_key: API Gateway route key. roles: Namespaced role claim value. query: Optional query string parameters. + role_claim: Verified JWT claim name containing the roles. Returns: Synthetic API Gateway event. @@ -102,7 +104,7 @@ def _event( claims = {} if roles is not None: - claims["https://topcoder-dev.com/roles"] = roles + claims[role_claim] = roles return { "queryStringParameters": query, "requestContext": { @@ -151,6 +153,41 @@ def test_accepts_json_array_role_claim_and_returns_private_filters(self) -> None self.assertEqual(["launch"], body["campaigns"]) self.assertEqual("2026-08-30", body["dataThrough"]) + def test_accepts_verified_topcoder_role_claim_variants(self) -> None: + """V2/V3 Topcoder role namespaces and API Gateway string forms authorize identically.""" + + variants = [ + ("https://topcoder.com/roles", ["analytics"]), + ("roles", "analytics"), + ("roles", "[analytics]"), + ] + with patch.object(self.module, "_execute_query", return_value=[]): + for claim_name, roles in variants: + with self.subTest(claim_name=claim_name, roles=roles): + self.module._cache.clear() + response = self.module.handler( + self._event( + "GET /v1/analytics/filters", + roles, + role_claim=claim_name, + ), + None, + ) + self.assertEqual(200, response["statusCode"]) + + def test_shared_api_preflight_does_not_require_a_role(self) -> None: + """The public OPTIONS route returns no data and never queries Redshift.""" + + with patch.object(self.module, "_execute_query") as execute: + response = self.module.handler( + self._event("OPTIONS /v1/analytics/{proxy+}"), + None, + ) + + self.assertEqual(204, response["statusCode"]) + self.assertEqual("", response["body"]) + execute.assert_not_called() + def test_rejects_invalid_or_excessive_date_ranges(self) -> None: """Malformed and unbounded reporting requests fail before Redshift.""" diff --git a/src/apps/analytics/README.md b/src/apps/analytics/README.md index 12741f7f0..cba74d3e4 100644 --- a/src/apps/analytics/README.md +++ b/src/apps/analytics/README.md @@ -10,7 +10,7 @@ Clickstream data. It is bundled with Platform UI and is available at Every Analytics route requires an authenticated profile with the exact `analytics` role. The browser guard controls navigation only; the analytics API also validates the Auth0 JWT issuer and audience and independently checks the -environment's namespaced roles claim before running a query. +verified Topcoder roles claim before running a query. The route tree is: @@ -53,7 +53,7 @@ a refresh fails. Set the following build variable for each provisioned environment: ```text -REACT_APP_ANALYTICS_API_URL=https://analytics-api./v1/analytics +REACT_APP_ANALYTICS_API_URL=https://api./v1/analytics ``` Leave it empty where the API has not been provisioned. Authenticated requests diff --git a/src/apps/analytics/src/lib/services/analytics.service.spec.ts b/src/apps/analytics/src/lib/services/analytics.service.spec.ts index 741656264..71cc6b9ef 100644 --- a/src/apps/analytics/src/lib/services/analytics.service.spec.ts +++ b/src/apps/analytics/src/lib/services/analytics.service.spec.ts @@ -10,7 +10,7 @@ import { jest.mock('~/config', () => ({ EnvironmentConfig: { - ANALYTICS: { API_URL: 'https://analytics-api.example.com/v1/analytics/' }, + ANALYTICS: { API_URL: 'https://api.example.com/v1/analytics/' }, }, }), { virtual: true }) @@ -34,7 +34,7 @@ describe('Analytics API service', () => { to: '2026-08-30', })) .toBe( - 'https://analytics-api.example.com/v1/analytics/campaign' + 'https://api.example.com/v1/analytics/campaign' + '?from=2026-08-01&to=2026-08-30&campaign=launch+2026', ) }) @@ -55,18 +55,18 @@ describe('Analytics API service', () => { expect(mockedXhrGetAsync) .toHaveBeenNthCalledWith( 1, - 'https://analytics-api.example.com/v1/analytics/filters', + 'https://api.example.com/v1/analytics/filters', ) expect(mockedXhrGetAsync) .toHaveBeenNthCalledWith( 2, - 'https://analytics-api.example.com/v1/analytics/campaign' + 'https://api.example.com/v1/analytics/campaign' + '?from=2026-08-01&to=2026-08-30&campaign=launch', ) expect(mockedXhrGetAsync) .toHaveBeenNthCalledWith( 3, - 'https://analytics-api.example.com/v1/analytics/general' + 'https://api.example.com/v1/analytics/general' + '?from=2026-08-01&to=2026-08-30&surface=platform_ui', ) }) diff --git a/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx b/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx index ce77426f9..831fd3e3c 100644 --- a/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx +++ b/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx @@ -157,7 +157,7 @@ export const CampaignAnalyticsPage: FC = () => {

Campaign efficiency

Landing page to submission

- Measure first-touch campaign engagement through click, registration, + Measure campaign engagement through click, registration, and successful challenge submission.

@@ -175,7 +175,6 @@ export const CampaignAnalyticsPage: FC = () => {

Filters

- First-touch UTM attribution