diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index a71042048e..3fd35441b8 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -59,3 +59,5 @@ export { CreateFolderDto } from './folders/create-folder.dto'; export { UpdateFolderDto } from './folders/update-folder.dto'; export { DeleteFolderDto } from './folders/delete-folder.dto'; export { ListFolderQueryDto } from './folders/list-folder-query.dto'; + +export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts new file mode 100644 index 0000000000..70455a56ce --- /dev/null +++ b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts @@ -0,0 +1,94 @@ +import { ListInsightsWorkflowQueryDto } from '../list-workflow-query.dto'; + +const DEFAULT_PAGINATION = { skip: 0, take: 10 }; + +describe('ListInsightsWorkflowQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'empty object (no filters)', + request: {}, + parsedResult: DEFAULT_PAGINATION, + }, + { + name: 'valid sortBy', + request: { + sortBy: 'total:asc', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + sortBy: 'total:asc', + }, + }, + { + name: 'valid skip and take', + request: { + skip: '0', + take: '20', + }, + parsedResult: { + skip: 0, + take: 20, + }, + }, + { + name: 'full query parameters', + request: { + skip: '0', + take: '10', + sortBy: 'total:desc', + }, + parsedResult: { + skip: 0, + take: 10, + sortBy: 'total:desc', + }, + }, + ])('should validate $name', ({ request, parsedResult }) => { + const result = ListInsightsWorkflowQueryDto.safeParse(request); + expect(result.success).toBe(true); + if (parsedResult) { + expect(result.data).toMatchObject(parsedResult); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid skip format', + request: { + skip: 'not-a-number', + take: '10', + }, + expectedErrorPath: ['skip'], + }, + { + name: 'invalid take format', + request: { + skip: '0', + take: 'not-a-number', + }, + expectedErrorPath: ['take'], + }, + { + name: 'invalid sortBy value', + request: { + sortBy: 'invalid-value', + }, + expectedErrorPath: ['sortBy'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ListInsightsWorkflowQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + if (Array.isArray(expectedErrorPath)) { + const errorPaths = result.error.issues[0].path; + expect(errorPaths).toContain(expectedErrorPath[0]); + } + } + }); + }); +}); 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 new file mode 100644 index 0000000000..9ed00a6550 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +const VALID_SORT_OPTIONS = [ + 'total:asc', + 'total:desc', + 'succeeded:asc', + 'succeeded:desc', + 'failed:asc', + 'failed:desc', + 'timeSaved:asc', + 'timeSaved:desc', + 'runTime:asc', + 'runTime:desc', + 'averageRunTime:asc', + 'averageRunTime:desc', +] as const; + +// --------------------- +// Parameter Validators +// --------------------- + +// Skip parameter validation +const skipValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 0)) + .refine((val) => !isNaN(val), { + message: 'Skip must be a valid number', + }); + +// Take parameter validation +const takeValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 10)) + .refine((val) => !isNaN(val), { + message: 'Take must be a valid number', + }); + +// SortBy parameter validation +const sortByValidator = z + .enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` }) + .optional(); + +export class ListInsightsWorkflowQueryDto extends Z.class({ + skip: skipValidator, + take: takeValidator, + sortBy: sortByValidator, +}) {} diff --git a/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts index 020b3df540..266fb5562a 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts @@ -1,4 +1,8 @@ -import { insightsSummarySchema } from '../insights.schema'; +import { + insightsByTimeSchema, + insightsByWorkflowSchema, + insightsSummarySchema, +} from '../insights.schema'; describe('insightsSummarySchema', () => { test.each([ @@ -62,3 +66,162 @@ describe('insightsSummarySchema', () => { expect(result.success).toBe(expected); }); }); + +describe('insightsByWorkflowSchema', () => { + test.each([ + { + name: 'valid workflow insights', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + workflowName: 'Test Workflow', + projectId: 'p1', + projectName: 'Test Project', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 0.56, + runTime: 300, + averageRunTime: 30.5, + timeSaved: 50, + }, + ], + }, + expected: true, + }, + { + name: 'wrong data type', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: '100', + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + timeSaved: 50, + }, + ], + }, + expected: false, + }, + { + name: 'missing required field', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + }, + ], + }, + expected: false, + }, + { + name: 'unexpected key', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + timeSaved: 50, + extraKey: 'value', + }, + ], + }, + expected: false, + }, + ])('should validate $name', ({ value, expected }) => { + const result = insightsByWorkflowSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('insightsByTimeSchema', () => { + test.each([ + { + name: 'valid insights by time', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: true, + }, + { + name: 'invalid date format', + value: { + date: '20240325', // Should be a string + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: false, + }, + { + name: 'invalid field type', + value: { + date: 20240325, // Should be a string + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: false, + }, + { + name: 'missing required key', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + }, + }, + expected: false, + }, + { + name: 'unexpected key', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + extraKey: 'value', + }, + }, + expected: false, + }, + ])('should validate $name', ({ value, expected }) => { + const result = insightsByTimeSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/insights.schema.ts b/packages/@n8n/api-types/src/schemas/insights.schema.ts index 170a74abfe..8dda1000ca 100644 --- a/packages/@n8n/api-types/src/schemas/insights.schema.ts +++ b/packages/@n8n/api-types/src/schemas/insights.schema.ts @@ -42,3 +42,44 @@ export const insightsSummaryDataSchemas = { export const insightsSummarySchema = z.object(insightsSummaryDataSchemas).strict(); export type InsightsSummary = z.infer; + +export const insightsByWorkflowDataSchemas = { + count: z.number(), + data: z.array( + z + .object({ + workflowId: z.string(), + workflowName: z.string().optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + total: z.number(), + succeeded: z.number(), + failed: z.number(), + failureRate: z.number(), + runTime: z.number(), + averageRunTime: z.number(), + timeSaved: z.number(), + }) + .strict(), + ), +} as const; + +export const insightsByWorkflowSchema = z.object(insightsByWorkflowDataSchemas).strict(); +export type InsightsByWorkflow = z.infer; + +export const insightsByTimeDataSchemas = { + date: z.string().refine((val) => !isNaN(Date.parse(val)) && new Date(val).toISOString() === val, { + message: 'Invalid date format, must be ISO 8601 format', + }), + values: z + .object({ + total: z.number(), + failureRate: z.number(), + averageRunTime: z.number(), + timeSaved: z.number(), + }) + .strict(), +} as const; + +export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict(); +export type InsightsByTime = z.infer;