Skip to content

Commit 28596a6

Browse files
feat(core): Implement granularity and date range filtering on insights (#14841)
1 parent 5ff073b commit 28596a6

File tree

8 files changed

+247
-32
lines changed

8 files changed

+247
-32
lines changed

packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod';
22
import { Z } from 'zod-class';
33

4+
import { InsightsDateFilterDto } from './date-filter.dto';
45
import { paginationSchema } from '../pagination/pagination.dto';
56

67
const VALID_SORT_OPTIONS = [
@@ -30,5 +31,6 @@ const sortByValidator = z
3031

3132
export class ListInsightsWorkflowQueryDto extends Z.class({
3233
...paginationSchema,
34+
dateRange: InsightsDateFilterDto.shape.dateRange,
3335
sortBy: sortByValidator,
3436
}) {}

packages/@n8n/api-types/src/index.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ export {
2929
SOURCE_CONTROL_FILE_TYPE,
3030
} from './schemas/source-controlled-file.schema';
3131

32-
export type {
33-
InsightsSummaryType,
34-
InsightsSummaryUnit,
35-
InsightsSummary,
36-
InsightsByWorkflow,
37-
InsightsByTime,
38-
InsightsDateRange,
32+
export {
33+
type InsightsSummaryType,
34+
type InsightsSummaryUnit,
35+
type InsightsSummary,
36+
type InsightsByWorkflow,
37+
type InsightsByTime,
38+
type InsightsDateRange,
39+
INSIGHTS_DATE_RANGE_KEYS,
3940
} from './schemas/insights.schema';

packages/@n8n/api-types/src/schemas/insights.schema.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,21 @@ export const insightsByTimeDataSchemas = {
8282
})
8383
.strict(),
8484
} as const;
85-
8685
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
8786
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
8887

88+
export const INSIGHTS_DATE_RANGE_KEYS = [
89+
'day',
90+
'week',
91+
'2weeks',
92+
'month',
93+
'quarter',
94+
'6months',
95+
'year',
96+
] as const;
8997
export const insightsDateRangeSchema = z
9098
.object({
91-
key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']),
99+
key: z.enum(INSIGHTS_DATE_RANGE_KEYS),
92100
licensed: z.boolean(),
93101
granularity: z.enum(['hour', 'day', 'week']),
94102
})

packages/cli/src/modules/insights/__tests__/insights.controller.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Container } from '@n8n/di';
2+
import { mock } from 'jest-mock-extended';
23

4+
import type { AuthenticatedRequest } from '@/requests';
35
import { mockInstance } from '@test/mocking';
46
import * as testDb from '@test-integration/test-db';
57

@@ -30,7 +32,10 @@ describe('InsightsController', () => {
3032
insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]);
3133

3234
// ACT
33-
const response = await controller.getInsightsSummary();
35+
const response = await controller.getInsightsSummary(
36+
mock<AuthenticatedRequest>(),
37+
mock<Response>(),
38+
);
3439

3540
// ASSERT
3641
expect(response).toEqual({
@@ -52,7 +57,10 @@ describe('InsightsController', () => {
5257
]);
5358

5459
// ACT
55-
const response = await controller.getInsightsSummary();
60+
const response = await controller.getInsightsSummary(
61+
mock<AuthenticatedRequest>(),
62+
mock<Response>(),
63+
);
5664

5765
// ASSERT
5866
expect(response).toEqual({
@@ -78,7 +86,10 @@ describe('InsightsController', () => {
7886
]);
7987

8088
// ACT
81-
const response = await controller.getInsightsSummary();
89+
const response = await controller.getInsightsSummary(
90+
mock<AuthenticatedRequest>(),
91+
mock<Response>(),
92+
);
8293

8394
// ASSERT
8495
expect(response).toEqual({

packages/cli/src/modules/insights/__tests__/insights.service.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { InsightsDateRange } from '@n8n/api-types';
12
import { Container } from '@n8n/di';
23
import { mock } from 'jest-mock-extended';
34
import { DateTime } from 'luxon';
@@ -517,6 +518,7 @@ describe('getAvailableDateRanges', () => {
517518
{ key: '2weeks', licensed: true, granularity: 'day' },
518519
{ key: 'month', licensed: true, granularity: 'day' },
519520
{ key: 'quarter', licensed: true, granularity: 'week' },
521+
{ key: '6months', licensed: true, granularity: 'week' },
520522
{ key: 'year', licensed: true, granularity: 'week' },
521523
]);
522524
});
@@ -533,6 +535,7 @@ describe('getAvailableDateRanges', () => {
533535
{ key: '2weeks', licensed: true, granularity: 'day' },
534536
{ key: 'month', licensed: true, granularity: 'day' },
535537
{ key: 'quarter', licensed: true, granularity: 'week' },
538+
{ key: '6months', licensed: true, granularity: 'week' },
536539
{ key: 'year', licensed: true, granularity: 'week' },
537540
]);
538541
});
@@ -549,6 +552,7 @@ describe('getAvailableDateRanges', () => {
549552
{ key: '2weeks', licensed: true, granularity: 'day' },
550553
{ key: 'month', licensed: true, granularity: 'day' },
551554
{ key: 'quarter', licensed: false, granularity: 'week' },
555+
{ key: '6months', licensed: false, granularity: 'week' },
552556
{ key: 'year', licensed: false, granularity: 'week' },
553557
]);
554558
});
@@ -565,6 +569,7 @@ describe('getAvailableDateRanges', () => {
565569
{ key: '2weeks', licensed: false, granularity: 'day' },
566570
{ key: 'month', licensed: false, granularity: 'day' },
567571
{ key: 'quarter', licensed: false, granularity: 'week' },
572+
{ key: '6months', licensed: false, granularity: 'week' },
568573
{ key: 'year', licensed: false, granularity: 'week' },
569574
]);
570575
});
@@ -581,7 +586,84 @@ describe('getAvailableDateRanges', () => {
581586
{ key: '2weeks', licensed: true, granularity: 'day' },
582587
{ key: 'month', licensed: true, granularity: 'day' },
583588
{ key: 'quarter', licensed: true, granularity: 'week' },
589+
{ key: '6months', licensed: false, granularity: 'week' },
584590
{ key: 'year', licensed: false, granularity: 'week' },
585591
]);
586592
});
587593
});
594+
595+
describe('getMaxAgeInDaysAndGranularity', () => {
596+
let insightsService: InsightsService;
597+
let licenseMock: jest.Mocked<License>;
598+
599+
beforeAll(() => {
600+
licenseMock = mock<License>();
601+
insightsService = new InsightsService(
602+
mock<InsightsByPeriodRepository>(),
603+
mock<InsightsCompactionService>(),
604+
mock<InsightsCollectionService>(),
605+
licenseMock,
606+
mock<Logger>(),
607+
);
608+
});
609+
610+
test('returns correct maxAgeInDays and granularity for a valid licensed date range', () => {
611+
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
612+
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
613+
614+
const result = insightsService.getMaxAgeInDaysAndGranularity('month');
615+
616+
expect(result).toEqual({
617+
key: 'month',
618+
licensed: true,
619+
granularity: 'day',
620+
maxAgeInDays: 30,
621+
});
622+
});
623+
624+
test('throws an error if the date range is not available', () => {
625+
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
626+
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
627+
628+
expect(() => {
629+
insightsService.getMaxAgeInDaysAndGranularity('invalidKey' as InsightsDateRange['key']);
630+
}).toThrowError('The selected date range is not available');
631+
});
632+
633+
test('throws an error if the date range is not licensed', () => {
634+
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
635+
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
636+
637+
expect(() => {
638+
insightsService.getMaxAgeInDaysAndGranularity('year');
639+
}).toThrowError('The selected date range exceeds the maximum history allowed by your license.');
640+
});
641+
642+
test('returns correct maxAgeInDays and granularity for a valid date range with hourly data disabled', () => {
643+
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
644+
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(false);
645+
646+
const result = insightsService.getMaxAgeInDaysAndGranularity('quarter');
647+
648+
expect(result).toEqual({
649+
key: 'quarter',
650+
licensed: true,
651+
granularity: 'week',
652+
maxAgeInDays: 90,
653+
});
654+
});
655+
656+
test('returns correct maxAgeInDays and granularity for a valid date range with unlimited history', () => {
657+
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
658+
licenseMock.isInsightsHourlyDataEnabled.mockReturnValue(true);
659+
660+
const result = insightsService.getMaxAgeInDaysAndGranularity('day');
661+
662+
expect(result).toEqual({
663+
key: 'day',
664+
licensed: true,
665+
granularity: 'hour',
666+
maxAgeInDays: 1,
667+
});
668+
});
669+
});
Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,39 @@
1-
import { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
1+
import { InsightsDateFilterDto, ListInsightsWorkflowQueryDto } from '@n8n/api-types';
22
import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types';
33
import { Get, GlobalScope, Licensed, Query, RestController } from '@n8n/decorators';
4+
import type { UserError } from 'n8n-workflow';
45

6+
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
57
import { AuthenticatedRequest } from '@/requests';
68

79
import { InsightsService } from './insights.service';
810

911
@RestController('/insights')
1012
export class InsightsController {
11-
private readonly maxAgeInDaysFilteredInsights = 7;
12-
1313
constructor(private readonly insightsService: InsightsService) {}
1414

15+
/**
16+
* This method is used to transform the date range from the request payload into a maximum age in days.
17+
* It throws a ForbiddenError if the date range does not match the license insights max history
18+
*/
19+
private getMaxAgeInDaysAndGranularity(payload: InsightsDateFilterDto) {
20+
try {
21+
return this.insightsService.getMaxAgeInDaysAndGranularity(payload.dateRange ?? 'week');
22+
} catch (error: unknown) {
23+
throw new ForbiddenError((error as UserError).message);
24+
}
25+
}
26+
1527
@Get('/summary')
1628
@GlobalScope('insights:list')
17-
async getInsightsSummary(): Promise<InsightsSummary> {
29+
async getInsightsSummary(
30+
_req: AuthenticatedRequest,
31+
_res: Response,
32+
@Query payload: InsightsDateFilterDto = { dateRange: 'week' },
33+
): Promise<InsightsSummary> {
34+
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
1835
return await this.insightsService.getInsightsSummary({
19-
periodLengthInDays: this.maxAgeInDaysFilteredInsights,
36+
periodLengthInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
2037
});
2138
}
2239

@@ -28,8 +45,11 @@ export class InsightsController {
2845
_res: Response,
2946
@Query payload: ListInsightsWorkflowQueryDto,
3047
): Promise<InsightsByWorkflow> {
48+
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity({
49+
dateRange: payload.dateRange ?? 'week',
50+
});
3151
return await this.insightsService.getInsightsByWorkflow({
32-
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
52+
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
3353
skip: payload.skip,
3454
take: payload.take,
3555
sortBy: payload.sortBy,
@@ -39,10 +59,15 @@ export class InsightsController {
3959
@Get('/by-time')
4060
@GlobalScope('insights:list')
4161
@Licensed('feat:insights:viewDashboard')
42-
async getInsightsByTime(): Promise<InsightsByTime[]> {
62+
async getInsightsByTime(
63+
_req: AuthenticatedRequest,
64+
_res: Response,
65+
@Query payload: InsightsDateFilterDto,
66+
): Promise<InsightsByTime[]> {
67+
const dateRangeAndMaxAgeInDays = this.getMaxAgeInDaysAndGranularity(payload);
4368
return await this.insightsService.getInsightsByTime({
44-
maxAgeInDays: this.maxAgeInDaysFilteredInsights,
45-
periodUnit: 'day',
69+
maxAgeInDays: dateRangeAndMaxAgeInDays.maxAgeInDays,
70+
periodUnit: dateRangeAndMaxAgeInDays.granularity,
4671
});
4772
}
4873
}

packages/cli/src/modules/insights/insights.service.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import type { InsightsSummary } from '@n8n/api-types';
2-
import type { InsightsDateRange } from '@n8n/api-types/src/schemas/insights.schema';
1+
import {
2+
type InsightsSummary,
3+
type InsightsDateRange,
4+
INSIGHTS_DATE_RANGE_KEYS,
5+
} from '@n8n/api-types';
36
import { OnShutdown } from '@n8n/decorators';
47
import { Service } from '@n8n/di';
58
import { Logger } from 'n8n-core';
69
import type { ExecutionLifecycleHooks } from 'n8n-core';
7-
import type { IRun } from 'n8n-workflow';
10+
import { UserError, type IRun } from 'n8n-workflow';
811

912
import { License } from '@/license';
1013

@@ -14,6 +17,16 @@ import { InsightsByPeriodRepository } from './database/repositories/insights-by-
1417
import { InsightsCollectionService } from './insights-collection.service';
1518
import { InsightsCompactionService } from './insights-compaction.service';
1619

20+
const keyRangeToDays: Record<InsightsDateRange['key'], number> = {
21+
day: 1,
22+
week: 7,
23+
'2weeks': 14,
24+
month: 30,
25+
quarter: 90,
26+
'6months': 180,
27+
year: 365,
28+
};
29+
1730
@Service()
1831
export class InsightsService {
1932
constructor(
@@ -181,20 +194,42 @@ export class InsightsService {
181194
});
182195
}
183196

197+
/**
198+
* Returns the available date ranges with their license authorization and time granularity
199+
* when grouped by time.
200+
*/
184201
getAvailableDateRanges(): InsightsDateRange[] {
185202
const maxHistoryInDays =
186203
this.license.getInsightsMaxHistory() === -1
187204
? Number.MAX_SAFE_INTEGER
188205
: this.license.getInsightsMaxHistory();
189206
const isHourlyDateEnabled = this.license.isInsightsHourlyDataEnabled();
190207

191-
return [
192-
{ key: 'day', licensed: isHourlyDateEnabled ?? false, granularity: 'hour' },
193-
{ key: 'week', licensed: maxHistoryInDays >= 7, granularity: 'day' },
194-
{ key: '2weeks', licensed: maxHistoryInDays >= 14, granularity: 'day' },
195-
{ key: 'month', licensed: maxHistoryInDays >= 30, granularity: 'day' },
196-
{ key: 'quarter', licensed: maxHistoryInDays >= 90, granularity: 'week' },
197-
{ key: 'year', licensed: maxHistoryInDays >= 365, granularity: 'week' },
198-
];
208+
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
209+
key,
210+
licensed:
211+
key === 'day' ? (isHourlyDateEnabled ?? false) : maxHistoryInDays >= keyRangeToDays[key],
212+
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
213+
}));
214+
}
215+
216+
getMaxAgeInDaysAndGranularity(
217+
dateRangeKey: InsightsDateRange['key'],
218+
): InsightsDateRange & { maxAgeInDays: number } {
219+
const availableDateRanges = this.getAvailableDateRanges();
220+
221+
const dateRange = availableDateRanges.find((range) => range.key === dateRangeKey);
222+
if (!dateRange) {
223+
// Not supposed to happen if we trust the dateRangeKey type
224+
throw new UserError('The selected date range is not available');
225+
}
226+
227+
if (!dateRange.licensed) {
228+
throw new UserError(
229+
'The selected date range exceeds the maximum history allowed by your license.',
230+
);
231+
}
232+
233+
return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] };
199234
}
200235
}

0 commit comments

Comments
 (0)