diff --git a/docs/report-api-migration.md b/docs/report-api-migration.md new file mode 100644 index 000000000..6789a25ee --- /dev/null +++ b/docs/report-api-migration.md @@ -0,0 +1,118 @@ +# Report API migration + +The website analytics APIs replace report execution endpoints. Analytics use GET; saved +funnels and goals have dedicated resource routes. The application and MCP use the new routes. +The report table and existing definition IDs are retained. No database migration is required. + +## Calculation endpoints + +Paths in the replacement column are relative to `/api/websites/{websiteId}`. + +| Legacy POST endpoint | Replacement GET endpoint(s) | +| --- | --- | +| `/api/reports/attribution` | `/attribution` | +| `/api/reports/breakdown` | `/breakdown` | +| `/api/reports/funnel` | `/funnels/stats` or `/funnels/{funnelId}/stats` | +| `/api/reports/goal` | `/goals/stats` or `/goals/{goalId}/stats` | +| `/api/reports/heatmap` | `/heatmaps` | +| `/api/reports/journey` | `/journeys` | +| `/api/reports/performance` | `/performance/stats`, `/performance/chart`, `/performance/metrics` | +| `/api/reports/retention` | `/retention` | +| `/api/reports/revenue` | Existing `/revenue/stats`, `/revenue/chart`, `/revenue/metrics` | +| `/api/reports/utm` | `/utm/metrics` | + +New requests use `startAt` and `endAt` in epoch milliseconds, with optional `timezone`, `unit`, +and existing filter parameters. Dates are normalized through the shared website filter pipeline, +including reset dates and account history limits. The report envelope and discriminator are gone. +Use a single date range; do not send separate filter dates and calculation dates. + +`steps` on funnel previews and `fields` on breakdowns are JSON strings, URL-encoded normally. +Other criteria are individual query parameters. For example: + +```ts +await client.getWebsiteFunnelStats({ + websiteId, + startAt, + endAt, + timezone: 'UTC', + window: 60, + steps: JSON.stringify([ + { type: 'path', value: '/pricing' }, + { type: 'event', value: 'signup' }, + ]), +}); +``` + +The calculation criteria remain feature-specific: journeys accept `steps`, `startStep`, +`endStep`, and `eventType`; attribution accepts `model`, `type`, and `step`; goal previews +accept `type` and `value`; heatmaps accept `urlPath` and `mode`. See `/openapi.json` for contracts. + +Performance stats return the old `summary` object directly. Performance chart returns +`{ chart }`. Performance metrics return a single dimension array; `type` is `path`, `title`, +`device`, or `browser`. Chart and metrics accept `metric` (`lcp`, `inp`, `cls`, `fcp`, `ttfb`). +Stats calculate all metrics independently of the chart selection. The same percentile SQL is +used by legacy and new handlers. + +UTM metrics return `{ utm, views }[]` for one `type`: `utm_source`, `utm_medium`, `utm_campaign`, +`utm_term`, or `utm_content`. The existing pageview counting rules, empty-value exclusion, +and 50-row limit are preserved. UTM board widgets request only their configured dimension. + +Revenue endpoints retain their existing contracts. MCP `get_revenue` composes stats, chart, +and four dimension requests, preserving its previous output and comparison data. + +## Saved definitions + +| Method | Path (relative to website) | Purpose | +| --- | --- | --- | +| GET | `/funnels` | List definitions, with pagination/search | +| POST | `/funnels` | Create `{ name, description?, parameters }` | +| GET | `/funnels/{funnelId}` | Read definition | +| POST | `/funnels/{funnelId}` | Update `{ name, description?, parameters }` | +| DELETE | `/funnels/{funnelId}` | Delete definition | +| GET | `/funnels/{funnelId}/stats` | Calculate saved criteria with supplied dates/filters | +| GET | `/funnels/stats` | Calculate supplied criteria without saving | + +Goals expose the same structure under `/goals`. Website and definition type are fixed by the +route and cannot be changed in an update body. IDs, names, descriptions, and board `reportId` +references are preserved. Saved stats validate stored criteria and require both definition access +and analytics-section access. Unsupported or malformed legacy criteria return 400 for stats; +the definition remains readable/editable through the compatibility API. + +Section-based sharing is preserved. Heatmaps remain authenticated-only. New definition lists +support type-specific section sharing and exclude deleted websites. Legacy report list routes +retain their original, differing authentication behavior during the transition. + +## Compatibility and removal gate + +All legacy calculation and saved-report routes remain callable with their existing contracts. +OpenAPI marks them deprecated; generated SDK methods carry `@deprecated` and remain available. +Legacy handlers and new feature routes call the same SQL functions; no internal HTTP forwarding +is involved. Legacy calculation envelopes and date precedence remain unchanged. + +Do not remove generic report CRUD solely because the current UI only saves funnels and goals. +Before scheduling removal: + +1. Audit persisted report counts by type, including older definitions outside funnel/goal. +2. Audit malformed funnel/goal criteria that stricter feature APIs cannot calculate. +3. Decide how users can export, edit, migrate, or retain every other saved type. +4. Confirm external API clients have replacements for their operations and payloads. +5. Announce a compatibility window and the breaking release that will remove legacy routes. +6. Verify the application, boards, SDK examples, and MCP no longer call legacy endpoints. + +No removal date or version is scheduled by this change. The metadata and documentation prepare +the deprecation; they do not announce a release externally or delete historical records. + +## Validation + +Unit tests cover GET serialization, invalid structured input, normalized ranges, permission +checks, definition website/type mismatches, immutable update scope, SQL dataset separation for +PostgreSQL and ClickHouse, cache keys, SDK requests, MCP composition, and deprecation metadata. + +`tests/api/report-migration.spec.ts` exercises legacy/new result parity, saved-definition +lifecycles, shared access, and split datasets against the existing disposable API test stack. +It is intended to run in both database modes. Do not point it at a development or production +database: the API suite seeds and mutates test data. This migration was developed without a +running disposable API server; live backend parity remains a release verification step. + +Regenerate and verify contracts with `pnpm generate:api`, `pnpm openapi:check`, and +`pnpm check:api:client`. Build packages before their tests so MCP resolves the current API client. diff --git a/packages/api-client/src/client.test.ts b/packages/api-client/src/client.test.ts index 276de990e..2b5839688 100644 --- a/packages/api-client/src/client.test.ts +++ b/packages/api-client/src/client.test.ts @@ -97,7 +97,7 @@ describe('UmamiClient', () => { expect(calls[0].method).toBe('GET'); expect(calls[0].body).toBeUndefined(); expect(calls[0].url.pathname).toBe('/api/websites/w1/funnels/stats'); - expect(JSON.parse(calls[0].url.searchParams.get('steps')!)).toEqual(steps); + expect(JSON.parse(calls[0].url.searchParams.get('steps') ?? 'null')).toEqual(steps); expect(calls[0].url.searchParams.get('browser1')).toBe('eq.Chrome'); }); diff --git a/packages/api-client/src/generated/operations.ts b/packages/api-client/src/generated/operations.ts index 56a991a96..963e1d37a 100644 --- a/packages/api-client/src/generated/operations.ts +++ b/packages/api-client/src/generated/operations.ts @@ -3411,7 +3411,10 @@ export abstract class GeneratedUmamiClient { /** * Save a report + * @deprecated See the operation description for migration details. * Saves a report's name, description, type, and parameters for a website so it can be opened again later. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `POST /api/reports` */ createReport( @@ -3589,7 +3592,10 @@ export abstract class GeneratedUmamiClient { /** * Delete a saved report + * @deprecated See the operation description for migration details. * Deletes the specified saved report definition. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `DELETE /api/reports/{reportId}` */ deleteReport( @@ -4091,7 +4097,10 @@ export abstract class GeneratedUmamiClient { /** * Get a saved report + * @deprecated See the operation description for migration details. * Returns the specified report's saved name, description, type, and parameters. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `GET /api/reports/{reportId}` */ getReport( @@ -4103,7 +4112,10 @@ export abstract class GeneratedUmamiClient { /** * List saved reports + * @deprecated See the operation description for migration details. * Returns a paginated list of saved reports for the requested website, optionally filtered by report type. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `GET /api/reports` */ getReports( @@ -4726,7 +4738,10 @@ export abstract class GeneratedUmamiClient { /** * List a website's saved reports + * @deprecated See the operation description for migration details. * Returns a paginated list of saved report definitions for the website, optionally filtered by report type. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `GET /api/websites/{websiteId}/reports` */ getWebsiteReports( @@ -5082,7 +5097,10 @@ export abstract class GeneratedUmamiClient { /** * Run an attribution report + * @deprecated See the operation description for migration details. * Calculates how traffic sources contribute to conversions using the supplied attribution settings, date range, and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/attribution. The legacy request and response remain supported during migration. * `POST /api/reports/attribution` */ runAttributionReport( @@ -5094,7 +5112,10 @@ export abstract class GeneratedUmamiClient { /** * Run a breakdown report + * @deprecated See the operation description for migration details. * Groups website activity by the selected dimensions for the requested date range and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/breakdown. The legacy request and response remain supported during migration. * `POST /api/reports/breakdown` */ runBreakdownReport( @@ -5106,7 +5127,10 @@ export abstract class GeneratedUmamiClient { /** * Run a funnel report + * @deprecated See the operation description for migration details. * Calculates visitor progression through a sequence of pages or events using the supplied funnel steps and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/funnels/stats or /funnels/{funnelId}/stats. The legacy request and response remain supported during migration. * `POST /api/reports/funnel` */ runFunnelReport( @@ -5118,7 +5142,10 @@ export abstract class GeneratedUmamiClient { /** * Run a goal report + * @deprecated See the operation description for migration details. * Counts visitors who reached a matching page or triggered a matching event and returns the total visitor count for comparison. + * + * Deprecated: use GET /api/websites/{websiteId}/goals/stats or /goals/{goalId}/stats. The legacy request and response remain supported during migration. * `POST /api/reports/goal` */ runGoalReport( @@ -5130,7 +5157,10 @@ export abstract class GeneratedUmamiClient { /** * Get page heatmap data + * @deprecated See the operation description for migration details. * Returns recorded click or scroll data for the selected page and date range to display as a heatmap. + * + * Deprecated: use GET /api/websites/{websiteId}/heatmaps. The legacy request and response remain supported during migration. * `POST /api/reports/heatmap` */ runHeatmapReport( @@ -5142,7 +5172,10 @@ export abstract class GeneratedUmamiClient { /** * Run a visitor journey report + * @deprecated See the operation description for migration details. * Returns paths through pages or events using the supplied journey settings and website filters. + * + * Deprecated: use GET /api/websites/{websiteId}/journeys. The legacy request and response remain supported during migration. * `POST /api/reports/journey` */ runJourneyReport( @@ -5154,7 +5187,10 @@ export abstract class GeneratedUmamiClient { /** * Run a performance report + * @deprecated See the operation description for migration details. * Returns performance trends, summary metrics, and breakdowns by page, page title, device, and browser for the selected website and date range. + * + * Deprecated: use GET /api/websites/{websiteId}/performance/stats, /performance/chart, and /performance/metrics. The legacy request and response remain supported during migration. * `POST /api/reports/performance` */ runPerformanceReport( @@ -5166,7 +5202,10 @@ export abstract class GeneratedUmamiClient { /** * Run a retention report + * @deprecated See the operation description for migration details. * Calculates how groups of visitors return over time using the supplied retention settings, date range, and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/retention. The legacy request and response remain supported during migration. * `POST /api/reports/retention` */ runRetentionReport( @@ -5178,7 +5217,10 @@ export abstract class GeneratedUmamiClient { /** * Run a revenue report + * @deprecated See the operation description for migration details. * Returns revenue trends, totals with a comparison period, and breakdowns by country, region, referrer, and channel. + * + * Deprecated: use GET /api/websites/{websiteId}/revenue/stats, /revenue/chart, and /revenue/metrics. The legacy request and response remain supported during migration. * `POST /api/reports/revenue` */ runRevenueReport( @@ -5190,7 +5232,10 @@ export abstract class GeneratedUmamiClient { /** * Run a campaign report + * @deprecated See the operation description for migration details. * Returns traffic breakdowns for UTM source, medium, campaign, term, and content using the supplied date range and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/utm/metrics for each UTM dimension. The legacy request and response remain supported during migration. * `POST /api/reports/utm` */ runUtmReport( @@ -5292,7 +5337,10 @@ export abstract class GeneratedUmamiClient { /** * Update a saved report + * @deprecated See the operation description for migration details. * Updates the specified report's website, name, description, type, and parameters. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. * `POST /api/reports/{reportId}` */ updateReport( diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 60d8a537f..238f7a421 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -895,13 +895,19 @@ export interface paths { }; /** * List saved reports + * @deprecated * @description Returns a paginated list of saved reports for the requested website, optionally filtered by report type. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ get: operations['getReports']; put?: never; /** * Save a report + * @deprecated * @description Saves a report's name, description, type, and parameters for a website so it can be opened again later. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ post: operations['createReport']; delete?: never; @@ -919,18 +925,27 @@ export interface paths { }; /** * Get a saved report + * @deprecated * @description Returns the specified report's saved name, description, type, and parameters. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ get: operations['getReport']; put?: never; /** * Update a saved report + * @deprecated * @description Updates the specified report's website, name, description, type, and parameters. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ post: operations['updateReport']; /** * Delete a saved report + * @deprecated * @description Deletes the specified saved report definition. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ delete: operations['deleteReport']; options?: never; @@ -949,7 +964,10 @@ export interface paths { put?: never; /** * Run an attribution report + * @deprecated * @description Calculates how traffic sources contribute to conversions using the supplied attribution settings, date range, and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/attribution. The legacy request and response remain supported during migration. */ post: operations['runAttributionReport']; delete?: never; @@ -969,7 +987,10 @@ export interface paths { put?: never; /** * Run a breakdown report + * @deprecated * @description Groups website activity by the selected dimensions for the requested date range and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/breakdown. The legacy request and response remain supported during migration. */ post: operations['runBreakdownReport']; delete?: never; @@ -989,7 +1010,10 @@ export interface paths { put?: never; /** * Run a funnel report + * @deprecated * @description Calculates visitor progression through a sequence of pages or events using the supplied funnel steps and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/funnels/stats or /funnels/{funnelId}/stats. The legacy request and response remain supported during migration. */ post: operations['runFunnelReport']; delete?: never; @@ -1009,7 +1033,10 @@ export interface paths { put?: never; /** * Run a goal report + * @deprecated * @description Counts visitors who reached a matching page or triggered a matching event and returns the total visitor count for comparison. + * + * Deprecated: use GET /api/websites/{websiteId}/goals/stats or /goals/{goalId}/stats. The legacy request and response remain supported during migration. */ post: operations['runGoalReport']; delete?: never; @@ -1029,7 +1056,10 @@ export interface paths { put?: never; /** * Get page heatmap data + * @deprecated * @description Returns recorded click or scroll data for the selected page and date range to display as a heatmap. + * + * Deprecated: use GET /api/websites/{websiteId}/heatmaps. The legacy request and response remain supported during migration. */ post: operations['runHeatmapReport']; delete?: never; @@ -1049,7 +1079,10 @@ export interface paths { put?: never; /** * Run a visitor journey report + * @deprecated * @description Returns paths through pages or events using the supplied journey settings and website filters. + * + * Deprecated: use GET /api/websites/{websiteId}/journeys. The legacy request and response remain supported during migration. */ post: operations['runJourneyReport']; delete?: never; @@ -1069,7 +1102,10 @@ export interface paths { put?: never; /** * Run a performance report + * @deprecated * @description Returns performance trends, summary metrics, and breakdowns by page, page title, device, and browser for the selected website and date range. + * + * Deprecated: use GET /api/websites/{websiteId}/performance/stats, /performance/chart, and /performance/metrics. The legacy request and response remain supported during migration. */ post: operations['runPerformanceReport']; delete?: never; @@ -1089,7 +1125,10 @@ export interface paths { put?: never; /** * Run a retention report + * @deprecated * @description Calculates how groups of visitors return over time using the supplied retention settings, date range, and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/retention. The legacy request and response remain supported during migration. */ post: operations['runRetentionReport']; delete?: never; @@ -1109,7 +1148,10 @@ export interface paths { put?: never; /** * Run a revenue report + * @deprecated * @description Returns revenue trends, totals with a comparison period, and breakdowns by country, region, referrer, and channel. + * + * Deprecated: use GET /api/websites/{websiteId}/revenue/stats, /revenue/chart, and /revenue/metrics. The legacy request and response remain supported during migration. */ post: operations['runRevenueReport']; delete?: never; @@ -1129,7 +1171,10 @@ export interface paths { put?: never; /** * Run a campaign report + * @deprecated * @description Returns traffic breakdowns for UTM source, medium, campaign, term, and content using the supplied date range and filters. + * + * Deprecated: use GET /api/websites/{websiteId}/utm/metrics for each UTM dimension. The legacy request and response remain supported during migration. */ post: operations['runUtmReport']; delete?: never; @@ -2456,7 +2501,10 @@ export interface paths { }; /** * List a website's saved reports + * @deprecated * @description Returns a paginated list of saved report definitions for the website, optionally filtered by report type. + * + * Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled. */ get: operations['getWebsiteReports']; put?: never; diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 835764ad6..90f98fbf1 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -424,7 +424,7 @@ describe('createUmamiMcpServer', () => { expect(init?.method).toBe('GET'); expect(init?.body).toBeUndefined(); expect(url.pathname).toBe(`/api/websites/${WEBSITE_ID}/funnels/stats`); - expect(JSON.parse(url.searchParams.get('steps')!)).toHaveLength(2); + expect(JSON.parse(url.searchParams.get('steps') ?? 'null')).toHaveLength(2); expect(url.searchParams.get('window')).toBe('60'); expect(Number(url.searchParams.get('startAt'))).toBeGreaterThan(0); }); diff --git a/scripts/generate-api-client.ts b/scripts/generate-api-client.ts index e6e5374de..487bbab4b 100644 --- a/scripts/generate-api-client.ts +++ b/scripts/generate-api-client.ts @@ -38,6 +38,7 @@ interface OpenApiOperation { operationId?: string; summary?: string; description?: string; + deprecated?: boolean; tags?: string[]; parameters?: OpenApiParameter[]; requestBody?: { required?: boolean }; @@ -55,6 +56,7 @@ interface OperationDefinition { path: string; summary: string; description?: string; + deprecated?: boolean; tags: string[]; pathParams: string[]; queryParams: string[]; @@ -119,6 +121,7 @@ function collectOperations(document: OpenApiDocument): OperationDefinition[] { path: route, summary: operation.summary ?? operationId, description: operation.description, + deprecated: operation.deprecated, tags: operation.tags ?? [], pathParams, queryParams, @@ -137,7 +140,7 @@ function collectOperations(document: OpenApiDocument): OperationDefinition[] { } function jsDoc(lines: (string | undefined)[]) { - const content = lines.filter((line): line is string => !!line); + const content = lines.filter((line): line is string => !!line).flatMap(line => line.split('\n')); if (!content.length) { return ''; @@ -170,6 +173,9 @@ function renderOperations(operations: OperationDefinition[], version: string) { !operation.bodyRequired; const doc = jsDoc([ operation.summary, + operation.deprecated + ? '@deprecated See the operation description for migration details.' + : undefined, operation.description ? '' : undefined, operation.description, '', diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx index 1f889337a..4aa92e957 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -1,7 +1,7 @@ -import { useModified } from '@/components/hooks/useModified'; import { Button, Column, ListItem, Row, Select, Text, TextField } from '@umami/react-zen'; import { useEffect, useMemo, useState } from 'react'; import { useApi, useMessages } from '@/components/hooks'; +import { useModified } from '@/components/hooks/useModified'; import { LinkSelect } from '@/components/input/LinkSelect'; import { PixelSelect } from '@/components/input/PixelSelect'; import { WebsiteSelect } from '@/components/input/WebsiteSelect'; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx index 2c9a7c0a6..f98d65a4a 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx @@ -29,7 +29,7 @@ import { ListTable } from '@/components/metrics/ListTable'; import { MetricLabel } from '@/components/metrics/MetricLabel'; import { PerformanceCard } from '@/components/metrics/PerformanceCard'; import { renderDateLabels } from '@/lib/charts'; -import { CHART_COLORS, WEB_VITALS_THRESHOLDS } from '@/lib/constants'; +import { CHART_COLORS } from '@/lib/constants'; import { generateTimeSeries } from '@/lib/date'; import { formatLongNumber } from '@/lib/format'; import styles from './Performance.module.css'; @@ -140,7 +140,6 @@ export function Performance({ websiteId, startDate, endDate, unit }: Performance const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); - const threshold = WEB_VITALS_THRESHOLDS[selectedMetric as keyof typeof WEB_VITALS_THRESHOLDS]; const isCls = selectedMetric === 'cls'; const metricLabel = t(labels[selectedMetric]) || selectedMetric.toUpperCase(); const formatListCount = isCls diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts index 86fcb2231..3414e18d8 100644 --- a/src/app/api/reports/attribution/route.ts +++ b/src/app/api/reports/attribution/route.ts @@ -2,7 +2,10 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { reportResultSchema } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { type AttributionParameters, getAttribution } from '@/queries/sql/attribution/getAttribution'; +import { + type AttributionParameters, + getAttribution, +} from '@/queries/sql/attribution/getAttribution'; export async function POST(request: Request) { const { auth, body, error } = await parseRequest(request, reportResultSchema); diff --git a/src/app/api/reports/performance/route.ts b/src/app/api/reports/performance/route.ts index ba10908f1..70cb42c4d 100644 --- a/src/app/api/reports/performance/route.ts +++ b/src/app/api/reports/performance/route.ts @@ -2,7 +2,10 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { reportResultSchema } from '@/lib/schema'; import { canViewWebsiteSection } from '@/permissions'; -import { getPerformance, type PerformanceParameters } from '@/queries/sql/performance/getPerformance'; +import { + getPerformance, + type PerformanceParameters, +} from '@/queries/sql/performance/getPerformance'; import { getPerformanceMetrics } from '@/queries/sql/performance/getPerformanceMetrics'; export async function POST(request: Request) { diff --git a/src/app/api/websites/[websiteId]/attribution/contract.ts b/src/app/api/websites/[websiteId]/attribution/contract.ts index 086d30061..94cb2a561 100644 --- a/src/app/api/websites/[websiteId]/attribution/contract.ts +++ b/src/app/api/websites/[websiteId]/attribution/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { attributionQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/attribution/route.ts b/src/app/api/websites/[websiteId]/attribution/route.ts index 7b4fd30c0..8c935876b 100644 --- a/src/app/api/websites/[websiteId]/attribution/route.ts +++ b/src/app/api/websites/[websiteId]/attribution/route.ts @@ -2,7 +2,10 @@ import { attributionQuerySchema } from '@/lib/analytics-schema'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsiteSection } from '@/permissions'; -import { getAttribution, type AttributionParameters } from '@/queries/sql/attribution/getAttribution'; +import { + type AttributionParameters, + getAttribution, +} from '@/queries/sql/attribution/getAttribution'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/breakdown/contract.ts b/src/app/api/websites/[websiteId]/breakdown/contract.ts index 7bd3aa5fa..7bf4ce89d 100644 --- a/src/app/api/websites/[websiteId]/breakdown/contract.ts +++ b/src/app/api/websites/[websiteId]/breakdown/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { breakdownQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/breakdown/route.ts b/src/app/api/websites/[websiteId]/breakdown/route.ts index d3ca0d563..1326f98f2 100644 --- a/src/app/api/websites/[websiteId]/breakdown/route.ts +++ b/src/app/api/websites/[websiteId]/breakdown/route.ts @@ -2,7 +2,7 @@ import { breakdownQuerySchema } from '@/lib/analytics-schema'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsiteSection } from '@/permissions'; -import { getBreakdown, type BreakdownParameters } from '@/queries/sql/breakdown/getBreakdown'; +import { type BreakdownParameters, getBreakdown } from '@/queries/sql/breakdown/getBreakdown'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/contract.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/contract.ts index f9781da1c..8ee7fb861 100644 --- a/src/app/api/websites/[websiteId]/funnels/[funnelId]/contract.ts +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/contract.ts @@ -1,13 +1,13 @@ import { z } from 'zod'; +import { funnelDefinitionSchema } from '@/lib/analytics-schema'; import { defineOperation } from '@/openapi/operation'; import { badRequestResponse, - unauthorizedResponse, - notFoundResponse, jsonResponse, + notFoundResponse, okSchema, + unauthorizedResponse, } from '@/openapi/schemas'; -import { funnelDefinitionSchema } from '@/lib/analytics-schema'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts index fa98b39e3..18be59678 100644 --- a/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/route.ts @@ -1,8 +1,8 @@ import { funnelDefinitionSchema } from '@/lib/analytics-schema'; import { parseRequest } from '@/lib/request'; import { json, notFound, ok, unauthorized } from '@/lib/response'; -import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions'; -import { getReport, updateReport, deleteReport } from '@/queries/prisma'; +import { canDeleteReport, canUpdateReport, canViewReport } from '@/permissions'; +import { deleteReport, getReport, updateReport } from '@/queries/prisma'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/contract.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/contract.ts index 7fd69f236..251c86e4d 100644 --- a/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/contract.ts +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { savedStatsQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts index 9e2acc27a..cae1d9b01 100644 --- a/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts +++ b/src/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route.ts @@ -1,9 +1,9 @@ -import { savedStatsQuerySchema, funnelParametersSchema } from '@/lib/analytics-schema'; +import { funnelParametersSchema, savedStatsQuerySchema } from '@/lib/analytics-schema'; import { getQueryFilters, parseRequest } from '@/lib/request'; -import { json, unauthorized, notFound, badRequest } from '@/lib/response'; -import { canViewWebsiteSection, canViewReport } from '@/permissions'; +import { badRequest, json, notFound, unauthorized } from '@/lib/response'; +import { canViewReport, canViewWebsiteSection } from '@/permissions'; import { getReport } from '@/queries/prisma'; -import { getFunnel, type FunnelParameters } from '@/queries/sql/funnels/getFunnel'; +import { type FunnelParameters, getFunnel } from '@/queries/sql/funnels/getFunnel'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/funnels/contract.ts b/src/app/api/websites/[websiteId]/funnels/contract.ts index a800b066b..750a37f65 100644 --- a/src/app/api/websites/[websiteId]/funnels/contract.ts +++ b/src/app/api/websites/[websiteId]/funnels/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { definitionListSchema, funnelDefinitionSchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/funnels/stats/contract.ts b/src/app/api/websites/[websiteId]/funnels/stats/contract.ts index afdfc6ae9..afdfaccda 100644 --- a/src/app/api/websites/[websiteId]/funnels/stats/contract.ts +++ b/src/app/api/websites/[websiteId]/funnels/stats/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { funnelQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/funnels/stats/route.ts b/src/app/api/websites/[websiteId]/funnels/stats/route.ts index 2ff706483..df6b0cc6b 100644 --- a/src/app/api/websites/[websiteId]/funnels/stats/route.ts +++ b/src/app/api/websites/[websiteId]/funnels/stats/route.ts @@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsiteSection } from '@/permissions'; -import { getFunnel, type FunnelParameters } from '@/queries/sql/funnels/getFunnel'; +import { type FunnelParameters, getFunnel } from '@/queries/sql/funnels/getFunnel'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/contract.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/contract.ts index 60d270d10..8f2a23454 100644 --- a/src/app/api/websites/[websiteId]/goals/[goalId]/contract.ts +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/contract.ts @@ -1,13 +1,13 @@ import { z } from 'zod'; +import { goalDefinitionSchema } from '@/lib/analytics-schema'; import { defineOperation } from '@/openapi/operation'; import { badRequestResponse, - unauthorizedResponse, - notFoundResponse, jsonResponse, + notFoundResponse, okSchema, + unauthorizedResponse, } from '@/openapi/schemas'; -import { goalDefinitionSchema } from '@/lib/analytics-schema'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts index a42720213..d52d0d074 100644 --- a/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/route.ts @@ -1,8 +1,8 @@ import { goalDefinitionSchema } from '@/lib/analytics-schema'; import { parseRequest } from '@/lib/request'; import { json, notFound, ok, unauthorized } from '@/lib/response'; -import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions'; -import { getReport, updateReport, deleteReport } from '@/queries/prisma'; +import { canDeleteReport, canUpdateReport, canViewReport } from '@/permissions'; +import { deleteReport, getReport, updateReport } from '@/queries/prisma'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/stats/contract.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/contract.ts index d9a5e45e8..103823902 100644 --- a/src/app/api/websites/[websiteId]/goals/[goalId]/stats/contract.ts +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { savedStatsQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts index 7d9aafc42..08bd70a30 100644 --- a/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts +++ b/src/app/api/websites/[websiteId]/goals/[goalId]/stats/route.ts @@ -1,9 +1,9 @@ -import { savedStatsQuerySchema, goalParametersSchema } from '@/lib/analytics-schema'; +import { goalParametersSchema, savedStatsQuerySchema } from '@/lib/analytics-schema'; import { getQueryFilters, parseRequest } from '@/lib/request'; -import { json, unauthorized, notFound, badRequest } from '@/lib/response'; -import { canViewWebsiteSection, canViewReport } from '@/permissions'; +import { badRequest, json, notFound, unauthorized } from '@/lib/response'; +import { canViewReport, canViewWebsiteSection } from '@/permissions'; import { getReport } from '@/queries/prisma'; -import { getGoal, type GoalParameters } from '@/queries/sql/goals/getGoal'; +import { type GoalParameters, getGoal } from '@/queries/sql/goals/getGoal'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/goals/contract.ts b/src/app/api/websites/[websiteId]/goals/contract.ts index 6ada7aaa4..765df90d8 100644 --- a/src/app/api/websites/[websiteId]/goals/contract.ts +++ b/src/app/api/websites/[websiteId]/goals/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { definitionListSchema, goalDefinitionSchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/goals/stats/contract.ts b/src/app/api/websites/[websiteId]/goals/stats/contract.ts index 95e9065a3..d55b2834d 100644 --- a/src/app/api/websites/[websiteId]/goals/stats/contract.ts +++ b/src/app/api/websites/[websiteId]/goals/stats/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { goalQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/goals/stats/route.ts b/src/app/api/websites/[websiteId]/goals/stats/route.ts index 2b0a46654..87a401ed0 100644 --- a/src/app/api/websites/[websiteId]/goals/stats/route.ts +++ b/src/app/api/websites/[websiteId]/goals/stats/route.ts @@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { canViewWebsiteSection } from '@/permissions'; -import { getGoal, type GoalParameters } from '@/queries/sql/goals/getGoal'; +import { type GoalParameters, getGoal } from '@/queries/sql/goals/getGoal'; export async function GET( request: Request, diff --git a/src/app/api/websites/[websiteId]/heatmaps/contract.ts b/src/app/api/websites/[websiteId]/heatmaps/contract.ts index de82416c7..cf74037b4 100644 --- a/src/app/api/websites/[websiteId]/heatmaps/contract.ts +++ b/src/app/api/websites/[websiteId]/heatmaps/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { heatmapQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/journeys/contract.ts b/src/app/api/websites/[websiteId]/journeys/contract.ts index e6aea2268..3e46bfa47 100644 --- a/src/app/api/websites/[websiteId]/journeys/contract.ts +++ b/src/app/api/websites/[websiteId]/journeys/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { journeyQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/performance/chart/contract.ts b/src/app/api/websites/[websiteId]/performance/chart/contract.ts index c8c326243..a438a0894 100644 --- a/src/app/api/websites/[websiteId]/performance/chart/contract.ts +++ b/src/app/api/websites/[websiteId]/performance/chart/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { performanceChartQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/performance/metrics/contract.ts b/src/app/api/websites/[websiteId]/performance/metrics/contract.ts index a96099131..4a14c8ff3 100644 --- a/src/app/api/websites/[websiteId]/performance/metrics/contract.ts +++ b/src/app/api/websites/[websiteId]/performance/metrics/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { performanceMetricsQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/performance/stats/contract.ts b/src/app/api/websites/[websiteId]/performance/stats/contract.ts index 8906d0f78..e257ca603 100644 --- a/src/app/api/websites/[websiteId]/performance/stats/contract.ts +++ b/src/app/api/websites/[websiteId]/performance/stats/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { performanceStatsQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/retention/contract.ts b/src/app/api/websites/[websiteId]/retention/contract.ts index b9f39672e..46ec470cc 100644 --- a/src/app/api/websites/[websiteId]/retention/contract.ts +++ b/src/app/api/websites/[websiteId]/retention/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { retentionQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/app/api/websites/[websiteId]/utm/metrics/contract.ts b/src/app/api/websites/[websiteId]/utm/metrics/contract.ts index f7e8b05f2..7985fa07c 100644 --- a/src/app/api/websites/[websiteId]/utm/metrics/contract.ts +++ b/src/app/api/websites/[websiteId]/utm/metrics/contract.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas'; import { utmMetricsQuerySchema } from '@/lib/analytics-schema'; +import { defineOperation } from '@/openapi/operation'; +import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas'; export const operations = [ defineOperation({ diff --git a/src/components/hooks/queries/useAnalyticsQuery.test.ts b/src/components/hooks/queries/useAnalyticsQuery.test.ts index b0e592636..58a194171 100644 --- a/src/components/hooks/queries/useAnalyticsQuery.test.ts +++ b/src/components/hooks/queries/useAnalyticsQuery.test.ts @@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest'; import { useFunnelQuery } from './useFunnelQuery'; import { usePerformanceStatsQuery } from './usePerformanceStatsQuery'; import { useUTMMetricsQuery } from './useUTMMetricsQuery'; + const mocks = vi.hoisted(() => ({ get: vi.fn(), query: vi.fn((options: any) => options) })); vi.mock('../useApi', () => ({ useApi: () => ({ get: mocks.get, useQuery: mocks.query }) })); vi.mock('../useDateParameters', () => ({ diff --git a/src/components/hooks/queries/useJourneyQuery.ts b/src/components/hooks/queries/useJourneyQuery.ts index f17aea78a..3f29cf790 100644 --- a/src/components/hooks/queries/useJourneyQuery.ts +++ b/src/components/hooks/queries/useJourneyQuery.ts @@ -4,7 +4,8 @@ import type { JourneyParameters, JourneyResult } from '@/queries/sql/journeys/ge import { useAnalyticsQuery } from './useAnalyticsQuery'; export function useJourneyQuery( - params: AnalyticsParameters & Partial> & { eventType?: number }, + params: AnalyticsParameters & + Partial> & { eventType?: number }, options?: ReactQueryOptions, ) { return useAnalyticsQuery('journeys', params, options); diff --git a/src/lib/analytics-routes.test.ts b/src/lib/analytics-routes.test.ts index 5163c4d5a..a500bae4b 100644 --- a/src/lib/analytics-routes.test.ts +++ b/src/lib/analytics-routes.test.ts @@ -30,11 +30,11 @@ vi.mock('@/queries/sql/breakdown/getBreakdown', () => ({ getBreakdown: mocks.que vi.mock('@/queries/sql/attribution/getAttribution', () => ({ getAttribution: mocks.query })); vi.mock('@/queries/sql/heatmap/getHeatmap', () => ({ getHeatmap: mocks.query })); +import { GET as attribution } from '@/app/api/websites/[websiteId]/attribution/route'; +import { GET as breakdown } from '@/app/api/websites/[websiteId]/breakdown/route'; +import { GET as heatmaps } from '@/app/api/websites/[websiteId]/heatmaps/route'; import { GET as journeys } from '@/app/api/websites/[websiteId]/journeys/route'; import { GET as retention } from '@/app/api/websites/[websiteId]/retention/route'; -import { GET as breakdown } from '@/app/api/websites/[websiteId]/breakdown/route'; -import { GET as attribution } from '@/app/api/websites/[websiteId]/attribution/route'; -import { GET as heatmaps } from '@/app/api/websites/[websiteId]/heatmaps/route'; const cases = [ ['journeys', journeys, { steps: '3' }], diff --git a/src/lib/analytics-schema.ts b/src/lib/analytics-schema.ts index bc654a985..9be754134 100644 --- a/src/lib/analytics-schema.ts +++ b/src/lib/analytics-schema.ts @@ -5,9 +5,9 @@ import { filterParams, funnelReportSchema, goalReportSchema, - pagingParams, heatmapReportSchema, journeyReportSchema, + pagingParams, timezoneParam, unitParam, } from './schema'; diff --git a/src/lib/saved-analytics-routes.test.ts b/src/lib/saved-analytics-routes.test.ts index bbe06703d..773216e6d 100644 --- a/src/lib/saved-analytics-routes.test.ts +++ b/src/lib/saved-analytics-routes.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; + const state = vi.hoisted(() => ({ report: null as any, sectionAllowed: true, @@ -33,10 +34,11 @@ vi.mock('@/lib/request', () => ({ }, getQueryFilters: async () => ({ startDate: new Date(1500), endDate: new Date(2000) }), })); -import * as goal from '@/app/api/websites/[websiteId]/goals/[goalId]/route'; + import * as funnel from '@/app/api/websites/[websiteId]/funnels/[funnelId]/route'; -import { GET as goalStats } from '@/app/api/websites/[websiteId]/goals/[goalId]/stats/route'; import { GET as funnelStats } from '@/app/api/websites/[websiteId]/funnels/[funnelId]/stats/route'; +import * as goal from '@/app/api/websites/[websiteId]/goals/[goalId]/route'; +import { GET as goalStats } from '@/app/api/websites/[websiteId]/goals/[goalId]/stats/route'; const context = { params: Promise.resolve({ websiteId: 'website', goalId: 'id', funnelId: 'id' }) }; const request = () => diff --git a/src/openapi/document.ts b/src/openapi/document.ts index f048faf59..b7f761d45 100644 --- a/src/openapi/document.ts +++ b/src/openapi/document.ts @@ -12,6 +12,7 @@ import { applyFieldDescriptions } from '@/openapi/field-descriptions'; import { inferApiContracts } from '@/openapi/infer'; import { type ApiAudience, getOperationKey } from '@/openapi/operation'; import { operationDescriptions } from '@/openapi/operation-descriptions'; +import { getReportDeprecation } from '@/openapi/report-deprecations'; import { errorResponseComponents } from '@/openapi/schemas'; import { getSecurityRequirements, securitySchemes } from '@/openapi/security'; @@ -86,9 +87,16 @@ export async function buildOpenApiDocument( paths[contract.path] = pathItem; } + const deprecation = getReportDeprecation(contract.method, contract.path); + const description = + operationDescriptions[getOperationKey(contract)]?.description ?? + contract.operation.description; const operation = { ...contract.operation, ...operationDescriptions[getOperationKey(contract)], + ...(deprecation + ? { deprecated: true, description: [description, deprecation].filter(Boolean).join('\n\n') } + : {}), security: getSecurityRequirements(contract.auth), 'x-umami-audience': contract.audience, 'x-umami-contract': contract.origin, diff --git a/src/openapi/report-deprecations.test.ts b/src/openapi/report-deprecations.test.ts new file mode 100644 index 000000000..a35846668 --- /dev/null +++ b/src/openapi/report-deprecations.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest'; +import { getReportDeprecation } from './report-deprecations'; + +test('marks all legacy calculation and persistence routes with replacements', () => { + for (const type of [ + 'attribution', + 'breakdown', + 'funnel', + 'goal', + 'heatmap', + 'journey', + 'performance', + 'retention', + 'revenue', + 'utm', + ]) { + expect(getReportDeprecation('post', `/api/reports/${type}`)).toContain( + 'GET /api/websites/{websiteId}/', + ); + } + expect(getReportDeprecation('get', '/api/reports')).toContain('persisted-data audit'); + expect(getReportDeprecation('delete', '/api/reports/{reportId}')).toContain( + 'Existing IDs are preserved', + ); + expect(getReportDeprecation('get', '/api/websites/{websiteId}/reports')).toContain( + 'no removal date', + ); +}); + +test('does not deprecate the replacement feature APIs', () => { + expect(getReportDeprecation('get', '/api/websites/{websiteId}/funnels/stats')).toBeUndefined(); + expect(getReportDeprecation('post', '/api/websites/{websiteId}/funnels')).toBeUndefined(); + expect(getReportDeprecation('get', '/api/websites/{websiteId}/revenue/stats')).toBeUndefined(); +}); diff --git a/src/openapi/report-deprecations.ts b/src/openapi/report-deprecations.ts new file mode 100644 index 000000000..49b7baa93 --- /dev/null +++ b/src/openapi/report-deprecations.ts @@ -0,0 +1,27 @@ +/** Keep the legacy surface discoverable until an announced breaking release. */ +const calculations: Record = { + attribution: 'GET /api/websites/{websiteId}/attribution', + breakdown: 'GET /api/websites/{websiteId}/breakdown', + funnel: 'GET /api/websites/{websiteId}/funnels/stats or /funnels/{funnelId}/stats', + goal: 'GET /api/websites/{websiteId}/goals/stats or /goals/{goalId}/stats', + heatmap: 'GET /api/websites/{websiteId}/heatmaps', + journey: 'GET /api/websites/{websiteId}/journeys', + performance: + 'GET /api/websites/{websiteId}/performance/stats, /performance/chart, and /performance/metrics', + retention: 'GET /api/websites/{websiteId}/retention', + revenue: 'GET /api/websites/{websiteId}/revenue/stats, /revenue/chart, and /revenue/metrics', + utm: 'GET /api/websites/{websiteId}/utm/metrics for each UTM dimension', +}; + +export function getReportDeprecation(method: string, path: string): string | undefined { + const feature = path.startsWith('/api/reports/') ? path.slice('/api/reports/'.length) : ''; + const replacement = method === 'post' ? calculations[feature] : undefined; + if (replacement) { + return `Deprecated: use ${replacement}. The legacy request and response remain supported during migration.`; + } + if ( + ['/api/reports', '/api/reports/{reportId}', '/api/websites/{websiteId}/reports'].includes(path) + ) { + return 'Deprecated: use website-scoped /funnels or /goals resources for saved definitions. Existing IDs are preserved. Other legacy report types remain accessible here pending a persisted-data audit; no removal date has been scheduled.'; + } +} diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index 6058a7e1e..bd020beec 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -1,3 +1,4 @@ +export * from './breakdown/getBreakdown'; export * from './events/getEventDataArraySeries'; export * from './events/getEventDataById'; export * from './events/getEventDataDateSeries'; @@ -17,19 +18,21 @@ export * from './events/getEventStats'; export * from './events/getEventUsage'; export * from './events/getWebsiteEvents'; export * from './events/saveEvent'; +export * from './funnels/getFunnel'; export * from './getActiveVisitors'; export * from './getChannelExpandedMetrics'; export * from './getChannelMetrics'; export * from './getRealtimeActivity'; export * from './getRealtimeData'; -export * from './getWebsiteListCharts'; export * from './getValues'; export * from './getWebsiteDateRange'; +export * from './getWebsiteListCharts'; export * from './getWebsiteStats'; export * from './getWeeklyTraffic'; export * from './heatmap/extractHeatmapEvents'; export * from './heatmap/getHeatmap'; export * from './heatmap/saveHeatmapEvents'; +export * from './journeys/getJourney'; export * from './pageviews/getPageviewExpandedMetrics'; export * from './pageviews/getPageviewMetrics'; export * from './pageviews/getPageviewStats'; @@ -37,11 +40,7 @@ export * from './performance/getPerformanceStats'; export * from './replays/getReplayChunks'; export * from './replays/getSessionReplays'; export * from './replays/saveRecording'; -export * from './breakdown/getBreakdown'; -export * from './funnels/getFunnel'; -export * from './journeys/getJourney'; export * from './retention/getRetention'; -export * from './utm/getUTM'; export * from './sessions/createSession'; export * from './sessions/getLinkedDistinctIds'; export * from './sessions/getLinkedSessionIds'; @@ -65,3 +64,4 @@ export * from './sessions/getWebsiteSessions'; export * from './sessions/saveSessionData'; export * from './sessions/saveSessionLink'; export * from './sessions/updateSession'; +export * from './utm/getUTM'; diff --git a/src/queries/sql/performance/getPerformance.test.ts b/src/queries/sql/performance/getPerformance.test.ts index 34b3c5ad3..15a2817d5 100644 --- a/src/queries/sql/performance/getPerformance.test.ts +++ b/src/queries/sql/performance/getPerformance.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getPerformance } from './getPerformance'; import { getPerformanceChart } from './getPerformanceChart'; import { getPerformanceStats } from './getPerformanceStats'; + const mocks = vi.hoisted(() => ({ backend: 'prisma', rawQuery: vi.fn(), diff --git a/tests/api/report-migration.spec.ts b/tests/api/report-migration.spec.ts new file mode 100644 index 000000000..6e8d1d595 --- /dev/null +++ b/tests/api/report-migration.spec.ts @@ -0,0 +1,190 @@ +import { serializeAnalyticsQuery } from '../../src/lib/analytics-query'; +import { expect, test } from './fixtures'; +import { dateRange } from './helpers/dates'; +import { uniqueName } from './helpers/entities'; + +const calculations = [ + ['journey', 'journeys', { steps: 3 }], + ['retention', 'retention', {}], + ['breakdown', 'breakdown', { fields: ['path', 'country'] }], + ['attribution', 'attribution', { model: 'first-click', type: 'path', step: '/pricing' }], + ['heatmap', 'heatmaps', { mode: 'click', urlPath: '/' }], + [ + 'funnel', + 'funnels/stats', + { + window: 60, + steps: [ + { type: 'path', value: '/' }, + { type: 'path', value: '/pricing' }, + ], + }, + ], + ['goal', 'goals/stats', { type: 'path', value: '/' }], +] as const; + +// SQL rankings can have ties, so compare result content independently of tied row order. +function canonical(value: any): any { + if (Array.isArray(value)) + return value.map(canonical).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + if (value && typeof value === 'object') + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, canonical(value[key])]), + ); + return value; +} + +for (const [type, path, parameters] of calculations) { + test(`GET ${path} matches legacy ${type} results and access`, async ({ + admin, + share, + viewer, + seed, + }) => { + const range = { ...dateRange(seed), timezone: 'UTC', unit: 'day' }; + const body = { + websiteId: seed.website.id, + type, + filters: range, + parameters: { + startDate: new Date(range.startAt).toISOString(), + endDate: new Date(range.endAt).toISOString(), + timezone: 'UTC', + unit: 'day', + ...parameters, + }, + }; + const url = `/api/websites/${seed.website.id}/${path}`; + const params = serializeAnalyticsQuery({ ...range, ...parameters }); + const legacy = await admin.post(`/api/reports/${type}`, body); + const result = await admin.get(url, { params }); + expect(legacy.status).toBe(200); + expect(result.status).toBe(200); + expect(canonical(result.body)).toEqual(canonical(legacy.body)); + expect((await viewer.get(url, { params })).status).toBe(401); + expect((await (await share()).get(url, { params })).status).toBe( + type === 'heatmap' ? 401 : 200, + ); + expect((await admin.get(url)).status).toBe(400); + }); +} + +for (const [type, parameters] of [ + [ + 'funnel', + { + window: 60, + steps: [ + { type: 'path', value: '/' }, + { type: 'event', value: 'signup' }, + ], + }, + ], + ['goal', { type: 'path', value: '/' }], +] as const) { + test(`${type} definitions preserve IDs and scope saved stats to the website`, async ({ + admin, + viewer, + share, + seed, + }) => { + const base = `/api/websites/${seed.website.id}/${type}s`; + const created = await admin.post(base, { name: uniqueName(type), parameters }); + expect(created.status).toBe(200); + const id = created.body.id; + try { + expect((await admin.get(`/api/reports/${id}`)).body.id).toBe(id); + expect((await admin.get(`${base}/${id}`)).body.parameters).toEqual(parameters); + expect((await (await share()).get(base)).status).toBe(200); + const range = dateRange(seed); + const saved = await admin.get(`${base}/${id}/stats`, { params: range }); + const preview = await admin.get(`${base}/stats`, { + params: serializeAnalyticsQuery({ ...range, ...parameters }), + }); + expect(saved.status).toBe(200); + expect(saved.body).toEqual(preview.body); + expect((await viewer.post(`${base}/${id}`, { name: 'Denied', parameters })).status).toBe(401); + const otherBase = `/api/websites/${seed.website2.id}/${type}s/${id}`; + expect((await admin.get(otherBase)).status).toBe(404); + expect((await admin.post(otherBase, { name: 'Wrong website', parameters })).status).toBe(404); + expect((await admin.del(otherBase)).status).toBe(404); + const otherType = type === 'goal' ? 'funnels' : 'goals'; + expect((await admin.get(`/api/websites/${seed.website.id}/${otherType}/${id}`)).status).toBe( + 404, + ); + const updated = await admin.post(`${base}/${id}`, { + name: 'Updated', + websiteId: seed.website2.id, + type: otherType, + parameters, + }); + expect(updated.status).toBe(200); + expect(updated.body).toMatchObject({ id, name: 'Updated', websiteId: seed.website.id, type }); + } finally { + expect((await admin.del(`${base}/${id}`)).status).toBe(200); + } + expect((await admin.get(`${base}/${id}`)).status).toBe(404); + }); +} + +test('performance and UTM datasets reproduce legacy aggregates', async ({ + admin, + viewer, + share, + seed, +}) => { + const range = { ...dateRange(seed), timezone: 'UTC', unit: 'day' }; + const body = (type: string) => ({ + websiteId: seed.website.id, + type, + filters: range, + parameters: { + startDate: new Date(range.startAt).toISOString(), + endDate: new Date(range.endAt).toISOString(), + timezone: 'UTC', + unit: 'day', + metric: 'lcp', + }, + }); + const base = `/api/websites/${seed.website.id}`; + const performance = await admin.post('/api/reports/performance', body('performance')); + expect(performance.status).toBe(200); + for (const [path, key] of [ + ['performance/stats', 'summary'], + ['performance/chart', 'chart'], + ] as const) { + const response = await admin.get(`${base}/${path}`, { params: range }); + expect(response.status).toBe(200); + expect(canonical(key === 'chart' ? response.body.chart : response.body)).toEqual( + canonical(performance.body[key]), + ); + expect((await viewer.get(`${base}/${path}`, { params: range })).status).toBe(401); + expect((await (await share()).get(`${base}/${path}`, { params: range })).status).toBe(200); + } + for (const [type, key] of [ + ['path', 'pages'], + ['title', 'pageTitles'], + ['device', 'devices'], + ['browser', 'browsers'], + ]) { + const response = await admin.get(`${base}/performance/metrics`, { params: { ...range, type } }); + expect(response.status).toBe(200); + expect(canonical(response.body)).toEqual(canonical(performance.body[key])); + } + const utm = await admin.post('/api/reports/utm', body('utm')); + expect(utm.status).toBe(200); + for (const type of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) { + const response = await admin.get(`${base}/utm/metrics`, { params: { ...range, type } }); + expect(response.status).toBe(200); + expect(canonical(response.body)).toEqual(canonical(utm.body[type])); + } + expect( + (await admin.get(`${base}/utm/metrics`, { params: { ...range, type: 'invalid' } })).status, + ).toBe(400); + expect( + (await admin.get(`${base}/performance/metrics`, { params: { ...range, type: 'invalid' } })) + .status, + ).toBe(400); +});