docs(api): deprecate report routes and document migration compatibility

This commit is contained in:
Mike Cao committed 2026-09-09 19:28:03 -07:00
1 parent 7c4406bad0
commit 4fbaa5300d
46 files changed
+564 -72

No files matched your search

+118
View File
@@ -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.
+1 -1
View File
@@ -97,7 +97,7 @@ describe('UmamiClient', () => {
expect(calls[0].method).toBe('GET'); expect(calls[0].method).toBe('GET');
expect(calls[0].body).toBeUndefined(); expect(calls[0].body).toBeUndefined();
expect(calls[0].url.pathname).toBe('/api/websites/w1/funnels/stats'); 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'); expect(calls[0].url.searchParams.get('browser1')).toBe('eq.Chrome');
}); });
@@ -3411,7 +3411,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Save a report * 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. * 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` * `POST /api/reports`
*/ */
createReport( createReport(
@@ -3589,7 +3592,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Delete a saved report * Delete a saved report
* @deprecated See the operation description for migration details.
* Deletes the specified saved report definition. * 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}` * `DELETE /api/reports/{reportId}`
*/ */
deleteReport( deleteReport(
@@ -4091,7 +4097,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Get a saved report * Get a saved report
* @deprecated See the operation description for migration details.
* Returns the specified report's saved name, description, type, and parameters. * 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}` * `GET /api/reports/{reportId}`
*/ */
getReport( getReport(
@@ -4103,7 +4112,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* List saved reports * 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. * 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` * `GET /api/reports`
*/ */
getReports( getReports(
@@ -4726,7 +4738,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* List a website's saved reports * 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. * 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` * `GET /api/websites/{websiteId}/reports`
*/ */
getWebsiteReports( getWebsiteReports(
@@ -5082,7 +5097,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run an attribution report * 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. * 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` * `POST /api/reports/attribution`
*/ */
runAttributionReport( runAttributionReport(
@@ -5094,7 +5112,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a breakdown report * 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. * 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` * `POST /api/reports/breakdown`
*/ */
runBreakdownReport( runBreakdownReport(
@@ -5106,7 +5127,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a funnel report * 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. * 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` * `POST /api/reports/funnel`
*/ */
runFunnelReport( runFunnelReport(
@@ -5118,7 +5142,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a goal report * 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. * 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` * `POST /api/reports/goal`
*/ */
runGoalReport( runGoalReport(
@@ -5130,7 +5157,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Get page heatmap data * 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. * 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` * `POST /api/reports/heatmap`
*/ */
runHeatmapReport( runHeatmapReport(
@@ -5142,7 +5172,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a visitor journey report * 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. * 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` * `POST /api/reports/journey`
*/ */
runJourneyReport( runJourneyReport(
@@ -5154,7 +5187,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a performance report * 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. * 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` * `POST /api/reports/performance`
*/ */
runPerformanceReport( runPerformanceReport(
@@ -5166,7 +5202,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a retention report * 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. * 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` * `POST /api/reports/retention`
*/ */
runRetentionReport( runRetentionReport(
@@ -5178,7 +5217,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a revenue report * 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. * 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` * `POST /api/reports/revenue`
*/ */
runRevenueReport( runRevenueReport(
@@ -5190,7 +5232,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Run a campaign report * 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. * 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` * `POST /api/reports/utm`
*/ */
runUtmReport( runUtmReport(
@@ -5292,7 +5337,10 @@ export abstract class GeneratedUmamiClient {
/** /**
* Update a saved report * Update a saved report
* @deprecated See the operation description for migration details.
* Updates the specified report's website, name, description, type, and parameters. * 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}` * `POST /api/reports/{reportId}`
*/ */
updateReport( updateReport(
@@ -895,13 +895,19 @@ export interface paths {
}; };
/** /**
* List saved reports * List saved reports
* @deprecated
* @description Returns a paginated list of saved reports for the requested website, optionally filtered by report type. * @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']; get: operations['getReports'];
put?: never; put?: never;
/** /**
* Save a report * Save a report
* @deprecated
* @description Saves a report's name, description, type, and parameters for a website so it can be opened again later. * @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']; post: operations['createReport'];
delete?: never; delete?: never;
@@ -919,18 +925,27 @@ export interface paths {
}; };
/** /**
* Get a saved report * Get a saved report
* @deprecated
* @description Returns the specified report's saved name, description, type, and parameters. * @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']; get: operations['getReport'];
put?: never; put?: never;
/** /**
* Update a saved report * Update a saved report
* @deprecated
* @description Updates the specified report's website, name, description, type, and parameters. * @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']; post: operations['updateReport'];
/** /**
* Delete a saved report * Delete a saved report
* @deprecated
* @description Deletes the specified saved report definition. * @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']; delete: operations['deleteReport'];
options?: never; options?: never;
@@ -949,7 +964,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run an attribution report * Run an attribution report
* @deprecated
* @description Calculates how traffic sources contribute to conversions using the supplied attribution settings, date range, and filters. * @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']; post: operations['runAttributionReport'];
delete?: never; delete?: never;
@@ -969,7 +987,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a breakdown report * Run a breakdown report
* @deprecated
* @description Groups website activity by the selected dimensions for the requested date range and filters. * @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']; post: operations['runBreakdownReport'];
delete?: never; delete?: never;
@@ -989,7 +1010,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a funnel report * Run a funnel report
* @deprecated
* @description Calculates visitor progression through a sequence of pages or events using the supplied funnel steps and filters. * @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']; post: operations['runFunnelReport'];
delete?: never; delete?: never;
@@ -1009,7 +1033,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a goal report * 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. * @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']; post: operations['runGoalReport'];
delete?: never; delete?: never;
@@ -1029,7 +1056,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Get page heatmap data * Get page heatmap data
* @deprecated
* @description Returns recorded click or scroll data for the selected page and date range to display as a heatmap. * @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']; post: operations['runHeatmapReport'];
delete?: never; delete?: never;
@@ -1049,7 +1079,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a visitor journey report * Run a visitor journey report
* @deprecated
* @description Returns paths through pages or events using the supplied journey settings and website filters. * @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']; post: operations['runJourneyReport'];
delete?: never; delete?: never;
@@ -1069,7 +1102,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a performance report * 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. * @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']; post: operations['runPerformanceReport'];
delete?: never; delete?: never;
@@ -1089,7 +1125,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a retention report * Run a retention report
* @deprecated
* @description Calculates how groups of visitors return over time using the supplied retention settings, date range, and filters. * @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']; post: operations['runRetentionReport'];
delete?: never; delete?: never;
@@ -1109,7 +1148,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a revenue report * Run a revenue report
* @deprecated
* @description Returns revenue trends, totals with a comparison period, and breakdowns by country, region, referrer, and channel. * @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']; post: operations['runRevenueReport'];
delete?: never; delete?: never;
@@ -1129,7 +1171,10 @@ export interface paths {
put?: never; put?: never;
/** /**
* Run a campaign report * Run a campaign report
* @deprecated
* @description Returns traffic breakdowns for UTM source, medium, campaign, term, and content using the supplied date range and filters. * @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']; post: operations['runUtmReport'];
delete?: never; delete?: never;
@@ -2456,7 +2501,10 @@ export interface paths {
}; };
/** /**
* List a website's saved reports * List a website's saved reports
* @deprecated
* @description Returns a paginated list of saved report definitions for the website, optionally filtered by report type. * @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']; get: operations['getWebsiteReports'];
put?: never; put?: never;
+1 -1
View File
@@ -424,7 +424,7 @@ describe('createUmamiMcpServer', () => {
expect(init?.method).toBe('GET'); expect(init?.method).toBe('GET');
expect(init?.body).toBeUndefined(); expect(init?.body).toBeUndefined();
expect(url.pathname).toBe(`/api/websites/${WEBSITE_ID}/funnels/stats`); 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(url.searchParams.get('window')).toBe('60');
expect(Number(url.searchParams.get('startAt'))).toBeGreaterThan(0); expect(Number(url.searchParams.get('startAt'))).toBeGreaterThan(0);
}); });
+7 -1
View File
@@ -38,6 +38,7 @@ interface OpenApiOperation {
operationId?: string; operationId?: string;
summary?: string; summary?: string;
description?: string; description?: string;
deprecated?: boolean;
tags?: string[]; tags?: string[];
parameters?: OpenApiParameter[]; parameters?: OpenApiParameter[];
requestBody?: { required?: boolean }; requestBody?: { required?: boolean };
@@ -55,6 +56,7 @@ interface OperationDefinition {
path: string; path: string;
summary: string; summary: string;
description?: string; description?: string;
deprecated?: boolean;
tags: string[]; tags: string[];
pathParams: string[]; pathParams: string[];
queryParams: string[]; queryParams: string[];
@@ -119,6 +121,7 @@ function collectOperations(document: OpenApiDocument): OperationDefinition[] {
path: route, path: route,
summary: operation.summary ?? operationId, summary: operation.summary ?? operationId,
description: operation.description, description: operation.description,
deprecated: operation.deprecated,
tags: operation.tags ?? [], tags: operation.tags ?? [],
pathParams, pathParams,
queryParams, queryParams,
@@ -137,7 +140,7 @@ function collectOperations(document: OpenApiDocument): OperationDefinition[] {
} }
function jsDoc(lines: (string | undefined)[]) { 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) { if (!content.length) {
return ''; return '';
@@ -170,6 +173,9 @@ function renderOperations(operations: OperationDefinition[], version: string) {
!operation.bodyRequired; !operation.bodyRequired;
const doc = jsDoc([ const doc = jsDoc([
operation.summary, operation.summary,
operation.deprecated
? '@deprecated See the operation description for migration details.'
: undefined,
operation.description ? '' : undefined, operation.description ? '' : undefined,
operation.description, operation.description,
'', '',
@@ -1,7 +1,7 @@
import { useModified } from '@/components/hooks/useModified';
import { Button, Column, ListItem, Row, Select, Text, TextField } from '@umami/react-zen'; import { Button, Column, ListItem, Row, Select, Text, TextField } from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useApi, useMessages } from '@/components/hooks'; import { useApi, useMessages } from '@/components/hooks';
import { useModified } from '@/components/hooks/useModified';
import { LinkSelect } from '@/components/input/LinkSelect'; import { LinkSelect } from '@/components/input/LinkSelect';
import { PixelSelect } from '@/components/input/PixelSelect'; import { PixelSelect } from '@/components/input/PixelSelect';
import { WebsiteSelect } from '@/components/input/WebsiteSelect'; import { WebsiteSelect } from '@/components/input/WebsiteSelect';
@@ -29,7 +29,7 @@ import { ListTable } from '@/components/metrics/ListTable';
import { MetricLabel } from '@/components/metrics/MetricLabel'; import { MetricLabel } from '@/components/metrics/MetricLabel';
import { PerformanceCard } from '@/components/metrics/PerformanceCard'; import { PerformanceCard } from '@/components/metrics/PerformanceCard';
import { renderDateLabels } from '@/lib/charts'; 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 { generateTimeSeries } from '@/lib/date';
import { formatLongNumber } from '@/lib/format'; import { formatLongNumber } from '@/lib/format';
import styles from './Performance.module.css'; 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 renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
const threshold = WEB_VITALS_THRESHOLDS[selectedMetric as keyof typeof WEB_VITALS_THRESHOLDS];
const isCls = selectedMetric === 'cls'; const isCls = selectedMetric === 'cls';
const metricLabel = t(labels[selectedMetric]) || selectedMetric.toUpperCase(); const metricLabel = t(labels[selectedMetric]) || selectedMetric.toUpperCase();
const formatListCount = isCls const formatListCount = isCls
+4 -1
View File
@@ -2,7 +2,10 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema'; import { reportResultSchema } from '@/lib/schema';
import { canViewWebsiteSection } from '@/permissions'; 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) { export async function POST(request: Request) {
const { auth, body, error } = await parseRequest(request, reportResultSchema); const { auth, body, error } = await parseRequest(request, reportResultSchema);
+4 -1
View File
@@ -2,7 +2,10 @@ import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { reportResultSchema } from '@/lib/schema'; import { reportResultSchema } from '@/lib/schema';
import { canViewWebsiteSection } from '@/permissions'; 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'; import { getPerformanceMetrics } from '@/queries/sql/performance/getPerformanceMetrics';
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { attributionQuerySchema } from '@/lib/analytics-schema'; import { attributionQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -2,7 +2,10 @@ import { attributionQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions'; 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( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { breakdownQuerySchema } from '@/lib/analytics-schema'; import { breakdownQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -2,7 +2,7 @@ import { breakdownQuerySchema } from '@/lib/analytics-schema';
import { getQueryFilters, parseRequest } from '@/lib/request'; import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions'; 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( export async function GET(
request: Request, request: Request,
@@ -1,13 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { funnelDefinitionSchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation'; import { defineOperation } from '@/openapi/operation';
import { import {
badRequestResponse, badRequestResponse,
unauthorizedResponse,
notFoundResponse,
jsonResponse, jsonResponse,
notFoundResponse,
okSchema, okSchema,
unauthorizedResponse,
} from '@/openapi/schemas'; } from '@/openapi/schemas';
import { funnelDefinitionSchema } from '@/lib/analytics-schema';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,8 +1,8 @@
import { funnelDefinitionSchema } from '@/lib/analytics-schema'; import { funnelDefinitionSchema } from '@/lib/analytics-schema';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response'; import { json, notFound, ok, unauthorized } from '@/lib/response';
import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions'; import { canDeleteReport, canUpdateReport, canViewReport } from '@/permissions';
import { getReport, updateReport, deleteReport } from '@/queries/prisma'; import { deleteReport, getReport, updateReport } from '@/queries/prisma';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { savedStatsQuerySchema } from '@/lib/analytics-schema'; import { savedStatsQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -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 { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized, notFound, badRequest } from '@/lib/response'; import { badRequest, json, notFound, unauthorized } from '@/lib/response';
import { canViewWebsiteSection, canViewReport } from '@/permissions'; import { canViewReport, canViewWebsiteSection } from '@/permissions';
import { getReport } from '@/queries/prisma'; 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( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { definitionListSchema, funnelDefinitionSchema } from '@/lib/analytics-schema'; import { definitionListSchema, funnelDefinitionSchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { funnelQuerySchema } from '@/lib/analytics-schema'; import { funnelQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions'; 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( export async function GET(
request: Request, request: Request,
@@ -1,13 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { goalDefinitionSchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation'; import { defineOperation } from '@/openapi/operation';
import { import {
badRequestResponse, badRequestResponse,
unauthorizedResponse,
notFoundResponse,
jsonResponse, jsonResponse,
notFoundResponse,
okSchema, okSchema,
unauthorizedResponse,
} from '@/openapi/schemas'; } from '@/openapi/schemas';
import { goalDefinitionSchema } from '@/lib/analytics-schema';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,8 +1,8 @@
import { goalDefinitionSchema } from '@/lib/analytics-schema'; import { goalDefinitionSchema } from '@/lib/analytics-schema';
import { parseRequest } from '@/lib/request'; import { parseRequest } from '@/lib/request';
import { json, notFound, ok, unauthorized } from '@/lib/response'; import { json, notFound, ok, unauthorized } from '@/lib/response';
import { canViewReport, canUpdateReport, canDeleteReport } from '@/permissions'; import { canDeleteReport, canUpdateReport, canViewReport } from '@/permissions';
import { getReport, updateReport, deleteReport } from '@/queries/prisma'; import { deleteReport, getReport, updateReport } from '@/queries/prisma';
export async function GET( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { savedStatsQuerySchema } from '@/lib/analytics-schema'; import { savedStatsQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -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 { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized, notFound, badRequest } from '@/lib/response'; import { badRequest, json, notFound, unauthorized } from '@/lib/response';
import { canViewWebsiteSection, canViewReport } from '@/permissions'; import { canViewReport, canViewWebsiteSection } from '@/permissions';
import { getReport } from '@/queries/prisma'; 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( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { definitionListSchema, goalDefinitionSchema } from '@/lib/analytics-schema'; import { definitionListSchema, goalDefinitionSchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { goalQuerySchema } from '@/lib/analytics-schema'; import { goalQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response'; import { json, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions'; 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( export async function GET(
request: Request, request: Request,
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { heatmapQuerySchema } from '@/lib/analytics-schema'; import { heatmapQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { journeyQuerySchema } from '@/lib/analytics-schema'; import { journeyQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { performanceChartQuerySchema } from '@/lib/analytics-schema'; import { performanceChartQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { performanceMetricsQuerySchema } from '@/lib/analytics-schema'; import { performanceMetricsQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { performanceStatsQuerySchema } from '@/lib/analytics-schema'; import { performanceStatsQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { retentionQuerySchema } from '@/lib/analytics-schema'; import { retentionQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, unauthorizedResponse, notFoundResponse } from '@/openapi/schemas';
import { utmMetricsQuerySchema } from '@/lib/analytics-schema'; import { utmMetricsQuerySchema } from '@/lib/analytics-schema';
import { defineOperation } from '@/openapi/operation';
import { badRequestResponse, notFoundResponse, unauthorizedResponse } from '@/openapi/schemas';
export const operations = [ export const operations = [
defineOperation({ defineOperation({
@@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest';
import { useFunnelQuery } from './useFunnelQuery'; import { useFunnelQuery } from './useFunnelQuery';
import { usePerformanceStatsQuery } from './usePerformanceStatsQuery'; import { usePerformanceStatsQuery } from './usePerformanceStatsQuery';
import { useUTMMetricsQuery } from './useUTMMetricsQuery'; import { useUTMMetricsQuery } from './useUTMMetricsQuery';
const mocks = vi.hoisted(() => ({ get: vi.fn(), query: vi.fn((options: any) => options) })); 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('../useApi', () => ({ useApi: () => ({ get: mocks.get, useQuery: mocks.query }) }));
vi.mock('../useDateParameters', () => ({ vi.mock('../useDateParameters', () => ({
@@ -4,7 +4,8 @@ import type { JourneyParameters, JourneyResult } from '@/queries/sql/journeys/ge
import { useAnalyticsQuery } from './useAnalyticsQuery'; import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useJourneyQuery( export function useJourneyQuery(
params: AnalyticsParameters & Partial<Omit<JourneyParameters, 'startDate' | 'endDate'>> & { eventType?: number }, params: AnalyticsParameters &
Partial<Omit<JourneyParameters, 'startDate' | 'endDate'>> & { eventType?: number },
options?: ReactQueryOptions<JourneyResult[]>, options?: ReactQueryOptions<JourneyResult[]>,
) { ) {
return useAnalyticsQuery<JourneyResult[]>('journeys', params, options); return useAnalyticsQuery<JourneyResult[]>('journeys', params, options);
+3 -3
View File
@@ -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/attribution/getAttribution', () => ({ getAttribution: mocks.query }));
vi.mock('@/queries/sql/heatmap/getHeatmap', () => ({ getHeatmap: 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 journeys } from '@/app/api/websites/[websiteId]/journeys/route';
import { GET as retention } from '@/app/api/websites/[websiteId]/retention/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 = [ const cases = [
['journeys', journeys, { steps: '3' }], ['journeys', journeys, { steps: '3' }],
+1 -1
View File
@@ -5,9 +5,9 @@ import {
filterParams, filterParams,
funnelReportSchema, funnelReportSchema,
goalReportSchema, goalReportSchema,
pagingParams,
heatmapReportSchema, heatmapReportSchema,
journeyReportSchema, journeyReportSchema,
pagingParams,
timezoneParam, timezoneParam,
unitParam, unitParam,
} from './schema'; } from './schema';
+4 -2
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'; import { beforeEach, describe, expect, test, vi } from 'vitest';
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
report: null as any, report: null as any,
sectionAllowed: true, sectionAllowed: true,
@@ -33,10 +34,11 @@ vi.mock('@/lib/request', () => ({
}, },
getQueryFilters: async () => ({ startDate: new Date(1500), endDate: new Date(2000) }), 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 * 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 { 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 context = { params: Promise.resolve({ websiteId: 'website', goalId: 'id', funnelId: 'id' }) };
const request = () => const request = () =>
+8
View File
@@ -12,6 +12,7 @@ import { applyFieldDescriptions } from '@/openapi/field-descriptions';
import { inferApiContracts } from '@/openapi/infer'; import { inferApiContracts } from '@/openapi/infer';
import { type ApiAudience, getOperationKey } from '@/openapi/operation'; import { type ApiAudience, getOperationKey } from '@/openapi/operation';
import { operationDescriptions } from '@/openapi/operation-descriptions'; import { operationDescriptions } from '@/openapi/operation-descriptions';
import { getReportDeprecation } from '@/openapi/report-deprecations';
import { errorResponseComponents } from '@/openapi/schemas'; import { errorResponseComponents } from '@/openapi/schemas';
import { getSecurityRequirements, securitySchemes } from '@/openapi/security'; import { getSecurityRequirements, securitySchemes } from '@/openapi/security';
@@ -86,9 +87,16 @@ export async function buildOpenApiDocument(
paths[contract.path] = pathItem; paths[contract.path] = pathItem;
} }
const deprecation = getReportDeprecation(contract.method, contract.path);
const description =
operationDescriptions[getOperationKey(contract)]?.description ??
contract.operation.description;
const operation = { const operation = {
...contract.operation, ...contract.operation,
...operationDescriptions[getOperationKey(contract)], ...operationDescriptions[getOperationKey(contract)],
...(deprecation
? { deprecated: true, description: [description, deprecation].filter(Boolean).join('\n\n') }
: {}),
security: getSecurityRequirements(contract.auth), security: getSecurityRequirements(contract.auth),
'x-umami-audience': contract.audience, 'x-umami-audience': contract.audience,
'x-umami-contract': contract.origin, 'x-umami-contract': contract.origin,
+34
View File
@@ -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();
});
+27
View File
@@ -0,0 +1,27 @@
/** Keep the legacy surface discoverable until an announced breaking release. */
const calculations: Record<string, string> = {
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.';
}
}
+5 -5
View File
@@ -1,3 +1,4 @@
export * from './breakdown/getBreakdown';
export * from './events/getEventDataArraySeries'; export * from './events/getEventDataArraySeries';
export * from './events/getEventDataById'; export * from './events/getEventDataById';
export * from './events/getEventDataDateSeries'; export * from './events/getEventDataDateSeries';
@@ -17,19 +18,21 @@ export * from './events/getEventStats';
export * from './events/getEventUsage'; export * from './events/getEventUsage';
export * from './events/getWebsiteEvents'; export * from './events/getWebsiteEvents';
export * from './events/saveEvent'; export * from './events/saveEvent';
export * from './funnels/getFunnel';
export * from './getActiveVisitors'; export * from './getActiveVisitors';
export * from './getChannelExpandedMetrics'; export * from './getChannelExpandedMetrics';
export * from './getChannelMetrics'; export * from './getChannelMetrics';
export * from './getRealtimeActivity'; export * from './getRealtimeActivity';
export * from './getRealtimeData'; export * from './getRealtimeData';
export * from './getWebsiteListCharts';
export * from './getValues'; export * from './getValues';
export * from './getWebsiteDateRange'; export * from './getWebsiteDateRange';
export * from './getWebsiteListCharts';
export * from './getWebsiteStats'; export * from './getWebsiteStats';
export * from './getWeeklyTraffic'; export * from './getWeeklyTraffic';
export * from './heatmap/extractHeatmapEvents'; export * from './heatmap/extractHeatmapEvents';
export * from './heatmap/getHeatmap'; export * from './heatmap/getHeatmap';
export * from './heatmap/saveHeatmapEvents'; export * from './heatmap/saveHeatmapEvents';
export * from './journeys/getJourney';
export * from './pageviews/getPageviewExpandedMetrics'; export * from './pageviews/getPageviewExpandedMetrics';
export * from './pageviews/getPageviewMetrics'; export * from './pageviews/getPageviewMetrics';
export * from './pageviews/getPageviewStats'; export * from './pageviews/getPageviewStats';
@@ -37,11 +40,7 @@ export * from './performance/getPerformanceStats';
export * from './replays/getReplayChunks'; export * from './replays/getReplayChunks';
export * from './replays/getSessionReplays'; export * from './replays/getSessionReplays';
export * from './replays/saveRecording'; export * from './replays/saveRecording';
export * from './breakdown/getBreakdown';
export * from './funnels/getFunnel';
export * from './journeys/getJourney';
export * from './retention/getRetention'; export * from './retention/getRetention';
export * from './utm/getUTM';
export * from './sessions/createSession'; export * from './sessions/createSession';
export * from './sessions/getLinkedDistinctIds'; export * from './sessions/getLinkedDistinctIds';
export * from './sessions/getLinkedSessionIds'; export * from './sessions/getLinkedSessionIds';
@@ -65,3 +64,4 @@ export * from './sessions/getWebsiteSessions';
export * from './sessions/saveSessionData'; export * from './sessions/saveSessionData';
export * from './sessions/saveSessionLink'; export * from './sessions/saveSessionLink';
export * from './sessions/updateSession'; export * from './sessions/updateSession';
export * from './utm/getUTM';
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { getPerformance } from './getPerformance'; import { getPerformance } from './getPerformance';
import { getPerformanceChart } from './getPerformanceChart'; import { getPerformanceChart } from './getPerformanceChart';
import { getPerformanceStats } from './getPerformanceStats'; import { getPerformanceStats } from './getPerformanceStats';
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
backend: 'prisma', backend: 'prisma',
rawQuery: vi.fn(), rawQuery: vi.fn(),
+190
View File
@@ -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);
});