diff --git a/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts index 2d3572ad8b2..7e7c944ff35 100644 --- a/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { Z } from 'zod-class'; +import { InsightsDateFilterDto } from './date-filter.dto'; import { paginationSchema } from '../pagination/pagination.dto'; const VALID_SORT_OPTIONS = [ @@ -30,5 +31,6 @@ const sortByValidator = z export class ListInsightsWorkflowQueryDto extends Z.class({ ...paginationSchema, + dateRange: InsightsDateFilterDto.shape.dateRange, sortBy: sortByValidator, }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 0bb8357c73b..da2e138abd8 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -29,11 +29,12 @@ export { SOURCE_CONTROL_FILE_TYPE, } from './schemas/source-controlled-file.schema'; -export type { - InsightsSummaryType, - InsightsSummaryUnit, - InsightsSummary, - InsightsByWorkflow, - InsightsByTime, - InsightsDateRange, +export { + type InsightsSummaryType, + type InsightsSummaryUnit, + type InsightsSummary, + type InsightsByWorkflow, + type InsightsByTime, + type InsightsDateRange, + INSIGHTS_DATE_RANGE_KEYS, } from './schemas/insights.schema'; diff --git a/packages/@n8n/api-types/src/schemas/insights.schema.ts b/packages/@n8n/api-types/src/schemas/insights.schema.ts index d177f742777..311d08775d8 100644 --- a/packages/@n8n/api-types/src/schemas/insights.schema.ts +++ b/packages/@n8n/api-types/src/schemas/insights.schema.ts @@ -82,13 +82,21 @@ export const insightsByTimeDataSchemas = { }) .strict(), } as const; - export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict(); export type InsightsByTime = z.infer; +export const INSIGHTS_DATE_RANGE_KEYS = [ + 'day', + 'week', + '2weeks', + 'month', + 'quarter', + '6months', + 'year', +] as const; export const insightsDateRangeSchema = z .object({ - key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']), + key: z.enum(INSIGHTS_DATE_RANGE_KEYS), licensed: z.boolean(), granularity: z.enum(['hour', 'day', 'week']), }) diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts index ae2fab4538d..f1fdcf85981 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -1,5 +1,7 @@ import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import type { AuthenticatedRequest } from '@/requests'; import { mockInstance } from '@test/mocking'; import * as testDb from '@test-integration/test-db'; @@ -30,7 +32,10 @@ describe('InsightsController', () => { insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]); // ACT - const response = await controller.getInsightsSummary(); + const response = await controller.getInsightsSummary( + mock(), + mock(), + ); // ASSERT expect(response).toEqual({ @@ -52,7 +57,10 @@ describe('InsightsController', () => { ]); // ACT - const response = await controller.getInsightsSummary(); + const response = await controller.getInsightsSummary( + mock(), + mock(), + ); // ASSERT expect(response).toEqual({ @@ -78,7 +86,10 @@ describe('InsightsController', () => { ]); // ACT - const response = await controller.getInsightsSummary(); + const response = await controller.getInsightsSummary( + mock(), + mock(), + ); // ASSERT expect(response).toEqual({ diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 41f19b845ee..b50b1c0cac9 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -1,3 +1,4 @@ +import type { InsightsDateRange } from '@n8n/api-types'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import { DateTime } from 'luxon'; @@ -517,6 +518,7 @@ describe('getAvailableDateRanges', () => { { key: '2weeks', licensed: true, granularity: 'day' }, { key: 'month', licensed: true, granularity: 'day' }, { key: 'quarter', licensed: true, granularity: 'week' }, + { key: '6months', licensed: true, granularity: 'week' }, { key: 'year', licensed: true, granularity: 'week' }, ]); }); @@ -533,6 +535,7 @@ describe('getAvailableDateRanges', () => { { key: '2weeks', licensed: true, granularity: 'day' }, { key: 'month', licensed: true, granularity: 'day' }, { key: 'quarter', licensed: true, granularity: 'week' }, + { key: '6months', licensed: true, granularity: 'week' }, { key: 'year', licensed: true, granularity: 'week' }, ]); }); @@ -549,6 +552,7 @@ describe('getAvailableDateRanges', () => { { key: '2weeks', licensed: true, granularity: 'day' }, { key: 'month', licensed: true, granularity: 'day' }, { key: 'quarter', licensed: false, granularity: 'week' }, + { key: '6months', licensed: false, granularity: 'week' }, { key: 'year', licensed: false, granularity: 'week' }, ]); }); @@ -565,6 +569,7 @@ describe('getAvailableDateRanges', () => { { key: '2weeks', licensed: false, granularity: 'day' }, { key: 'month', licensed: false, granularity: 'day' }, { key: 'quarter', licensed: false, granularity: 'week' }, + { key: '6months', licensed: false, granularity: 'week' }, { key: 'year', licensed: false, granularity: 'week' }, ]); }); @@ -581,7 +586,84 @@ describe('getAvailableDateRanges', () => { { key: '2weeks', licensed: true, granularity: 'day' }, { key: 'month', licensed: true, granularity: 'day' }, { key: 'quarter', licensed: true, granularity: 'week' }, + { key: '6months', licensed: false, granularity: 'week' }, { key: 'year', licensed: false, granularity: 'week' }, ]); }); }); + +describe('getMaxAgeInDaysAndGranularity', () => { + let insightsService: InsightsService; + let licenseMock: jest.Mocked; + + beforeAll(() => { + licenseMock = mock(); + insightsService = new InsightsService( + mock(), + mock(), + mock(), + licenseMock, + mock(), + ); + }); + + test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => { + licenseMock.getInsightsMaxHistory.mockReturnValue(365); + licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true); + + const result = insightsService.getMaxAgeInDaysAndGranularity('month'); + + expect(result).toEqual({ + key: 'month', + licensed: true, + granularity: 'day', + maxAgeInDays: 30, + }); + }); + + test('throws an error if the date range is not available', () => { + licenseMock.getInsightsMaxHistory.mockReturnValue(365); + licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true); + + expect(() => { + insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']); + }).toThrowError('The selected date range is not available'); + }); + + test('throws an error if the date range is not licensed', () => { + licenseMock.getInsightsMaxHistory.mockReturnValue(30); + licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false); + + expect(() => { + insightsService.getMaxAgeInDaysAndGranularity('year'); + }).toThrowError('The selected date range exceeds the maximum history allowed by your license.'); + }); + + test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => { + licenseMock.getInsightsMaxHistory.mockReturnValue(90); + licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false); + + const result = insightsService.getMaxAgeInDaysAndGranularity('quarter'); + + expect(result).toEqual({ + key: 'quarter', + licensed: true, + granularity: 'week', + maxAgeInDays: 90, + }); + }); + + test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => { + licenseMock.getInsightsMaxHistory.mockReturnValue(-1); + licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true); + + const result = insightsService.getMaxAgeInDaysAndGranularity('day'); + + expect(result).toEqual({ + key: 'day', + licensed: true, + granularity: 'hour', + maxAgeInDays: 1, + }); + }); +}); diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 7404e2ec82a..806a7539569 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -1,22 +1,39 @@ -import { ListInsightsWorkflowQueryDto } from '@n8n/api-types'; +import { InsightsDateFilterDto, ListInsightsWorkflowQueryDto } from '@n8n/api-types'; import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types'; import { Get, GlobalScope, Licensed, Query, RestController } from '@n8n/decorators'; +import type { UserError } from 'n8n-workflow'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { AuthenticatedRequest } from '@/requests'; import { InsightsService } from './insights.service'; @RestController('/insights') export class InsightsController { - private readonly maxAgeInDaysFilteredInsights = 7; - constructor(private readonly insightsService: InsightsService) {} + /** + * This method is used to transform the date range from the request payload into a maximum age in days. + * It throws a ForbiddenError if the date range does not match the license insights max history + */ + private getMaxAgeInDaysAndGranularity(payload: InsightsDateFilterDto) { + try { + return this.insightsService.getMaxAgeInDaysAndGranularity(payload.dateRange ?? 'week'); + } catch (error: unknown) { + throw new ForbiddenError((error as UserError).message); + } + } + @Get('/summary') @GlobalScope('insights:list') - async getInsightsSummary(): Promise { + async getInsightsSummary( + _req: AuthenticatedRequest, + _res: Response, + @Query payload: InsightsDateFilterDto = { dateRange: 'week' }, + ): Promise { + const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload); return await this.insightsService.getInsightsSummary({ - periodLengthInDays: this.maxAgeInDaysFilteredInsights, + periodLengthInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, }); } @@ -28,8 +45,11 @@ export class InsightsController { _res: Response, @Query payload: ListInsightsWorkflowQueryDto, ): Promise { + const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity({ + dateRange: payload.dateRange ?? 'week', + }); return await this.insightsService.getInsightsByWorkflow({ - maxAgeInDays: this.maxAgeInDaysFilteredInsights, + maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, skip: payload.skip, take: payload.take, sortBy: payload.sortBy, @@ -39,10 +59,15 @@ export class InsightsController { @Get('/by-time') @GlobalScope('insights:list') @Licensed('feat:insights:viewDashboard') - async getInsightsByTime(): Promise { + async getInsightsByTime( + _req: AuthenticatedRequest, + _res: Response, + @Query payload: InsightsDateFilterDto, + ): Promise { + const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload); return await this.insightsService.getInsightsByTime({ - maxAgeInDays: this.maxAgeInDaysFilteredInsights, - periodUnit: 'day', + maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays, + periodUnit: dateRangeAndMaxAgeInDays.granularity, }); } } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 6b017143bd4..9262806aabd 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -1,10 +1,13 @@ -import type { InsightsSummary } from '@n8n/api-types'; -import type { InsightsDateRange } from '@n8n/api-types/src/schemas/insights.schema'; +import { + type InsightsSummary, + type InsightsDateRange, + INSIGHTS_DATE_RANGE_KEYS, +} from '@n8n/api-types'; import { OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; import { Logger } from 'n8n-core'; import type { ExecutionLifecycleHooks } from 'n8n-core'; -import type { IRun } from 'n8n-workflow'; +import { UserError, type IRun } from 'n8n-workflow'; import { License } from '@/license'; @@ -14,6 +17,16 @@ import { InsightsByPeriodRepository } from './database/repositories/insights-by- import { InsightsCollectionService } from './insights-collection.service'; import { InsightsCompactionService } from './insights-compaction.service'; +const keyRangeToDays: Record = { + day: 1, + week: 7, + '2weeks': 14, + month: 30, + quarter: 90, + '6months': 180, + year: 365, +}; + @Service() export class InsightsService { constructor( @@ -181,6 +194,10 @@ export class InsightsService { }); } + /** + * Returns the available date ranges with their license authorization and time granularity + * when grouped by time. + */ getAvailableDateRanges(): InsightsDateRange[] { const maxHistoryInDays = this.license.getInsightsMaxHistory() === -1 @@ -188,13 +205,31 @@ export class InsightsService { : this.license.getInsightsMaxHistory(); const isHourlyDateEnabled = this.license.isInsightsHourlyDataEnabled(); - return [ - { key: 'day', licensed: isHourlyDateEnabled ?? false, granularity: 'hour' }, - { key: 'week', licensed: maxHistoryInDays >= 7, granularity: 'day' }, - { key: '2weeks', licensed: maxHistoryInDays >= 14, granularity: 'day' }, - { key: 'month', licensed: maxHistoryInDays >= 30, granularity: 'day' }, - { key: 'quarter', licensed: maxHistoryInDays >= 90, granularity: 'week' }, - { key: 'year', licensed: maxHistoryInDays >= 365, granularity: 'week' }, - ]; + return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({ + key, + licensed: + key === 'day' ? (isHourlyDateEnabled ?? false) : maxHistoryInDays >= keyRangeToDays[key], + granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week', + })); + } + + getMaxAgeInDaysAndGranularity( + dateRangeKey: InsightsDateRange['key'], + ): InsightsDateRange & { maxAgeInDays: number } { + const availableDateRanges = this.getAvailableDateRanges(); + + const dateRange = availableDateRanges.find((range) => range.key === dateRangeKey); + if (!dateRange) { + // Not supposed to happen if we trust the dateRangeKey type + throw new UserError('The selected date range is not available'); + } + + if (!dateRange.licensed) { + throw new UserError( + 'The selected date range exceeds the maximum history allowed by your license.', + ); + } + + return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] }; } } diff --git a/packages/cli/test/integration/insights/insights.api.test.ts b/packages/cli/test/integration/insights/insights.api.test.ts index 17db449f289..7c627bf91fe 100644 --- a/packages/cli/test/integration/insights/insights.api.test.ts +++ b/packages/cli/test/integration/insights/insights.api.test.ts @@ -1,3 +1,5 @@ +import type { InsightsDateRange } from '@n8n/api-types'; + import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; @@ -11,6 +13,7 @@ let agents: Record = {}; const testServer = utils.setupTestServer({ endpointGroups: ['insights', 'license', 'auth'], enabledFeatures: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'], + quotas: { 'quota:insights:maxHistoryDays': 365 }, }); beforeAll(async () => { @@ -49,6 +52,54 @@ describe('GET /insights routes return 403 for dashboard routes when summary lice ); }); +describe('GET /insights routes return 403 if date range outside license limits', () => { + beforeAll(() => { + testServer.license.setDefaults({ quotas: { 'quota:insights:maxHistoryDays': 3 } }); + }); + + test('Call should throw forbidden for default week insights', async () => { + const authAgent = agents.admin; + await authAgent.get('/insights/summary').expect(403); + await authAgent.get('/insights/by-time').expect(403); + await authAgent.get('/insights/by-workflow').expect(403); + }); + + test('Call should throw forbidden for daily data without viewHourlyData enabled', async () => { + const authAgent = agents.admin; + await authAgent.get('/insights/summary?dateRange=day').expect(403); + await authAgent.get('/insights/by-time?dateRange=day').expect(403); + await authAgent.get('/insights/by-workflow?dateRange=day').expect(403); + }); +}); + +describe('GET /insights routes return 200 if date range inside license limits', () => { + beforeAll(() => { + testServer.license.setDefaults({ + features: [ + 'feat:insights:viewSummary', + 'feat:insights:viewDashboard', + 'feat:insights:viewHourlyData', + ], + quotas: { 'quota:insights:maxHistoryDays': 365 }, + }); + }); + + test.each([ + 'day', + 'week', + '2weeks', + 'month', + 'quarter', + '6months', + 'year', + ])('Call should work for date range %s', async (dateRange) => { + const authAgent = agents.admin; + await authAgent.get(`/insights/summary?dateRange=${dateRange}`).expect(200); + await authAgent.get(`/insights/by-time?dateRange=${dateRange}`).expect(200); + await authAgent.get(`/insights/by-workflow?dateRange=${dateRange}`).expect(200); + }); +}); + describe('GET /insights/by-workflow', () => { beforeAll(() => { testServer.license.setDefaults({