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-.*/ diff --git a/.environments/.env.dev b/.environments/.env.dev index ab1b994f6..38b99ddbb 100644 --- a/.environments/.env.dev +++ b/.environments/.env.dev @@ -1,5 +1,10 @@ REACT_APP_HOST_ENV=dev +# First-party AWS Clickstream 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 + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +16,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..0c3a4bab5 100644 --- a/.environments/.env.prod +++ b/.environments/.env.prod @@ -1,5 +1,10 @@ REACT_APP_HOST_ENV=prod +# First-party AWS Clickstream analytics +REACT_APP_ANALYTICS_API_URL= +REACT_APP_AWS_ANALYTICS_APP_ID= +REACT_APP_AWS_ANALYTICS_ENDPOINT= + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +16,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..091977cd2 100644 --- a/.environments/.env.qa +++ b/.environments/.env.qa @@ -1,5 +1,10 @@ REACT_APP_HOST_ENV=qa +# First-party AWS Clickstream analytics +REACT_APP_ANALYTICS_API_URL= +REACT_APP_AWS_ANALYTICS_APP_ID= +REACT_APP_AWS_ANALYTICS_ENDPOINT= + REACT_APP_ENABLE_TCA_CERT_MONETIZATION=false # Stripe configs @@ -11,8 +16,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/.gitignore b/.gitignore index ada8e1f55..cf9575e76 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ yarn-debug.log* yarn-error.log* storybook-static + +# Python build artifacts +__pycache__/ +*.py[cod] diff --git a/docs/adr/0002-aws-product-analytics.md b/docs/adr/0002-aws-product-analytics.md new file mode 100644 index 000000000..611df82e7 --- /dev/null +++ b/docs/adr/0002-aws-product-analytics.md @@ -0,0 +1,258 @@ +# 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://events.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 collector's durable client hostname is +https://events.topcoder-dev.com/collect. The original +analytics.topcoder-dev.com ingestion alias remains available only during the +cutover observation window; that hostname now belongs to the reporting UI. + +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/. + +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://api.topcoder-dev.com/v1/analytics. API Gateway validates the +development Auth0 issuer and human-client audience; Lambda independently checks +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 +submission funnel with UTM, landing-page, and privacy-safe click-location +breakdowns. The General tab exposes page views, visitors, clicks, pages, +traffic sources, and application surfaces over time. Both report warehouse +freshness and limit callers to 366 inclusive days. QuickSight remains the AWS +native exploratory dashboard; the Platform UI app is the narrowly scoped daily +operational interface. + +## 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://events.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. +8. Verify the reporting API returns `401` without a JWT, `403` for a verified + account without the analytics role, and aggregate JSON for an authorized + account; confirm that its logs contain no tokens, filters, SQL, or records. + +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/infrastructure/analytics-api/README.md b/infrastructure/analytics-api/README.md new file mode 100644 index 000000000..7ac76ba66 --- /dev/null +++ b/infrastructure/analytics-api/README.md @@ -0,0 +1,116 @@ +# Topcoder analytics API + +This directory contains the development infrastructure and Lambda code for the +role-gated Analytics UI. The API is a read-only adapter over the existing AWS +Clickstream Redshift reporting views; it is not an ingestion endpoint. + +## Architecture and security boundary + +```text +Platform UI + -> 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 + -> approved reporting views +``` + +API Gateway validates the configured Auth0 issuer, audience, signature, and +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. + +Reports are limited to 366 inclusive days and 2,000 decoded rows. Query waits +leave time for a sanitized response. A failed or aborted statement is retried +once within the same deadline. If Redshift Serverless needs longer than one +HTTP request to resume, the statement remains active and a browser retry +reattaches to it through a server-generated, thirty-minute idempotency window +instead of starting another warehouse query. 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. + +## Files + +- `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. +- `collector-host-migration.yaml` creates `events.` on the existing + ingestion ALB so `analytics.` can become the reporting UI host. +- `tests/test_handler.py` verifies authorization, validation, parameterization, + privacy-safe click shaping, and response contracts without AWS access. + +## Development deployment order + +Use `us-east-1` and account `811668436784`. Resolve every ARN and hosted-zone ID +from AWS immediately before deployment; do not paste credentials or managed +secret values into parameters, source files, or shell history. + +1. Validate both templates and run the local tests. +2. Deploy `collector-host-migration.yaml` against the existing HTTPS listener + and ingestion target group. +3. Verify `https://events.topcoder-dev.com/ping?appId=topcoder_web` and a browser + 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`, 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. +7. Exercise Lambda directly with missing, wrong, and exact role claims, then + exercise the public API with no token, an unauthorized token, and an + authorized token. A direct invocation does not replace the positive public + JWT test. +8. Add `analytics.topcoder-dev.com` to the Platform UI CloudFront distribution, + deploy the verified Platform UI build, and only then change its Route 53 + alias from the ingestion ALB to CloudFront. +9. Keep the former collector listener rules during the observation window. + Remove them only after both clients use `events.topcoder-dev.com` and ALB + traffic confirms the old host is idle. + +The collector move and reporting-host cutover are deliberately separate. If +the new collector fails, leave `analytics.topcoder-dev.com` on the ALB and roll +the client endpoint back. If the UI deployment fails after the collector move, +the `events` hostname can remain active without changing reporting DNS. + +## Validation commands + +```bash +python3 -m unittest discover -s infrastructure/analytics-api/tests -v +python3 -m py_compile infrastructure/analytics-api/src/handler.py +aws cloudformation validate-template \ + --template-body file://infrastructure/analytics-api/collector-host-migration.yaml +aws cloudformation validate-template \ + --template-body file://infrastructure/analytics-api/template.yaml +``` + +After deployment, expected public authorization behavior is: + +```text +no or malformed bearer token -> 401 from API Gateway +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 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/bootstrap.sql b/infrastructure/analytics-api/bootstrap.sql new file mode 100644 index 000000000..bb50cde2f --- /dev/null +++ b/infrastructure/analytics-api/bootstrap.sql @@ -0,0 +1,9 @@ +-- Run as the managed Redshift administrator after deploying the Lambda role. +-- CREATE ROLE is intentionally separate because Redshift does not support +-- CREATE ROLE IF NOT EXISTS. Reapplication may begin at the GRANT statements. + +CREATE ROLE analytics_api_reader; + +GRANT USAGE ON SCHEMA topcoder_web TO ROLE analytics_api_reader; +GRANT SELECT ON topcoder_web.product_analytics_events_v1 TO ROLE analytics_api_reader; +GRANT SELECT ON topcoder_web.challenge_funnel_daily_v1 TO ROLE analytics_api_reader; diff --git a/infrastructure/analytics-api/collector-host-migration.yaml b/infrastructure/analytics-api/collector-host-migration.yaml new file mode 100644 index 000000000..869815923 --- /dev/null +++ b/infrastructure/analytics-api/collector-host-migration.yaml @@ -0,0 +1,83 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Preserves AWS Clickstream ingestion while analytics.topcoder-dev.com becomes a UI host. + +Parameters: + ApplicationId: + Type: String + Default: topcoder_web + EventsDomainName: + Type: String + Default: events.topcoder-dev.com + HostedZoneId: + Type: String + Description: Public Route 53 hosted zone ID for topcoder-dev.com. + IngestionAlbDnsName: + Type: String + IngestionAlbHostedZoneId: + Type: String + IngestionHttpsListenerArn: + Type: String + IngestionTargetGroupArn: + Type: String + +Resources: + EventsDns: + Type: AWS::Route53::RecordSet + Properties: + AliasTarget: + DNSName: !Ref IngestionAlbDnsName + HostedZoneId: !Ref IngestionAlbHostedZoneId + EvaluateTargetHealth: true + HostedZoneId: !Ref HostedZoneId + Name: !Ref EventsDomainName + Type: A + + EventsCollectRule: + Type: AWS::ElasticLoadBalancingV2::ListenerRule + Properties: + Actions: + - Type: forward + TargetGroupArn: !Ref IngestionTargetGroupArn + Conditions: + - Field: host-header + HostHeaderConfig: + Values: + - !Ref EventsDomainName + - Field: path-pattern + PathPatternConfig: + Values: + - /collect + - Field: query-string + QueryStringConfig: + Values: + - Key: appId + Value: !Ref ApplicationId + ListenerArn: !Ref IngestionHttpsListenerArn + Priority: 7 + + EventsPingRule: + Type: AWS::ElasticLoadBalancingV2::ListenerRule + Properties: + Actions: + - Type: forward + TargetGroupArn: !Ref IngestionTargetGroupArn + Conditions: + - Field: host-header + HostHeaderConfig: + Values: + - !Ref EventsDomainName + - Field: path-pattern + PathPatternConfig: + Values: + - /ping + - Field: query-string + QueryStringConfig: + Values: + - Key: appId + Value: !Ref ApplicationId + ListenerArn: !Ref IngestionHttpsListenerArn + Priority: 8 + +Outputs: + CollectorEndpoint: + Value: !Sub 'https://${EventsDomainName}/collect' diff --git a/infrastructure/analytics-api/src/handler.py b/infrastructure/analytics-api/src/handler.py new file mode 100644 index 000000000..0108b4869 --- /dev/null +++ b/infrastructure/analytics-api/src/handler.py @@ -0,0 +1,1193 @@ +"""Read-only Topcoder campaign and site analytics HTTP API. + +API Gateway verifies the current Topcoder Auth0 access token before invoking +this Lambda. The handler independently requires the exact ``analytics`` role, +validates every filter, and executes fixed parameterized queries against the +Topcoder AWS Clickstream reporting views. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import time +from datetime import date, datetime, timedelta, timezone +from typing import Any + +import boto3 + + +MAX_DATE_RANGE_DAYS = 366 +MAX_RESULT_ROWS = 2_000 +MAX_QUERY_ATTEMPTS = 2 +REPORT_CACHE_SECONDS = 60 +FILTER_CACHE_SECONDS = 300 +QUERY_TOKEN_WINDOW_SECONDS = 1_800 +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]]] = {} + + +FILTERS_SQL = """ +WITH recent_events AS ( + SELECT event_date, utm_campaign, utm_campaign_id, utm_source, utm_medium, surface + FROM topcoder_web.product_analytics_events_v1 + WHERE event_date >= DATEADD(day, -365, CURRENT_DATE) +), +option_rows AS ( + SELECT 'campaign'::varchar AS row_type, utm_campaign::varchar AS value, COUNT(*)::bigint AS usage_count + FROM recent_events + WHERE utm_campaign IS NOT NULL + GROUP BY utm_campaign + UNION ALL + SELECT 'campaign_id', utm_campaign_id, COUNT(*)::bigint + FROM recent_events + WHERE utm_campaign_id IS NOT NULL + GROUP BY utm_campaign_id + UNION ALL + SELECT 'source', utm_source, COUNT(*)::bigint + FROM recent_events + WHERE utm_source IS NOT NULL + GROUP BY utm_source + UNION ALL + SELECT 'medium', utm_medium, COUNT(*)::bigint + FROM recent_events + WHERE utm_medium IS NOT NULL + GROUP BY utm_medium + UNION ALL + SELECT 'surface', surface, COUNT(*)::bigint + FROM recent_events + WHERE surface IS NOT NULL + GROUP BY surface +), +ranked_options AS ( + SELECT + row_type, + value, + usage_count, + ROW_NUMBER() OVER ( + PARTITION BY row_type + ORDER BY usage_count DESC, value + ) AS option_rank + FROM option_rows +) +SELECT 'meta' AS row_type, + CAST(MIN(event_date) AS varchar(10)) AS value, + CAST(MAX(event_date) AS varchar(10)) AS secondary_value, + COUNT(*)::bigint AS usage_count +FROM recent_events +UNION ALL +SELECT row_type, value, NULL, usage_count +FROM ranked_options +WHERE option_rank <= 200 +ORDER BY row_type, usage_count DESC, value +""" + + +CAMPAIGN_SQL = """ +WITH filtered_funnel AS ( + 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) +), +filtered_clicks AS ( + SELECT + event_date, + page_path, + placement, + element_id, + element_type, + destination_host, + destination_path, + CAST(FLOOR(click_x_percent / 10.0) * 10 AS integer) AS click_x_bucket, + CAST(FLOOR(click_y_percent / 10.0) * 10 AS integer) AS click_y_bucket, + analytics_user_id + 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) +), +summary_row AS ( + SELECT + CAST(MAX(cohort_date) AS varchar(10)) AS data_through, + COALESCE(SUM(landing_users), 0)::bigint AS landing_users, + COALESCE(SUM(landing_clickers), 0)::bigint AS landing_clickers, + COALESCE(SUM(registered_after_click), 0)::bigint AS registrations, + COALESCE(SUM(submitted_after_registration), 0)::bigint AS submissions + FROM filtered_funnel +), +daily_rows AS ( + SELECT + cohort_date, + SUM(landing_users)::bigint AS landing_users, + SUM(landing_clickers)::bigint AS landing_clickers, + SUM(registered_after_click)::bigint AS registrations, + SUM(submitted_after_registration)::bigint AS submissions + FROM filtered_funnel + GROUP BY cohort_date +), +campaign_rows AS ( + SELECT + utm_campaign, + utm_campaign_id, + utm_source, + utm_medium, + SUM(landing_users)::bigint AS landing_users, + SUM(landing_clickers)::bigint AS landing_clickers, + SUM(registered_after_click)::bigint AS registrations, + SUM(submitted_after_registration)::bigint AS submissions + FROM filtered_funnel + GROUP BY utm_campaign, utm_campaign_id, utm_source, utm_medium + ORDER BY landing_users DESC, utm_campaign + LIMIT 100 +), +landing_rows AS ( + SELECT + landing_page_path, + SUM(landing_users)::bigint AS landing_users, + SUM(landing_clickers)::bigint AS landing_clickers, + SUM(registered_after_click)::bigint AS registrations, + SUM(submitted_after_registration)::bigint AS submissions + FROM filtered_funnel + GROUP BY landing_page_path + ORDER BY landing_users DESC, landing_page_path + LIMIT 50 +), +click_rows AS ( + SELECT + page_path, + placement, + element_id, + element_type, + destination_host, + destination_path, + click_x_bucket, + click_y_bucket, + COUNT(*)::bigint AS click_count, + COUNT(DISTINCT analytics_user_id)::bigint AS click_users + FROM filtered_clicks + GROUP BY + page_path, + placement, + element_id, + element_type, + destination_host, + destination_path, + click_x_bucket, + click_y_bucket + ORDER BY click_count DESC, page_path + LIMIT 100 +) +SELECT + 'summary'::varchar AS row_type, + NULL::varchar AS date_value, + data_through::varchar AS dimension_1, + NULL::varchar AS dimension_2, + NULL::varchar AS dimension_3, + NULL::varchar AS dimension_4, + NULL::varchar AS dimension_5, + NULL::varchar AS dimension_6, + NULL::varchar AS dimension_7, + landing_users::double precision AS metric_1, + landing_clickers::double precision AS metric_2, + registrations::double precision AS metric_3, + submissions::double precision AS metric_4, + NULL::double precision AS metric_5, + NULL::double precision AS metric_6, + NULL::double precision AS metric_7, + NULL::double precision AS metric_8 +FROM summary_row +UNION ALL +SELECT + 'daily', + CAST(cohort_date AS varchar(10)), + NULL, NULL, NULL, NULL, NULL, NULL, NULL, + landing_users, landing_clickers, registrations, submissions, + NULL, NULL, NULL, NULL +FROM daily_rows +UNION ALL +SELECT + 'campaign', + NULL, + utm_campaign, + utm_campaign_id, + utm_source, + utm_medium, + NULL, NULL, NULL, + landing_users, landing_clickers, registrations, submissions, + NULL, NULL, NULL, NULL +FROM campaign_rows +UNION ALL +SELECT + 'landing_page', + NULL, + landing_page_path, + NULL, NULL, NULL, NULL, NULL, NULL, + landing_users, landing_clickers, registrations, submissions, + NULL, NULL, NULL, NULL +FROM landing_rows +UNION ALL +SELECT + 'click_location', + NULL, + page_path, + placement, + element_id, + element_type, + destination_host, + destination_path, + 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 +""" + + +GENERAL_SQL = """ +WITH filtered_events AS ( + 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) +), +summary_row AS ( + SELECT + CAST(MAX(event_date) AS varchar(10)) AS data_through, + COUNT(CASE WHEN event_name = '_page_view' THEN 1 END)::bigint AS page_views, + COUNT(DISTINCT CASE WHEN event_name = '_page_view' THEN analytics_user_id END)::bigint AS visitors, + COUNT(CASE WHEN event_name = 'ui_click' THEN 1 END)::bigint AS clicks, + COUNT(DISTINCT CASE WHEN event_name = 'ui_click' THEN analytics_user_id END)::bigint AS clickers + FROM filtered_events +), +daily_rows AS ( + SELECT + event_date, + COUNT(CASE WHEN event_name = '_page_view' THEN 1 END)::bigint AS page_views, + COUNT(DISTINCT CASE WHEN event_name = '_page_view' THEN analytics_user_id END)::bigint AS visitors, + COUNT(CASE WHEN event_name = 'ui_click' THEN 1 END)::bigint AS clicks, + COUNT(DISTINCT CASE WHEN event_name = 'ui_click' THEN analytics_user_id END)::bigint AS clickers + FROM filtered_events + GROUP BY event_date +), +page_rows AS ( + SELECT + surface, + page_path, + COUNT(*)::bigint AS page_views, + COUNT(DISTINCT analytics_user_id)::bigint AS visitors + FROM filtered_events + WHERE event_name = '_page_view' AND page_path IS NOT NULL + GROUP BY surface, page_path + ORDER BY page_views DESC, page_path + LIMIT 50 +), +source_rows AS ( + SELECT + utm_source, + COUNT(*)::bigint AS page_views, + COUNT(DISTINCT analytics_user_id)::bigint AS visitors + FROM filtered_events + WHERE event_name = '_page_view' + GROUP BY utm_source + ORDER BY page_views DESC, utm_source + LIMIT 25 +), +surface_rows AS ( + SELECT + surface, + COUNT(CASE WHEN event_name = '_page_view' THEN 1 END)::bigint AS page_views, + COUNT(DISTINCT CASE + WHEN event_name = '_page_view' THEN analytics_user_id + END)::bigint AS visitors, + COUNT(CASE WHEN event_name = 'ui_click' THEN 1 END)::bigint AS clicks + FROM filtered_events + WHERE surface IS NOT NULL + GROUP BY surface + ORDER BY page_views DESC, surface + LIMIT 25 +) +SELECT + 'summary'::varchar AS row_type, + NULL::varchar AS date_value, + data_through::varchar AS dimension_1, + NULL::varchar AS dimension_2, + NULL::varchar AS dimension_3, + NULL::varchar AS dimension_4, + page_views::double precision AS metric_1, + visitors::double precision AS metric_2, + clicks::double precision AS metric_3, + clickers::double precision AS metric_4 +FROM summary_row +UNION ALL +SELECT + 'daily', + CAST(event_date AS varchar(10)), + NULL, NULL, NULL, NULL, + page_views, visitors, clicks, clickers +FROM daily_rows +UNION ALL +SELECT + 'page', + NULL, + surface, + page_path, + NULL, NULL, + page_views, visitors, NULL, NULL +FROM page_rows +UNION ALL +SELECT + 'source', + NULL, + utm_source, + NULL, NULL, NULL, + page_views, visitors, NULL, NULL +FROM source_rows +UNION ALL +SELECT + 'surface', + NULL, + surface, + NULL, NULL, NULL, + page_views, visitors, clicks, NULL +FROM surface_rows +ORDER BY row_type, date_value, metric_1 DESC +""" + + +class QueryFailure(RuntimeError): + """Raised when Redshift rejects or aborts a reporting query.""" + + +class QueryTimeout(RuntimeError): + """Raised when a reusable reporting query cannot finish inside the HTTP deadline.""" + + +def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: + """Authorize and serve one analytics endpoint. + + Args: + event: API Gateway HTTP API v2 proxy event. + context: Lambda invocation context used to bound query wait time. + + Returns: + An API Gateway proxy response containing a JSON analytics document. + + Raises: + No exceptions escape; failures are converted to sanitized HTTP errors. + """ + + 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}) + + query = event.get("queryStringParameters") or {} + + if route_key == "GET /v1/analytics/filters": + return _response(200, _cached_report("filters", FILTER_CACHE_SECONDS, _filters_report, context)) + if route_key == "GET /v1/analytics/campaign": + filters = _campaign_filters(query) + cache_key = f"campaign:{json.dumps(filters, sort_keys=True)}" + report = _cached_report( + cache_key, + REPORT_CACHE_SECONDS, + lambda: _campaign_report(filters, context), + context, + ) + return _response(200, report) + if route_key == "GET /v1/analytics/general": + filters = _general_filters(query) + cache_key = f"general:{json.dumps(filters, sort_keys=True)}" + report = _cached_report( + cache_key, + REPORT_CACHE_SECONDS, + lambda: _general_report(filters, context), + context, + ) + return _response(200, report) + + return _response(404, {"message": "Analytics route not found", "requestId": request_id}) + except ValueError as error: + return _response(400, {"message": str(error), "requestId": request_id}) + except QueryTimeout: + return _response(504, {"message": "Analytics data is still being prepared. Please retry.", "requestId": request_id}) + except QueryFailure: + _log_error(request_id, "redshift-query-failed") + return _response(502, {"message": "Analytics data could not be loaded", "requestId": request_id}) + except Exception: + _log_error(request_id, "unhandled-analytics-error") + return _response(500, {"message": "Analytics data could not be loaded", "requestId": request_id}) + + +def _cached_report( + key: str, + lifetime_seconds: int, + loader: Any, + context: Any, +) -> dict[str, Any]: + """Return a short-lived cached report or invoke its loader. + + Args: + key: Stable cache key containing only validated filters. + lifetime_seconds: Maximum age of a cached response. + loader: Zero-argument callable that loads the response. + context: Lambda context retained for a uniform loader signature. + + Returns: + Cached or newly loaded analytics report. + + Raises: + Propagates loader failures so the handler can sanitize them. + """ + + del context + now = time.monotonic() + cached = _cache.get(key) + if cached and cached[0] > now: + return cached[1] + result = loader() + if len(_cache) >= 50: + _cache.clear() + _cache[key] = (now + lifetime_seconds, result) + return result + + +def _filters_report() -> dict[str, Any]: + """Load bounded campaign and surface filter options. + + Returns: + Filter option arrays and available event-date bounds. + + Raises: + QueryFailure or QueryTimeout when Redshift cannot return data. + """ + + rows = _execute_query(FILTERS_SQL, [], None) + options: dict[str, list[str]] = { + "campaigns": [], + "campaignIds": [], + "sources": [], + "mediums": [], + "surfaces": [], + } + min_date = None + max_date = None + key_by_row_type = { + "campaign": "campaigns", + "campaign_id": "campaignIds", + "source": "sources", + "medium": "mediums", + "surface": "surfaces", + } + for row in rows: + row_type = row.get("row_type") + if row_type == "meta": + min_date = row.get("value") + max_date = row.get("secondary_value") + continue + option_key = key_by_row_type.get(str(row_type)) + value = row.get("value") + if option_key and isinstance(value, str) and value: + options[option_key].append(value) + + return { + **options, + "generatedAt": _now_iso(), + "minDate": min_date, + "maxDate": max_date, + "dataThrough": max_date, + } + + +def _campaign_report(filters: dict[str, str], context: Any) -> dict[str, Any]: + """Load and shape the ordered campaign funnel report. + + Args: + filters: Validated date and UTM filter values. + context: Lambda context used to respect the remaining deadline. + + Returns: + Funnel totals, daily series, campaign/landing breakdowns, and click locations. + + Raises: + QueryFailure or QueryTimeout when Redshift cannot return data. + """ + + rows = _execute_query(CAMPAIGN_SQL, _sql_parameters(filters), context) + summary = next((row for row in rows if row.get("row_type") == "summary"), {}) + totals = { + "landingUsers": _integer(summary.get("metric_1")), + "landingClickers": _integer(summary.get("metric_2")), + "registrations": _integer(summary.get("metric_3")), + "submissions": _integer(summary.get("metric_4")), + } + totals.update({ + "clickThroughPercent": _percentage(totals["landingClickers"], totals["landingUsers"]), + "clickToRegistrationPercent": _percentage(totals["registrations"], totals["landingClickers"]), + "registrationToSubmissionPercent": _percentage(totals["submissions"], totals["registrations"]), + "landingToSubmissionPercent": _percentage(totals["submissions"], totals["landingUsers"]), + }) + + return { + "generatedAt": _now_iso(), + "dataThrough": summary.get("dimension_1"), + "filters": filters, + "totals": totals, + "series": [ + { + "date": row.get("date_value"), + "landingUsers": _integer(row.get("metric_1")), + "landingClickers": _integer(row.get("metric_2")), + "registrations": _integer(row.get("metric_3")), + "submissions": _integer(row.get("metric_4")), + } + for row in rows if row.get("row_type") == "daily" + ], + "campaigns": [ + { + "campaign": row.get("dimension_1") or "Direct", + "campaignId": row.get("dimension_2"), + "source": row.get("dimension_3") or "Direct", + "medium": row.get("dimension_4") or "None", + "landingUsers": _integer(row.get("metric_1")), + "landingClickers": _integer(row.get("metric_2")), + "registrations": _integer(row.get("metric_3")), + "submissions": _integer(row.get("metric_4")), + } + for row in rows if row.get("row_type") == "campaign" + ], + "landingPages": [ + { + "path": row.get("dimension_1") or "Unknown", + "landingUsers": _integer(row.get("metric_1")), + "landingClickers": _integer(row.get("metric_2")), + "registrations": _integer(row.get("metric_3")), + "submissions": _integer(row.get("metric_4")), + } + for row in rows if row.get("row_type") == "landing_page" + ], + "clickLocations": [ + _click_location(row) + for row in rows if row.get("row_type") == "click_location" + ], + } + + +def _general_report(filters: dict[str, str], context: Any) -> dict[str, Any]: + """Load and shape general Topcoder site engagement analytics. + + Args: + filters: Validated date and optional surface filter values. + context: Lambda context used to respect the remaining deadline. + + Returns: + General totals, daily series, pages, traffic sources, and surfaces. + + Raises: + QueryFailure or QueryTimeout when Redshift cannot return data. + """ + + rows = _execute_query(GENERAL_SQL, _sql_parameters(filters), context) + summary = next((row for row in rows if row.get("row_type") == "summary"), {}) + return { + "generatedAt": _now_iso(), + "dataThrough": summary.get("dimension_1"), + "filters": filters, + "totals": { + "pageViews": _integer(summary.get("metric_1")), + "visitors": _integer(summary.get("metric_2")), + "clicks": _integer(summary.get("metric_3")), + "clickers": _integer(summary.get("metric_4")), + }, + "series": [ + { + "date": row.get("date_value"), + "pageViews": _integer(row.get("metric_1")), + "visitors": _integer(row.get("metric_2")), + "clicks": _integer(row.get("metric_3")), + "clickers": _integer(row.get("metric_4")), + } + for row in rows if row.get("row_type") == "daily" + ], + "pages": [ + { + "surface": row.get("dimension_1") or "Unknown", + "path": row.get("dimension_2") or "Unknown", + "pageViews": _integer(row.get("metric_1")), + "visitors": _integer(row.get("metric_2")), + } + for row in rows if row.get("row_type") == "page" + ], + "sources": [ + { + "source": row.get("dimension_1") or "Direct", + "pageViews": _integer(row.get("metric_1")), + "visitors": _integer(row.get("metric_2")), + } + for row in rows if row.get("row_type") == "source" + ], + "surfaces": [ + { + "surface": row.get("dimension_1") or "Unknown", + "pageViews": _integer(row.get("metric_1")), + "visitors": _integer(row.get("metric_2")), + "clicks": _integer(row.get("metric_3")), + } + for row in rows if row.get("row_type") == "surface" + ], + } + + +def _click_location(row: dict[str, Any]) -> dict[str, Any]: + """Convert one normalized Redshift click-location row to the wire contract. + + Args: + row: Query result row with safe, aggregate click dimensions. + + Returns: + Camel-cased click-location object with separate coarse coordinates. + + Raises: + Does not raise; malformed buckets become null coordinates. + """ + + bucket = str(row.get("dimension_7") or ":").split(":", 1) + return { + "pagePath": row.get("dimension_1") or "Unknown", + "placement": row.get("dimension_2"), + "elementId": row.get("dimension_3"), + "elementType": row.get("dimension_4"), + "destinationHost": row.get("dimension_5"), + "destinationPath": row.get("dimension_6"), + "xBucket": _optional_integer(bucket[0]), + "yBucket": _optional_integer(bucket[1] if len(bucket) > 1 else ""), + "clicks": _integer(row.get("metric_1")), + "clickers": _integer(row.get("metric_2")), + } + + +def _campaign_filters(query: dict[str, Any]) -> dict[str, str]: + """Validate campaign report dates and UTM dimensions. + + Args: + query: Untrusted API Gateway query string values. + + Returns: + Complete normalized filter dictionary. + + Raises: + ValueError for malformed dates, excessive ranges, or unsafe dimensions. + """ + + date_filters = _date_filters(query) + return { + **date_filters, + "campaign": _safe_filter(query.get("campaign"), "campaign"), + "campaignId": _safe_filter(query.get("campaignId"), "campaign ID"), + "source": _safe_filter(query.get("source"), "source"), + "medium": _safe_filter(query.get("medium"), "medium"), + } + + +def _general_filters(query: dict[str, Any]) -> dict[str, str]: + """Validate general report dates and surface. + + Args: + query: Untrusted API Gateway query string values. + + Returns: + Complete normalized filter dictionary. + + Raises: + ValueError for malformed dates, excessive ranges, or unsafe surface. + """ + + return { + **_date_filters(query), + "surface": _safe_filter(query.get("surface"), "surface"), + } + + +def _date_filters(query: dict[str, Any]) -> dict[str, str]: + """Parse an inclusive UTC date range with a safe 30-day default. + + Args: + query: Untrusted query values containing optional ``from`` and ``to``. + + Returns: + ISO date strings under ``from`` and ``to``. + + Raises: + ValueError when dates are invalid, reversed, future, or over 366 days. + """ + + today = datetime.now(timezone.utc).date() + to_date = _parse_date(query.get("to"), "to") if query.get("to") else today + from_date = _parse_date(query.get("from"), "from") if query.get("from") else to_date - timedelta(days=29) + if from_date > to_date: + raise ValueError("The from date must not be after the to date") + if to_date > today: + raise ValueError("The to date must not be in the future") + if (to_date - from_date).days + 1 > MAX_DATE_RANGE_DAYS: + raise ValueError(f"Analytics date ranges cannot exceed {MAX_DATE_RANGE_DAYS} days") + return {"from": from_date.isoformat(), "to": to_date.isoformat()} + + +def _parse_date(value: Any, label: str) -> date: + """Parse one strict ISO calendar date. + + Args: + value: Untrusted candidate date. + label: Field label used in the validation message. + + Returns: + Parsed date. + + Raises: + ValueError when the candidate is not exactly YYYY-MM-DD. + """ + + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + raise ValueError(f"The {label} date must use YYYY-MM-DD") + try: + return date.fromisoformat(value) + except ValueError as error: + raise ValueError(f"The {label} date must be valid") from error + + +def _safe_filter(value: Any, label: str) -> str: + """Validate one optional UTM or surface token. + + Args: + value: Untrusted query value. + label: Human-readable field label. + + Returns: + Empty string for no filter or the unchanged safe token. + + Raises: + ValueError when the value is not a bounded marketing token. + """ + + if value in (None, ""): + return "" + if not isinstance(value, str) or not SAFE_FILTER_PATTERN.fullmatch(value): + raise ValueError(f"The {label} filter contains unsupported characters") + return value + + +def _sql_parameters(filters: dict[str, str]) -> list[dict[str, str]]: + """Map wire filters to Redshift Data API named parameters. + + Args: + filters: Validated campaign or general filter dictionary. + + Returns: + 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. + """ + + names = { + "from": "from_date", + "to": "to_date", + "campaign": "campaign", + "campaignId": "campaign_id", + "source": "source", + "medium": "medium", + "surface": "surface", + } + return [ + {"name": names[key], "value": value or NO_FILTER_PARAMETER} + for key, value in filters.items() + if key in names + ] + + +def _execute_query( + sql: str, + parameters: list[dict[str, str]], + context: Any, +) -> list[dict[str, Any]]: + """Execute a fixed parameterized query with resumable timeout handling. + + Args: + sql: Server-owned SQL template. + parameters: Validated Data API named parameters. + context: Lambda context or null for the cached filter loader. + + Returns: + Query rows keyed by Redshift column name. + + Raises: + QueryFailure after repeated provider failure or excess output and + QueryTimeout when the shared deadline expires. A timed-out statement + remains active so a client retry can resume polling it by idempotency token. + """ + + request: dict[str, Any] = { + "Database": os.environ["REDSHIFT_DATABASE"], + "Sql": sql, + "StatementName": "topcoder-analytics-read", + "WithEvent": False, + "WorkgroupName": os.environ["REDSHIFT_WORKGROUP"], + } + if parameters: + request["Parameters"] = parameters + deadline = time.monotonic() + _query_wait_seconds(context) + for attempt in range(MAX_QUERY_ATTEMPTS): + request["ClientToken"] = _query_client_token(sql, parameters, attempt) + 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: + raise QueryTimeout("Redshift reporting query timed out") + + raise QueryFailure("Redshift reporting query failed") + + +def _query_client_token( + sql: str, + parameters: list[dict[str, str]], + attempt: int, +) -> str: + """Build a bounded idempotency key for one fixed reporting query. + + Args: + sql: Server-owned SQL template. + parameters: Validated named parameters. + attempt: Provider retry index; failed statements receive a new token. + + Returns: + Sixty-four-character SHA-256 token stable within a thirty-minute window. + + Raises: + Does not raise for validated handler inputs and configured environment values. + """ + + token_window = int(time.time() // QUERY_TOKEN_WINDOW_SECONDS) + fingerprint = json.dumps({ + "attempt": attempt, + "database": os.environ["REDSHIFT_DATABASE"], + "parameters": parameters, + "sql": sql, + "tokenWindow": token_window, + "workgroup": os.environ["REDSHIFT_WORKGROUP"], + }, separators=(",", ":"), sort_keys=True) + return hashlib.sha256(fingerprint.encode("utf-8")).hexdigest() + + +def _query_wait_seconds(context: Any) -> float: + """Calculate a provider wait that leaves time for a sanitized HTTP response. + + Args: + context: Lambda invocation context, or null in direct unit calls. + + Returns: + Wait duration between one and 24 seconds. + + Raises: + Does not raise. + """ + + if context and hasattr(context, "get_remaining_time_in_millis"): + return max(1.0, min(24.0, (context.get_remaining_time_in_millis() / 1_000) - 2.0)) + return 24.0 + + +def _statement_rows(statement_id: str) -> list[dict[str, Any]]: + """Page through one Data API result without exceeding the response contract. + + Args: + statement_id: Completed Data API statement identifier. + + Returns: + Decoded query rows. + + Raises: + QueryFailure when the query returns more than the allowed row bound. + """ + + rows: list[dict[str, Any]] = [] + next_token = None + while True: + request = {"Id": statement_id} + if next_token: + request["NextToken"] = next_token + page = _redshift_data.get_statement_result(**request) + columns = [column["name"] for column in page.get("ColumnMetadata", [])] + rows.extend({name: _field_value(field) for name, field in zip(columns, record)} + for record in page.get("Records", [])) + if len(rows) > MAX_RESULT_ROWS: + raise QueryFailure("Analytics query exceeded the result row bound") + next_token = page.get("NextToken") + if not next_token: + return rows + + +def _field_value(field: dict[str, Any]) -> Any: + """Decode one Redshift Data API union field. + + Args: + field: Data API field object. + + Returns: + Native scalar value or null. + + Raises: + Does not raise for supported Data API field shapes. + """ + + if field.get("isNull"): + return None + for key in ("stringValue", "longValue", "doubleValue", "booleanValue", "blobValue"): + if key in field: + return field[key] + return None + + +def _has_required_role(event: dict[str, Any]) -> bool: + """Require the exact role from supported API Gateway-verified Topcoder claims. + + Args: + event: API Gateway event containing JWT authorizer claims. + + Returns: + True only when the normalized role set contains the required role. + + Raises: + Does not raise; malformed claims deny access. + """ + + claims = (((event.get("requestContext") or {}).get("authorizer") or {}).get("jwt") or {}).get("claims") or {} + 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): + 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): + return [role for role in parsed if isinstance(role, str)] + if isinstance(parsed, str): + return [parsed] + except json.JSONDecodeError: + 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]: + """Build a private JSON API Gateway proxy response. + + Args: + status_code: HTTP response status. + body: JSON-serializable response document. + + Returns: + HTTP API v2 Lambda proxy response. + + Raises: + Does not raise for the service-owned response shapes. + """ + + return { + "statusCode": status_code, + "headers": { + "Cache-Control": "private, no-store", + "Content-Type": "application/json; charset=utf-8", + "Referrer-Policy": "no-referrer", + "Vary": "Authorization, Origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + "body": json.dumps(body, separators=(",", ":")), + } + + +def _request_id(event: dict[str, Any], context: Any) -> str: + """Resolve a provider-generated request identifier. + + Args: + event: API Gateway event. + context: Lambda context fallback. + + Returns: + Request identifier suitable for support correlation. + + Raises: + Does not raise. + """ + + gateway_id = (event.get("requestContext") or {}).get("requestId") + lambda_id = getattr(context, "aws_request_id", None) + return str(gateway_id or lambda_id or "unknown")[:128] + + +def _log_error(request_id: str, error_type: str) -> None: + """Write a bounded diagnostic without tokens, filters, or SQL. + + Args: + request_id: Provider-generated correlation identifier. + error_type: Service-owned error category. + + Returns: + Nothing after writing one structured log line. + + Raises: + Does not raise. + """ + + print(json.dumps({"level": "error", "requestId": request_id, "type": error_type})) + + +def _integer(value: Any) -> int: + """Convert a numeric aggregate to a non-negative integer. + + Args: + value: Data API numeric field. + + Returns: + Non-negative integer, defaulting to zero. + + Raises: + Does not raise for malformed provider values. + """ + + try: + return max(0, int(float(value or 0))) + except (TypeError, ValueError): + return 0 + + +def _optional_integer(value: Any) -> int | None: + """Convert an optional coarse coordinate to an integer. + + Args: + value: Data API string or number. + + Returns: + Integer coordinate or null when absent/malformed. + + Raises: + Does not raise. + """ + + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _percentage(numerator: int, denominator: int) -> float: + """Calculate a bounded conversion percentage. + + Args: + numerator: Successful later-stage count. + denominator: Eligible earlier-stage count. + + Returns: + Percentage rounded to two decimals, or zero for an empty denominator. + + Raises: + Does not raise. + """ + + if denominator <= 0: + return 0.0 + return round((numerator / denominator) * 100, 2) + + +def _now_iso() -> str: + """Return the current UTC timestamp for response freshness metadata. + + Returns: + ISO-8601 timestamp ending in ``Z``. + + Raises: + Does not raise. + """ + + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/infrastructure/analytics-api/template.yaml b/infrastructure/analytics-api/template.yaml new file mode 100644 index 000000000..de4c1e528 --- /dev/null +++ b/infrastructure/analytics-api/template.yaml @@ -0,0 +1,366 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Role-gated read-only Topcoder product analytics API. + +Parameters: + CodeBucket: + Type: String + Description: S3 bucket containing the versioned Lambda zip. + CodeKey: + Type: String + Description: S3 key containing the versioned Lambda zip. + CertificateArn: + Type: String + Description: us-east-1 ACM wildcard certificate for the custom API domain. + DatabaseName: + Type: String + Default: topcoder_web_dev + DomainName: + Type: String + Default: analytics-api.topcoder-dev.com + HostedZoneId: + Type: String + Description: Public Route 53 hosted zone ID for topcoder-dev.com. + HumanJwtAudience: + Type: String + Default: BXWXUWnilVUPdN01t2Se29Tw2ZYNGZvH + HumanJwtIssuer: + Type: String + Default: https://auth.topcoder-dev.com/ + 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. + WorkgroupName: + Type: String + Default: clickstream-topcoder-web-dev + +Resources: + AnalyticsFunctionLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + LogGroupName: /aws/lambda/topcoder-analytics-api-dev + RetentionInDays: 30 + Tags: + - Key: Application + Value: topcoder-web-analytics + - Key: CostCenter + Value: product-analytics + - Key: Environment + Value: dev + + AnalyticsFunctionRole: + Type: AWS::IAM::Role + Properties: + RoleName: topcoder-analytics-api-dev-query-role + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: analytics-read-only-query + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: WriteOwnLogs + Effect: Allow + Action: + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub '${AnalyticsFunctionLogGroup.Arn}:*' + - Sid: ExecuteAgainstAnalyticsWorkgroup + Effect: Allow + Action: redshift-data:ExecuteStatement + Resource: !Ref WorkgroupArn + - Sid: ReadOwnStatement + Effect: Allow + Action: + - redshift-data:CancelStatement + - redshift-data:DescribeStatement + - redshift-data:GetStatementResult + Resource: '*' + Condition: + StringEquals: + redshift-data:statement-owner-iam-userid: '${aws:userid}' + - Sid: GetExactWorkgroupCredentials + Effect: Allow + Action: redshift-serverless:GetCredentials + Resource: !Ref WorkgroupArn + - Sid: ResolveDatabaseRoleTag + Effect: Allow + Action: + - tag:GetResources + - tag:GetTagKeys + Resource: '*' + Tags: + - Key: Application + Value: topcoder-web-analytics + - Key: CostCenter + Value: product-analytics + - Key: Environment + Value: dev + - Key: RedshiftDbRoles + Value: analytics_api_reader + + AnalyticsFunction: + Type: AWS::Lambda::Function + DependsOn: AnalyticsFunctionLogGroup + Properties: + FunctionName: topcoder-analytics-api-dev + Architectures: + - arm64 + Code: + S3Bucket: !Ref CodeBucket + S3Key: !Ref CodeKey + Description: Read-only role-gated Topcoder campaign and site analytics. + Environment: + Variables: + HUMAN_ROLE_CLAIM: !Ref HumanRoleClaim + REDSHIFT_DATABASE: !Ref DatabaseName + REDSHIFT_WORKGROUP: !Ref WorkgroupName + REQUIRED_ROLE: analytics + Handler: handler.handler + MemorySize: 256 + ReservedConcurrentExecutions: 5 + Role: !GetAtt AnalyticsFunctionRole.Arn + Runtime: python3.12 + Timeout: 28 + Tags: + - Key: Application + Value: topcoder-web-analytics + - Key: CostCenter + Value: product-analytics + - Key: Environment + Value: dev + + AnalyticsApiAccessLogGroup: + Type: AWS::Logs::LogGroup + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + LogGroupName: /aws/apigateway/topcoder-analytics-api-dev + RetentionInDays: 30 + Tags: + - Key: Application + Value: topcoder-web-analytics + - Key: CostCenter + Value: product-analytics + - Key: Environment + Value: dev + + AnalyticsHttpApi: + Type: AWS::ApiGatewayV2::Api + Properties: + Name: topcoder-analytics-api-dev + ProtocolType: HTTP + CorsConfiguration: + AllowHeaders: + - authorization + - content-type + AllowMethods: + - GET + AllowOrigins: + - https://analytics.topcoder-dev.com + - https://platform-ui.topcoder-dev.com + - https://local.topcoder-dev.com + MaxAge: 300 + Tags: + Application: topcoder-web-analytics + CostCenter: product-analytics + Environment: dev + + AnalyticsJwtAuthorizer: + Type: AWS::ApiGatewayV2::Authorizer + Properties: + ApiId: !Ref AnalyticsHttpApi + AuthorizerType: JWT + IdentitySource: + - $request.header.Authorization + JwtConfiguration: + Audience: + - !Ref HumanJwtAudience + Issuer: !Ref HumanJwtIssuer + Name: topcoder-human-jwt + + AnalyticsIntegration: + Type: AWS::ApiGatewayV2::Integration + Properties: + ApiId: !Ref AnalyticsHttpApi + IntegrationType: AWS_PROXY + IntegrationUri: !GetAtt AnalyticsFunction.Arn + PayloadFormatVersion: '2.0' + TimeoutInMillis: 29000 + + FiltersRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref AnalyticsHttpApi + AuthorizationType: JWT + AuthorizerId: !Ref AnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/filters + Target: !Sub 'integrations/${AnalyticsIntegration}' + + CampaignRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref AnalyticsHttpApi + AuthorizationType: JWT + AuthorizerId: !Ref AnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/campaign + Target: !Sub 'integrations/${AnalyticsIntegration}' + + GeneralRoute: + Type: AWS::ApiGatewayV2::Route + Properties: + ApiId: !Ref AnalyticsHttpApi + AuthorizationType: JWT + AuthorizerId: !Ref AnalyticsJwtAuthorizer + RouteKey: GET /v1/analytics/general + Target: !Sub 'integrations/${AnalyticsIntegration}' + + DefaultStage: + Type: AWS::ApiGatewayV2::Stage + DependsOn: AnalyticsApiAccessLogGroup + Properties: + ApiId: !Ref AnalyticsHttpApi + StageName: $default + AutoDeploy: true + AccessLogSettings: + 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: + DetailedMetricsEnabled: true + ThrottlingBurstLimit: 10 + ThrottlingRateLimit: 5 + Tags: + Application: topcoder-web-analytics + CostCenter: product-analytics + Environment: dev + + AnalyticsInvokePermission: + 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}:${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: + DomainName: !Ref DomainName + DomainNameConfigurations: + - CertificateArn: !Ref CertificateArn + EndpointType: REGIONAL + SecurityPolicy: TLS_1_2 + Tags: + Application: topcoder-web-analytics + CostCenter: product-analytics + Environment: dev + + AnalyticsApiMapping: + Type: AWS::ApiGatewayV2::ApiMapping + DependsOn: DefaultStage + Properties: + ApiId: !Ref AnalyticsHttpApi + DomainName: !Ref AnalyticsApiDomain + Stage: !Ref DefaultStage + + AnalyticsApiDns: + Type: AWS::Route53::RecordSet + Properties: + AliasTarget: + DNSName: !GetAtt AnalyticsApiDomain.RegionalDomainName + HostedZoneId: !GetAtt AnalyticsApiDomain.RegionalHostedZoneId + EvaluateTargetHealth: false + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + +Outputs: + ApiEndpoint: + Value: !Sub 'https://${SharedApiDomainName}/v1/analytics' + FunctionArn: + Value: !GetAtt AnalyticsFunction.Arn + FunctionRoleName: + Value: !Ref AnalyticsFunctionRole diff --git a/infrastructure/analytics-api/tests/test_handler.py b/infrastructure/analytics-api/tests/test_handler.py new file mode 100644 index 000000000..ff74d46a1 --- /dev/null +++ b/infrastructure/analytics-api/tests/test_handler.py @@ -0,0 +1,444 @@ +"""Unit tests for the role-gated analytics Lambda contract.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +HANDLER_PATH = Path(__file__).parents[1] / "src" / "handler.py" + + +class _FakeBoto3(types.ModuleType): + """Minimal boto3 module used while importing the dependency-free handler.""" + + def __init__(self) -> None: + super().__init__("boto3") + self.redshift = Mock() + + def client(self, service: str) -> Mock: + """Return the fake Redshift Data API client. + + Args: + service: Requested AWS service name. + + Returns: + Shared mock client. + + Raises: + AssertionError when the handler requests an unexpected service. + """ + + if service != "redshift-data": + raise AssertionError(f"Unexpected AWS client: {service}") + return self.redshift + + +def _load_handler() -> types.ModuleType: + """Import the Lambda handler with a fake boto3 module. + + Returns: + Fresh handler module. + + Raises: + RuntimeError when the module cannot be loaded from disk. + """ + + fake_boto3 = _FakeBoto3() + sys.modules["boto3"] = fake_boto3 + specification = importlib.util.spec_from_file_location("analytics_handler", HANDLER_PATH) + if specification is None or specification.loader is None: + raise RuntimeError("Unable to load analytics handler") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +class AnalyticsHandlerTests(unittest.TestCase): + """Covers authorization, filter validation, and wire-shape transformations.""" + + @classmethod + def setUpClass(cls) -> None: + """Load the handler once with deterministic runtime configuration.""" + + os.environ.update({ + "HUMAN_ROLE_CLAIM": "https://topcoder-dev.com/roles", + "REDSHIFT_DATABASE": "topcoder_web_dev", + "REDSHIFT_WORKGROUP": "clickstream-topcoder-web-dev", + "REQUIRED_ROLE": "analytics", + }) + cls.module = _load_handler() + + def setUp(self) -> None: + """Clear the warm Lambda cache before each test.""" + + self.module._cache.clear() + + def _event( + self, + 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. + + Args: + 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. + + Raises: + Does not raise. + """ + + claims = {} + if roles is not None: + claims[role_claim] = roles + return { + "queryStringParameters": query, + "requestContext": { + "authorizer": {"jwt": {"claims": claims}}, + "requestId": "request-123", + }, + "routeKey": route_key, + } + + def test_denies_missing_or_wrong_role_without_querying(self) -> None: + """A verified token still needs the exact analytics role.""" + + with patch.object(self.module, "_execute_query") as execute: + missing = self.module.handler(self._event("GET /v1/analytics/filters"), None) + wrong = self.module.handler( + self._event("GET /v1/analytics/filters", json.dumps(["administrator"])), + None, + ) + wrong_case = self.module.handler( + self._event("GET /v1/analytics/filters", json.dumps(["Analytics"])), + None, + ) + + self.assertEqual(403, missing["statusCode"]) + self.assertEqual(403, wrong["statusCode"]) + self.assertEqual(403, wrong_case["statusCode"]) + execute.assert_not_called() + + def test_accepts_json_array_role_claim_and_returns_private_filters(self) -> None: + """The API Gateway string form of an array role claim is supported.""" + + rows = [ + {"row_type": "meta", "value": "2026-08-01", "secondary_value": "2026-08-30"}, + {"row_type": "campaign", "value": "launch", "usage_count": 4}, + {"row_type": "source", "value": "newsletter", "usage_count": 4}, + ] + with patch.object(self.module, "_execute_query", return_value=rows): + response = self.module.handler( + self._event("GET /v1/analytics/filters", json.dumps(["analytics"])), + None, + ) + + body = json.loads(response["body"]) + self.assertEqual(200, response["statusCode"]) + self.assertEqual("private, no-store", response["headers"]["Cache-Control"]) + 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.""" + + with patch.object(self.module, "_execute_query") as execute: + invalid = self.module.handler( + self._event( + "GET /v1/analytics/general", + ["analytics"], + {"from": "2026-99-01", "to": "2026-08-30"}, + ), + None, + ) + excessive = self.module.handler( + self._event( + "GET /v1/analytics/general", + ["analytics"], + {"from": "2025-01-01", "to": "2026-01-02"}, + ), + None, + ) + + self.assertEqual(400, invalid["statusCode"]) + self.assertEqual(400, excessive["statusCode"]) + execute.assert_not_called() + + def test_rejects_unsafe_utm_values_before_querying(self) -> None: + """UTM values cannot alter fixed SQL templates.""" + + with patch.object(self.module, "_execute_query") as execute: + response = self.module.handler( + self._event( + "GET /v1/analytics/campaign", + ["analytics"], + {"campaign": "launch' OR 1=1 --"}, + ), + 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_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_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) + self.assertNotEqual( + execute.call_args_list[0].kwargs["ClientToken"], + execute.call_args_list[1].kwargs["ClientToken"], + ) + + 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_timeout_keeps_an_idempotent_statement_for_the_next_request(self) -> None: + """A client retry reuses rather than cancels a query that outlives one HTTP request.""" + + context = Mock() + context.get_remaining_time_in_millis.return_value = 28_000 + with ( + patch.object(self.module.time, "time", return_value=1_000), + patch.object(self.module, "_query_wait_seconds", return_value=0), + patch.object( + self.module._redshift_data, + "execute_statement", + return_value={"Id": "still-running"}, + ) as execute, + patch.object(self.module._redshift_data, "cancel_statement") as cancel, + ): + for _ in range(2): + with self.assertRaises(self.module.QueryTimeout): + self.module._execute_query("SELECT 1", [], context) + + self.assertEqual(2, execute.call_count) + self.assertEqual( + execute.call_args_list[0].kwargs["ClientToken"], + execute.call_args_list[1].kwargs["ClientToken"], + ) + cancel.assert_not_called() + + def test_query_client_token_is_scoped_by_query_attempt_and_time_window(self) -> None: + """Idempotency tokens reuse only the same query attempt inside one bounded window.""" + + parameters = [{"name": "from_date", "value": "2026-08-01"}] + with patch.object(self.module.time, "time", return_value=1_000): + original = self.module._query_client_token("SELECT 1", parameters, 0) + same_query = self.module._query_client_token("SELECT 1", parameters, 0) + new_attempt = self.module._query_client_token("SELECT 1", parameters, 1) + with patch.object( + self.module.time, + "time", + return_value=1_000 + self.module.QUERY_TOKEN_WINDOW_SECONDS, + ): + next_window = self.module._query_client_token("SELECT 1", parameters, 0) + + self.assertRegex(original, r"^[0-9a-f]{64}$") + self.assertEqual(original, same_query) + self.assertNotEqual(original, new_attempt) + self.assertNotEqual(original, next_window) + + def test_shapes_campaign_funnel_and_click_location(self) -> None: + """Campaign rows become totals, conversion rates, series, and safe click dimensions.""" + + rows = [ + { + "row_type": "summary", + "dimension_1": "2026-08-29", + "metric_1": 100, + "metric_2": 40, + "metric_3": 10, + "metric_4": 5, + }, + { + "row_type": "daily", + "date_value": "2026-08-29", + "metric_1": 100, + "metric_2": 40, + "metric_3": 10, + "metric_4": 5, + }, + { + "row_type": "click_location", + "dimension_1": "/challenges", + "dimension_2": "main", + "dimension_3": "register", + "dimension_4": "button", + "dimension_5": "www.topcoder-dev.com", + "dimension_6": "/challenges/123", + "dimension_7": "40:60", + "metric_1": 8, + "metric_2": 6, + }, + ] + with patch.object(self.module, "_execute_query", return_value=rows): + response = self.module.handler( + self._event( + "GET /v1/analytics/campaign", + ["analytics"], + {"from": "2026-08-01", "to": "2026-08-30"}, + ), + None, + ) + + body = json.loads(response["body"]) + self.assertEqual(200, response["statusCode"]) + self.assertEqual(40.0, body["totals"]["clickThroughPercent"]) + self.assertEqual(50.0, body["totals"]["registrationToSubmissionPercent"]) + self.assertEqual(40, body["clickLocations"][0]["xBucket"]) + self.assertEqual(60, body["clickLocations"][0]["yBucket"]) + + def test_shapes_general_report(self) -> None: + """General rows retain page, source, and surface dimensions.""" + + rows = [ + { + "row_type": "summary", + "dimension_1": "2026-08-30", + "metric_1": 30, + "metric_2": 20, + "metric_3": 10, + "metric_4": 8, + }, + { + "row_type": "page", + "dimension_1": "topcoder_website", + "dimension_2": "/challenges", + "metric_1": 12, + "metric_2": 9, + }, + ] + with patch.object(self.module, "_execute_query", return_value=rows): + response = self.module.handler( + self._event( + "GET /v1/analytics/general", + ["analytics"], + {"from": "2026-08-01", "to": "2026-08-30"}, + ), + None, + ) + + body = json.loads(response["body"]) + self.assertEqual(30, body["totals"]["pageViews"]) + self.assertEqual("/challenges", body["pages"][0]["path"]) + + +if __name__ == "__main__": + unittest.main() 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/analytics/README.md b/src/apps/analytics/README.md new file mode 100644 index 000000000..9886863dd --- /dev/null +++ b/src/apps/analytics/README.md @@ -0,0 +1,90 @@ +# Analytics app + +The Analytics app is the first-party reporting UI for Topcoder's AWS +Clickstream data. It is bundled with Platform UI and is available at +`/analytics` on the combined Platform UI host or at the root of the dedicated +`analytics.` host. + +## Access control + +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 +verified Topcoder roles claim before running a query. + +The route tree is: + +```text +/analytics + -> /analytics/campaigns + /campaigns + /general +``` + +On `analytics.`, the same children are `/campaigns` and `/general`. + +## Reports + +Campaign Analytics uses first-touch UTM attribution and an ordered cohort +funnel. Its totals answer how many distinct landing visitors clicked, then +registered for a challenge after clicking, then submitted after registering. +It also shows campaign and landing-page breakdowns plus aggregate click +locations by semantic element fields and ten-percentage-point viewport buckets. + +General Analytics shows page views, distinct visitors, clicks, and distinct +clickers over time, with page, traffic-source, and instrumented-application +breakdowns. + +Counts are daily aggregates from the AWS Clickstream reporting views. Date +ranges are inclusive and limited to 366 days. The UI displays the warehouse's +`dataThrough` value because the development transform currently runs daily. +Empty dates in a series are not inferred as provider outages. + +Redshift Serverless can take one request to resume after an idle period. The UI +automatically retries one warehouse timeout after a one-second delay while +keeping the loading state visible. A second timeout is surfaced with the +explicit retry action so requests remain bounded. + +## Privacy + +The UI receives aggregate counts only. It never receives member IDs, +pseudonymous analytics IDs, handles, email addresses, rendered click text, form +values, raw destination queries, or raw click coordinates. API errors are +mapped to safe categories before display, and last-good data remains visible if +a refresh fails. + +## Configuration + +Set the following build variable for each provisioned environment: + +```text +REACT_APP_ANALYTICS_API_URL=https://api./v1/analytics +``` + +Leave it empty where the API has not been provisioned. Authenticated requests +use Platform UI's global XHR client, which attaches the current access token. + +The event collector is a separate public ingestion endpoint configured through +`REACT_APP_AWS_ANALYTICS_ENDPOINT`; in development it is +`https://events.topcoder-dev.com/collect`. Do not point the reporting UI at the +collector or reuse development resources in production. + +## Verification + +From the Platform UI project directory: + +```bash +nvm use +yarn lint +CI=true yarn test --watchAll=false --runTestsByPath \ + src/apps/analytics/src/config/routes.config.spec.ts \ + src/apps/analytics/src/analytics-app.routes.spec.tsx \ + src/apps/analytics/src/lib/services/analytics.service.spec.ts \ + src/apps/analytics/src/lib/utils/analytics.utils.spec.ts \ + src/apps/analytics/src/lib/hooks/useAnalyticsResource.spec.ts +yarn run build +``` + +The AWS API source, database grants, deployment sequence, and operational +checks are documented in +[`../../../infrastructure/analytics-api/README.md`](../../../infrastructure/analytics-api/README.md). diff --git a/src/apps/analytics/index.ts b/src/apps/analytics/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/analytics/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/analytics/src/AnalyticsApp.tsx b/src/apps/analytics/src/AnalyticsApp.tsx new file mode 100644 index 000000000..7092bbdc4 --- /dev/null +++ b/src/apps/analytics/src/AnalyticsApp.tsx @@ -0,0 +1,34 @@ +/** Root application shell for role-gated product analytics. */ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { AnalyticsLayout } from './lib/components' +import { toolTitle } from './analytics-app.routes' +import './lib/styles/index.scss' + +/** + * Renders Analytics navigation, layout, and the active child route. + * + * @returns Analytics application shell. + * @throws Does not throw. + */ +const AnalyticsApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('analytics-app') + return () => document.body.classList.remove('analytics-app') + }, []) + + return ( + + + {childRoutes} + + ) +} + +export default AnalyticsApp diff --git a/src/apps/analytics/src/analytics-app.routes.spec.tsx b/src/apps/analytics/src/analytics-app.routes.spec.tsx new file mode 100644 index 000000000..841ecc14b --- /dev/null +++ b/src/apps/analytics/src/analytics-app.routes.spec.tsx @@ -0,0 +1,44 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { UserRole } from '~/libs/core' + +import { analyticsRoutes } from './analytics-app.routes' + +jest.mock('~/config', () => ({ + AppSubdomain: { analytics: 'analytics' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, + ToolTitle: { analytics: 'Analytics' }, +}), { virtual: true }) + +jest.mock('~/libs/core', () => ({ + lazyLoad: () => (): JSX.Element =>
, + Rewrite: (): JSX.Element =>
, + UserRole: { analytics: 'analytics' }, +}), { virtual: true }) + +describe('Analytics application routes', () => { + it('protects the dedicated host and every child with the analytics role', () => { + const [root] = analyticsRoutes + + expect(root.authRequired) + .toBe(true) + expect(root.domain) + .toBe('analytics') + expect(root.rolesRequired) + .toEqual([UserRole.analytics]) + root.children?.forEach(child => { + expect(child.authRequired) + .toBe(true) + expect(child.rolesRequired) + .toEqual([UserRole.analytics]) + }) + }) + + it('registers the default redirect plus campaign and general tabs', () => { + const children = analyticsRoutes[0].children + + expect(children?.map(route => route.route)) + .toEqual(['', 'campaigns', 'general']) + expect(children?.[0].element?.props.to) + .toBe('campaigns') + }) +}) diff --git a/src/apps/analytics/src/analytics-app.routes.tsx b/src/apps/analytics/src/analytics-app.routes.tsx new file mode 100644 index 000000000..49791b735 --- /dev/null +++ b/src/apps/analytics/src/analytics-app.routes.tsx @@ -0,0 +1,57 @@ +/** Platform route tree for the role-gated Analytics application. */ +import { AppSubdomain, ToolTitle } from '~/config' +import { + lazyLoad, + LazyLoadedComponent, + PlatformRoute, + Rewrite, + UserRole, +} from '~/libs/core' + +import { + campaignsRouteId, + generalRouteId, + rootRoute, +} from './config/routes.config' + +const AnalyticsApp: LazyLoadedComponent = lazyLoad(() => import('./AnalyticsApp')) +const CampaignAnalyticsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/CampaignAnalyticsPage'), + 'CampaignAnalyticsPage', +) +const GeneralAnalyticsPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/GeneralAnalyticsPage'), + 'GeneralAnalyticsPage', +) + +export const toolTitle: string = ToolTitle.analytics + +export const analyticsRoutes: ReadonlyArray = [{ + authRequired: true, + children: [ + { + authRequired: true, + element: , + rolesRequired: [UserRole.analytics], + route: '', + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.analytics], + route: campaignsRouteId, + }, + { + authRequired: true, + element: , + rolesRequired: [UserRole.analytics], + route: generalRouteId, + }, + ], + domain: AppSubdomain.analytics, + element: , + id: toolTitle, + rolesRequired: [UserRole.analytics], + route: rootRoute, + title: toolTitle, +}] diff --git a/src/apps/analytics/src/config/routes.config.spec.ts b/src/apps/analytics/src/config/routes.config.spec.ts new file mode 100644 index 000000000..8e1422c7a --- /dev/null +++ b/src/apps/analytics/src/config/routes.config.spec.ts @@ -0,0 +1,26 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import { + buildAnalyticsPath, + getAnalyticsRootRoute, +} from './routes.config' + +jest.mock('~/config', () => ({ + AppSubdomain: { analytics: 'analytics' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, +}), { virtual: true }) + +describe('Analytics route configuration', () => { + it('resolves combined and dedicated host roots', () => { + expect(getAnalyticsRootRoute('platform-ui')) + .toBe('/analytics') + expect(getAnalyticsRootRoute('analytics')) + .toBe('') + }) + + it('builds encoded absolute paths under the active root', () => { + expect(buildAnalyticsPath('campaigns')) + .toBe('/analytics/campaigns') + expect(buildAnalyticsPath('campaigns', 'paid search')) + .toBe('/analytics/campaigns/paid%20search') + }) +}) diff --git a/src/apps/analytics/src/config/routes.config.ts b/src/apps/analytics/src/config/routes.config.ts new file mode 100644 index 000000000..38efbb882 --- /dev/null +++ b/src/apps/analytics/src/config/routes.config.ts @@ -0,0 +1,32 @@ +/** Route identifiers and builders for the Analytics application. */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +/** + * Resolves the Analytics root for combined Platform UI and dedicated hosts. + * + * @param subdomain current hostname's leading label. + * @returns empty dedicated-host root or `/analytics` on the combined host. + * @throws Does not throw. + */ +export function getAnalyticsRootRoute(subdomain: string): string { + return subdomain === AppSubdomain.analytics ? '' : `/${AppSubdomain.analytics}` +} + +export const rootRoute = getAnalyticsRootRoute(EnvironmentConfig.SUBDOMAIN) +export const campaignsRouteId = 'campaigns' +export const generalRouteId = 'general' + +/** + * Builds an absolute in-app Analytics path for combined and dedicated hosts. + * + * @param segments URL-safe segments excluding the Analytics root. + * @returns normalized path beginning with `/`. + * @throws Does not throw. + */ +export function buildAnalyticsPath(...segments: string[]): string { + const suffix = segments + .filter(Boolean) + .map(segment => encodeURIComponent(segment)) + .join('/') + return `${rootRoute || ''}/${suffix}`.replace(/\/+/g, '/') +} diff --git a/src/apps/analytics/src/index.ts b/src/apps/analytics/src/index.ts new file mode 100644 index 000000000..8d3fb774f --- /dev/null +++ b/src/apps/analytics/src/index.ts @@ -0,0 +1 @@ +export * from './analytics-app.routes' diff --git a/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.module.scss b/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.module.scss new file mode 100644 index 000000000..9ed6a4281 --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.module.scss @@ -0,0 +1,22 @@ +@import '@libs/ui/styles/includes'; + +.contentLayoutOuter { + background: #f5f7fa; + min-height: calc(100vh - 180px); +} + +.contentLayoutInner { + max-width: 1440px; + padding-bottom: $sp-10; + padding-top: $sp-8; +} + +.main { + width: 100%; +} + +@include ltemd { + .contentLayoutInner { + padding: $sp-5 $sp-4 $sp-8; + } +} diff --git a/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.tsx b/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.tsx new file mode 100644 index 000000000..d9901297e --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsLayout/AnalyticsLayout.tsx @@ -0,0 +1,29 @@ +/** Responsive page layout shared by both Analytics tabs. */ +import { FC, PropsWithChildren } from 'react' + +import { ContentLayout } from '~/libs/ui' + +import { AnalyticsNav } from '../AnalyticsNav' + +import styles from './AnalyticsLayout.module.scss' + +/** + * Places analytics content below its dedicated tab navigation. + * + * @param props React children rendered in the constrained content region. + * @returns Analytics page layout. + * @throws Does not throw. + */ +export const AnalyticsLayout: FC = props => ( + <> + + +
{props.children}
+
+ +) + +export default AnalyticsLayout diff --git a/src/apps/analytics/src/lib/components/AnalyticsLayout/index.ts b/src/apps/analytics/src/lib/components/AnalyticsLayout/index.ts new file mode 100644 index 000000000..c8222dfa6 --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsLayout/index.ts @@ -0,0 +1 @@ +export * from './AnalyticsLayout' diff --git a/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.module.scss b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.module.scss new file mode 100644 index 000000000..dcc01c8e6 --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.module.scss @@ -0,0 +1,105 @@ +@import '@libs/ui/styles/includes'; + +.navBar { + background: #ffffff; + border-bottom: 1px solid #d9e1e8; + box-shadow: 0 2px 8px rgba(13, 52, 69, 0.05); +} + +.inner { + align-items: stretch; + display: flex; + margin: 0 auto; + max-width: 1440px; + min-height: 64px; + padding: 0 $sp-8; +} + +.tabs { + display: flex; + gap: $sp-8; + list-style: none; + margin: 0; + padding: 0; +} + +.tab { + align-items: center; + border-bottom: 3px solid transparent; + color: #455b65; + display: flex; + font-size: 15px; + font-weight: 700; + height: 64px; + padding: 0 $sp-1; + text-decoration: none; + + &:hover { + color: #0d3445; + } + + &.active { + border-bottom-color: #137d60; + color: #137d60; + } +} + +.mobileTrigger { + display: none; +} + +@include ltemd { + .inner { + display: block; + min-height: 56px; + padding: 0 $sp-4; + } + + .mobileTrigger { + align-items: center; + background: transparent; + border: 0; + color: #0d3445; + display: flex; + font: inherit; + font-weight: 800; + height: 56px; + justify-content: space-between; + padding: 0; + width: 100%; + } + + .chevron { + font-size: 22px; + transform: rotate(0deg); + transition: transform 150ms ease; + } + + .tabs { + display: none; + padding-bottom: $sp-3; + } + + .open { + .tabs { + display: block; + } + + .chevron { + transform: rotate(180deg); + } + } + + .tab { + border-bottom: 0; + border-left: 3px solid transparent; + height: 44px; + padding: 0 $sp-4; + + &.active { + border-bottom: 0; + border-left-color: #137d60; + background: #eef8f4; + } + } +} diff --git a/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.spec.tsx b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.spec.tsx new file mode 100644 index 000000000..fd0e83d9c --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.spec.tsx @@ -0,0 +1,31 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import '@testing-library/jest-dom' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { AnalyticsNav } from './AnalyticsNav' + +jest.mock('~/config', () => ({ + AppSubdomain: { analytics: 'analytics' }, + EnvironmentConfig: { SUBDOMAIN: 'platform-ui' }, +}), { virtual: true }) + +describe('Analytics navigation tabs', () => { + it('renders only Campaigns and General with the current tab selected', () => { + render( + + + , + ) + + expect(screen.getAllByRole('link') + .map(link => link.textContent)) + .toEqual(['Campaigns', 'General']) + expect(screen.getByRole('link', { name: 'General' })) + .toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('button', { name: 'General' })) + .toBeInTheDocument() + expect(screen.queryByText('Analytics')) + .not.toBeInTheDocument() + }) +}) diff --git a/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.tsx b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.tsx new file mode 100644 index 000000000..245c31906 --- /dev/null +++ b/src/apps/analytics/src/lib/components/AnalyticsNav/AnalyticsNav.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react/jsx-no-bind */ +/* eslint-disable ordered-imports/ordered-imports */ +/** URL-driven desktop and mobile navigation for Analytics tabs. */ +import classNames from 'classnames' +import { FC, useMemo, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' + +import { + buildAnalyticsPath, + campaignsRouteId, + generalRouteId, +} from '../../../config/routes.config' + +import styles from './AnalyticsNav.module.scss' + +interface AnalyticsTab { + id: string + label: string +} + +const ANALYTICS_TABS: AnalyticsTab[] = [ + { id: campaignsRouteId, label: 'Campaigns' }, + { id: generalRouteId, label: 'General' }, +] + +/** + * Gets the active Analytics tab from a route pathname. + * + * @param pathname current browser pathname. + * @returns matching tab ID, defaulting to Campaigns. + * @throws Does not throw. + */ +export function getActiveAnalyticsTab(pathname: string): string { + return ANALYTICS_TABS.find(tab => ( + pathname === buildAnalyticsPath(tab.id) + || pathname.startsWith(`${buildAnalyticsPath(tab.id)}/`) + ))?.id ?? campaignsRouteId +} + +/** + * Renders only the Campaigns and General tabs with a mobile disclosure. + * + * @returns responsive Analytics tab navigation. + * @throws Does not throw. + */ +export const AnalyticsNav: FC = () => { + const { pathname }: { pathname: string } = useLocation() + const [isOpen, setIsOpen] = useState(false) + const activeTab = useMemo(() => getActiveAnalyticsTab(pathname), [pathname]) + const activeLabel = ANALYTICS_TABS.find(tab => tab.id === activeTab)?.label ?? 'Campaigns' + + return ( + + ) +} + +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..0750794e1 --- /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; +} + +.screenReaderOnly { + 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..18cd1fe90 --- /dev/null +++ b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.spec.ts @@ -0,0 +1,81 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { + classifyAnalyticsError, + requestAnalyticsWithRetry, +} 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') + }) + + it('retries one warehouse timeout after a bounded delay', async () => { + const result = { generatedAt: '2026-08-31T00:00:00Z' } + const request = jest.fn() + .mockRejectedValueOnce({ response: { status: 504 } }) + .mockResolvedValueOnce(result) + const wait = jest.fn() + .mockResolvedValue(undefined) + + await expect(requestAnalyticsWithRetry(request, wait)) + .resolves.toEqual(result) + expect(request) + .toHaveBeenCalledTimes(2) + expect(wait) + .toHaveBeenCalledWith(1_000) + }) + + it('does not retry a non-timeout failure', async () => { + const error = { response: { status: 403 } } + const request = jest.fn() + .mockRejectedValue(error) + const wait = jest.fn() + .mockResolvedValue(undefined) + + await expect(requestAnalyticsWithRetry(request, wait)) + .rejects.toBe(error) + expect(request) + .toHaveBeenCalledTimes(1) + expect(wait) + .not.toHaveBeenCalled() + }) + + it('surfaces a failed retry without issuing a third request', async () => { + const firstError = { response: { status: 504 } } + const retryError = { response: { status: 504 } } + const request = jest.fn() + .mockRejectedValueOnce(firstError) + .mockRejectedValueOnce(retryError) + const wait = jest.fn() + .mockResolvedValue(undefined) + + await expect(requestAnalyticsWithRetry(request, wait)) + .rejects.toBe(retryError) + expect(request) + .toHaveBeenCalledTimes(2) + }) +}) 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..02d701ec6 --- /dev/null +++ b/src/apps/analytics/src/lib/hooks/useAnalyticsResource.ts @@ -0,0 +1,187 @@ +/** 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 +} + +type AnalyticsRetryWait = (milliseconds: number) => Promise + +const ANALYTICS_TIMEOUT_RETRY_DELAY = 1_000 + +/** + * Waits before retrying a cold analytics warehouse request. + * + * @param milliseconds bounded retry delay. + * @returns promise resolved after the delay. + * @throws Does not throw. + */ +function waitForAnalyticsRetry(milliseconds: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, milliseconds) + }) +} + +/** + * 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, + } +} + +/** + * Runs an analytics request and retries one warehouse timeout after a short delay. + * + * @param request function performing the authenticated analytics GET request. + * @param wait delay implementation; tests inject an immediate deterministic wait. + * @returns analytics response from the initial request or its single retry. + * @throws Rejects immediately for non-timeout failures and after a failed retry. + */ +export async function requestAnalyticsWithRetry( + request: () => Promise, + wait: AnalyticsRetryWait = waitForAnalyticsRetry, +): Promise { + try { + return await request() + } catch (error) { + if (classifyAnalyticsError(error).kind !== 'timeout') throw error + await wait(ANALYTICS_TIMEOUT_RETRY_DELAY) + return request() + } +} + +/** + * 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), + })) + requestAnalyticsWithRetry(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..71cc6b9ef --- /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://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://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://api.example.com/v1/analytics/filters', + ) + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 2, + 'https://api.example.com/v1/analytics/campaign' + + '?from=2026-08-01&to=2026-08-30&campaign=launch', + ) + expect(mockedXhrGetAsync) + .toHaveBeenNthCalledWith( + 3, + '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/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..3a0b873f8 --- /dev/null +++ b/src/apps/analytics/src/pages/AnalyticsPages.module.scss @@ -0,0 +1,260 @@ +@import '@libs/ui/styles/includes'; + +.page { + color: #0d3445; + display: flex; + flex-direction: column; + gap: $sp-6; +} + +.pageHeader { + 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; + } + } +} + +.filterHeading { + grid-column: 1 / -1; + + h2 { + font-size: 18px; + margin: 0; + } + + span { + color: #71838b; + font-size: 13px; + } +} + +.filterActions { + display: flex; + gap: $sp-3; +} + +.filterError { + color: #b42318; + font-size: 13px; + grid-column: 1 / -1; + margin: 0; +} + +.freshness { + color: #617681; + font-size: 13px; + text-align: right; +} + +.staleWarning { + background: #fff8e1; + border: 1px solid #e9b949; + border-radius: 6px; + color: #704d00; + padding: $sp-3 $sp-4; +} + +.metricGrid { + 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; +} + +.panelHeader { + 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; + } +} + +.twoColumnGrid { + display: grid; + gap: $sp-5; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.tableScroll { + overflow-x: auto; + width: 100%; + + table { + border-collapse: collapse; + font-size: 13px; + min-width: 560px; + text-align: left; + width: 100%; + } + + 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; + } +} + +.primaryCell, +.secondaryCell { + display: block; +} + +.secondaryCell { + color: #71838b; + font-size: 11px; + font-weight: 400; + margin-top: 3px; +} + +.emptyTable { + color: #71838b; + padding: $sp-8 !important; + text-align: center; +} + +@include ltemd { + .pageHeader { + align-items: stretch; + flex-direction: column; + + h1 { + font-size: 27px; + } + } + + .filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .metricGrid, + .twoColumnGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@include ltesm { + .filters, + .metricGrid, + .twoColumnGrid { + grid-template-columns: 1fr; + } + + .panel { + padding: $sp-4; + } + + .panelHeader { + 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..831fd3e3c --- /dev/null +++ b/src/apps/analytics/src/pages/CampaignAnalyticsPage.tsx @@ -0,0 +1,431 @@ +/** 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 campaign engagement through click, registration, + and successful challenge submission. +

+
+ +
+ +
+
+

Filters

+
+ + + + + + +
+ + +
+ {filterError && ( +

{filterError}

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

Engagement over time

+

Daily 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/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 => { <>