diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index c05e466eca..5099a0b526 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -158,6 +158,11 @@ export class WorkflowRepository extends Repository { findManyOptions.order = { updatedAt: 'ASC' }; } + if (options.sortBy) { + const [column, order] = options.sortBy.split(':'); + findManyOptions.order = { [column]: order }; + } + if (relations.length > 0) { findManyOptions.relations = relations; } diff --git a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts index 0e77792a43..3ced89ed49 100644 --- a/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts +++ b/packages/cli/src/middlewares/list-query/__tests__/list-query.test.ts @@ -6,6 +6,8 @@ import { selectListQueryMiddleware } from '@/middlewares/list-query/select'; import type { ListQuery } from '@/requests'; import * as ResponseHelper from '@/response-helper'; +import { sortByQueryMiddleware } from '../sort-by'; + describe('List query middleware', () => { let mockReq: ListQuery.Request; let mockRes: Response; @@ -174,6 +176,84 @@ describe('List query middleware', () => { }); }); + describe('Query sort by', () => { + const validCases: Array<{ name: string; value: ListQuery.Workflow.SortOrder }> = [ + { + name: 'sorting by name asc', + value: 'name:asc', + }, + { + name: 'sorting by name desc', + value: 'name:desc', + }, + { + name: 'sorting by createdAt asc', + value: 'createdAt:asc', + }, + { + name: 'sorting by createdAt desc', + value: 'createdAt:desc', + }, + { + name: 'sorting by updatedAt asc', + value: 'updatedAt:asc', + }, + { + name: 'sorting by updatedAt desc', + value: 'updatedAt:desc', + }, + ]; + + const invalidCases: Array<{ name: string; value: string }> = [ + { + name: 'sorting by invalid column', + value: 'test:asc', + }, + { + name: 'sorting by valid column without order', + value: 'name', + }, + { + name: 'sorting by valid column with invalid order', + value: 'name:test', + }, + ]; + + test.each(validCases)('should succeed validation when $name', async ({ value }) => { + mockReq.query = { + sortBy: value, + }; + + sortByQueryMiddleware(...args); + + expect(mockReq.listQueryOptions).toMatchObject( + expect.objectContaining({ + sortBy: value, + }), + ); + expect(nextFn).toBeCalledTimes(1); + }); + + test.each(invalidCases)('should fail validation when $name', async ({ value }) => { + mockReq.query = { + sortBy: value as ListQuery.Workflow.SortOrder, + }; + + sortByQueryMiddleware(...args); + + expect(sendErrorResponse).toHaveBeenCalledTimes(1); + }); + + test('should not pass sortBy to listQueryOptions if not provided', async () => { + mockReq.query = {}; + + sortByQueryMiddleware(...args); + + expect(mockReq.listQueryOptions).toBeUndefined(); + expect(nextFn).toBeCalledTimes(1); + }); + }); + describe('Combinations', () => { test('should combine filter with select', async () => { mockReq.query = { filter: '{ "name": "My Workflow" }', select: '["name", "id"]' }; diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.sort-by.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.sort-by.dto.ts new file mode 100644 index 0000000000..80a0ca3a74 --- /dev/null +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.sort-by.dto.ts @@ -0,0 +1,22 @@ +import type { ValidatorConstraintInterface, ValidationArguments } from 'class-validator'; +import { IsString, Validate, ValidatorConstraint } from 'class-validator'; + +@ValidatorConstraint({ name: 'WorkflowSortByParameter', async: false }) +export class WorkflowSortByParameter implements ValidatorConstraintInterface { + validate(text: string, _: ValidationArguments) { + const [column, order] = text.split(':'); + if (!column || !order) return false; + + return ['name', 'createdAt', 'updatedAt'].includes(column) && ['asc', 'desc'].includes(order); + } + + defaultMessage(_: ValidationArguments) { + return 'Invalid value for sortBy parameter'; + } +} + +export class WorkflowSorting { + @IsString() + @Validate(WorkflowSortByParameter) + sortBy?: string; +} diff --git a/packages/cli/src/middlewares/list-query/index.ts b/packages/cli/src/middlewares/list-query/index.ts index a766b43bd0..2e4781337a 100644 --- a/packages/cli/src/middlewares/list-query/index.ts +++ b/packages/cli/src/middlewares/list-query/index.ts @@ -1,10 +1,11 @@ -import type { NextFunction, Response } from 'express'; +import { type NextFunction, type Response } from 'express'; import type { ListQuery } from '@/requests'; import { filterListQueryMiddleware } from './filter'; import { paginationListQueryMiddleware } from './pagination'; import { selectListQueryMiddleware } from './select'; +import { sortByQueryMiddleware } from './sort-by'; export type ListQueryMiddleware = ( req: ListQuery.Request, @@ -16,4 +17,5 @@ export const listQueryMiddleware: ListQueryMiddleware[] = [ filterListQueryMiddleware, selectListQueryMiddleware, paginationListQueryMiddleware, + sortByQueryMiddleware, ]; diff --git a/packages/cli/src/middlewares/list-query/sort-by.ts b/packages/cli/src/middlewares/list-query/sort-by.ts new file mode 100644 index 0000000000..01aca505af --- /dev/null +++ b/packages/cli/src/middlewares/list-query/sort-by.ts @@ -0,0 +1,39 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import type { RequestHandler } from 'express'; +import { ApplicationError } from 'n8n-workflow'; + +import type { ListQuery } from '@/requests'; +import * as ResponseHelper from '@/response-helper'; +import { toError } from '@/utils'; + +import { WorkflowSorting } from './dtos/workflow.sort-by.dto'; + +export const sortByQueryMiddleware: RequestHandler = (req: ListQuery.Request, res, next) => { + const { sortBy } = req.query; + + if (!sortBy) return next(); + + let SortBy; + + try { + if (req.baseUrl.endsWith('workflows')) { + SortBy = WorkflowSorting; + } else { + return next(); + } + + const validationResponse = validateSync(plainToInstance(SortBy, { sortBy })); + + if (validationResponse.length) { + const validationError = validationResponse[0]; + throw new ApplicationError(validationError.constraints?.workflowSortBy ?? ''); + } + + req.listQueryOptions = { ...req.listQueryOptions, sortBy }; + + next(); + } catch (maybeError) { + ResponseHelper.sendErrorResponse(res, toError(maybeError)); + } +}; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 9c26f740bb..f57b8b858f 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -62,6 +62,7 @@ export namespace ListQuery { skip?: string; take?: string; select?: string; + sortBy?: string; }; export type Options = { @@ -69,6 +70,7 @@ export namespace ListQuery { select?: Record; skip?: number; take?: number; + sortBy?: string; }; /** @@ -82,6 +84,10 @@ export namespace ListQuery { type SharedField = Partial>; + type SortingField = 'createdAt' | 'updatedAt' | 'name'; + + export type SortOrder = `${SortingField}:asc` | `${SortingField}:desc`; + type OwnedByField = { ownedBy: SlimUser | null; homeProject: SlimProject | null }; export type Plain = BaseFields; diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index e69e172f97..55c34f633c 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -854,6 +854,97 @@ describe('GET /workflows', () => { }); }); }); + + describe('sortBy', () => { + test('should fail when trying to sort by non sortable column', async () => { + await authOwnerAgent.get('/workflows').query('sortBy=nonSortableColumn:asc').expect(500); + }); + + test('should sort by createdAt column', async () => { + await createWorkflow({ name: 'First' }, owner); + await createWorkflow({ name: 'Second' }, owner); + + let response = await authOwnerAgent + .get('/workflows') + .query('sortBy=createdAt:asc') + .expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'First' }), + expect.objectContaining({ name: 'Second' }), + ]), + }); + + response = await authOwnerAgent.get('/workflows').query('sortBy=createdAt:desc').expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'Second' }), + expect.objectContaining({ name: 'First' }), + ]), + }); + }); + + test('should sort by name column', async () => { + await createWorkflow({ name: 'a' }, owner); + await createWorkflow({ name: 'b' }, owner); + + let response; + + response = await authOwnerAgent.get('/workflows').query('sortBy=name:asc').expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'a' }), + expect.objectContaining({ name: 'b' }), + ]), + }); + + response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'b' }), + expect.objectContaining({ name: 'a' }), + ]), + }); + }); + + test('should sort by updatedAt column', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + + await createWorkflow({ name: 'First', updatedAt: futureDate }, owner); + await createWorkflow({ name: 'Second' }, owner); + + let response; + + response = await authOwnerAgent.get('/workflows').query('sortBy=updatedAt:asc').expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'Second' }), + expect.objectContaining({ name: 'First' }), + ]), + }); + + response = await authOwnerAgent.get('/workflows').query('sortBy=name:desc').expect(200); + + expect(response.body).toEqual({ + count: 2, + data: arrayContaining([ + expect.objectContaining({ name: 'First' }), + expect.objectContaining({ name: 'Second' }), + ]), + }); + }); + }); }); describe('PATCH /workflows/:workflowId', () => {