diff --git a/apps/api/src/app/shared/dtos/pagination-request.ts b/apps/api/src/app/shared/dtos/pagination-request.ts index b0bc090f1415..62f007990aa4 100644 --- a/apps/api/src/app/shared/dtos/pagination-request.ts +++ b/apps/api/src/app/shared/dtos/pagination-request.ts @@ -2,10 +2,15 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Max, Min } from 'class-validator'; -export type Constructor = new (...args: any[]) => I; +import { Constructor } from '../types'; + +export interface IPagination { + page?: number; + limit?: number; +} // eslint-disable-next-line @typescript-eslint/naming-convention -export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor { +export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor { class PaginationRequest { @ApiPropertyOptional({ type: Number, diff --git a/apps/api/src/app/shared/dtos/pagination-with-filters-request.ts b/apps/api/src/app/shared/dtos/pagination-with-filters-request.ts new file mode 100644 index 000000000000..cc397c13bd4c --- /dev/null +++ b/apps/api/src/app/shared/dtos/pagination-with-filters-request.ts @@ -0,0 +1,33 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +import { Constructor } from '../types'; +import { IPagination, PaginationRequestDto } from './pagination-request'; + +export interface IPaginationWithFilters extends IPagination { + query?: string; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export function PaginationWithFiltersRequestDto({ + defaultLimit = 10, + maxLimit = 100, + queryDescription, +}: { + defaultLimit: number; + maxLimit: number; + queryDescription: string; +}): Constructor { + class PaginationWithFiltersRequest extends PaginationRequestDto(defaultLimit, maxLimit) { + @ApiPropertyOptional({ + type: String, + required: false, + description: `A query string to filter the results. ${queryDescription}`, + }) + @IsOptional() + @IsString() + query?: string; + } + + return PaginationWithFiltersRequest; +} diff --git a/apps/api/src/app/shared/types.ts b/apps/api/src/app/shared/types.ts new file mode 100644 index 000000000000..1c27831816de --- /dev/null +++ b/apps/api/src/app/shared/types.ts @@ -0,0 +1 @@ +export type Constructor = new (...args: any[]) => I; diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index 1ffd357da11a..79b613f67a3b 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -485,10 +485,10 @@ export class SubscribersController { organizationId: user.organizationId, environmentId: user.environmentId, subscriberId: subscriberId, - page: query.page != null ? parseInt(query.page) : 0, + page: query.page != null ? parseInt(query.page as any) : 0, feedId: feedsQuery, query: { seen: query.seen, read: query.read }, - limit: query.limit != null ? parseInt(query.limit) : 10, + limit: query.limit != null ? parseInt(query.limit as any) : 10, payload: query.payload, }); diff --git a/apps/api/src/app/widgets/widgets.controller.ts b/apps/api/src/app/widgets/widgets.controller.ts index df0ddb709970..afc8595a90aa 100644 --- a/apps/api/src/app/widgets/widgets.controller.ts +++ b/apps/api/src/app/widgets/widgets.controller.ts @@ -123,10 +123,10 @@ export class WidgetsController { organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, - page: query.page != null ? parseInt(query.page) : 0, + page: query.page != null ? parseInt(query.page as any) : 0, feedId: feedsQuery, query: { seen: query.seen, read: query.read }, - limit: query.limit != null ? parseInt(query.limit) : 10, + limit: query.limit != null ? parseInt(query.limit as any) : 10, payload: query.payload, }); diff --git a/apps/api/src/app/workflows/dto/workflows-request.dto.ts b/apps/api/src/app/workflows/dto/workflows-request.dto.ts index 16ee519966fb..2258ab4cf710 100644 --- a/apps/api/src/app/workflows/dto/workflows-request.dto.ts +++ b/apps/api/src/app/workflows/dto/workflows-request.dto.ts @@ -1,3 +1,7 @@ -import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; +import { PaginationWithFiltersRequestDto } from '../../shared/dtos/pagination-with-filters-request'; -export class WorkflowsRequestDto extends PaginationRequestDto(10, 100) {} +export class WorkflowsRequestDto extends PaginationWithFiltersRequestDto({ + defaultLimit: 10, + maxLimit: 100, + queryDescription: 'It allows filtering based on either the name or trigger identifier of the workflow items.', +}) {} diff --git a/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts b/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts index e40b00f0c89d..408450738aa9 100644 --- a/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts +++ b/apps/api/src/app/workflows/e2e/get-notification-templates.e2e.ts @@ -8,6 +8,7 @@ import { FilterPartTypeEnum, StepTypeEnum, TemplateVariableTypeEnum, + TriggerTypeEnum, } from '@novu/shared'; describe('Get workflows - /workflows (GET)', async () => { @@ -123,4 +124,107 @@ describe('Get workflows - /workflows (GET)', async () => { expect(page1Limit3Results.pageSize).to.equal(3); expect(page1Limit3Results.data[2]._id).to.equal(templates[0]._id); }); + + it('should paginate and filter workflows based on the name', async () => { + const promises: Promise[] = []; + const count = 10; + for (let i = 0; i < count; i++) { + promises.push( + notificationTemplateService.createTemplate({ + name: `Pagination Test ${i}`, + }) + ); + } + await Promise.all(promises); + + const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=2&query=Pagination+Test`); + + expect(body.data.length).to.equal(2); + expect(body.totalCount).to.equal(count); + expect(body.page).to.equal(0); + expect(body.pageSize).to.equal(2); + for (let i = 0; i < 2; i++) { + expect(body.data[i].name).to.contain('Pagination Test'); + } + }); + + it('should filter workflows based on the name', async () => { + const promises: Promise[] = []; + const count = 10; + for (let i = 0; i < count; i++) { + promises.push( + notificationTemplateService.createTemplate({ + name: `Test Template ${i}`, + }) + ); + } + await Promise.all(promises); + + const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=Test+Template`); + + expect(body.data.length).to.equal(count); + expect(body.totalCount).to.equal(count); + expect(body.page).to.equal(0); + expect(body.pageSize).to.equal(100); + for (let i = 0; i < count; i++) { + expect(body.data[i].name).to.contain('Test Template'); + } + }); + + it('should filter workflows based on the trigger identifier', async () => { + const promises: Promise[] = []; + const count = 10; + const triggerIdentifier = 'test-trigger-identifier'; + for (let i = 0; i < count; i++) { + promises.push( + notificationTemplateService.createTemplate({ + triggers: [{ identifier: `${triggerIdentifier}-${i}`, type: TriggerTypeEnum.EVENT, variables: [] }], + }) + ); + } + await Promise.all(promises); + + const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=${triggerIdentifier}`); + + expect(body.data.length).to.equal(count); + expect(body.totalCount).to.equal(count); + expect(body.page).to.equal(0); + expect(body.pageSize).to.equal(100); + for (let i = 0; i < count; i++) { + expect(body.data[i].triggers[0].identifier).to.contain(`${triggerIdentifier}`); + } + }); + + it('should filter workflows based on both the name and trigger identifier', async () => { + const promises: Promise[] = []; + const count = 10; + for (let i = 0; i < count; i++) { + if (i % 2 === 0) { + promises.push( + notificationTemplateService.createTemplate({ + name: Math.random() > 0.5 ? `SMS ${i}` : `sms ${i}`, + }) + ); + continue; + } + + promises.push( + notificationTemplateService.createTemplate({ + triggers: [{ identifier: `sms-trigger-${i}`, type: TriggerTypeEnum.EVENT, variables: [] }], + }) + ); + } + await Promise.all(promises); + + const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=sms`); + const nameCount = body.data.filter((i) => i.name.toUpperCase().includes('SMS')).length; + const triggerCount = body.data.filter((i) => i.triggers[0].identifier.includes('sms')).length; + + expect(body.data.length).to.equal(count); + expect(body.totalCount).to.equal(count); + expect(body.page).to.equal(0); + expect(body.pageSize).to.equal(100); + expect(nameCount).to.equal(5); + expect(triggerCount).to.equal(5); + }); }); diff --git a/apps/api/src/app/workflows/notification-template.controller.ts b/apps/api/src/app/workflows/notification-template.controller.ts index 3e030e4609b0..dda14749f5e8 100644 --- a/apps/api/src/app/workflows/notification-template.controller.ts +++ b/apps/api/src/app/workflows/notification-template.controller.ts @@ -62,15 +62,16 @@ export class NotificationTemplateController { @ExternalApiAccessible() getNotificationTemplates( @UserSession() user: IJwtPayload, - @Query() query: WorkflowsRequestDto + @Query() queryParams: WorkflowsRequestDto ): Promise { return this.getNotificationTemplatesUsecase.execute( GetNotificationTemplatesCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, - page: query.page ? query.page : 0, - limit: query.limit ? query.limit : 10, + page: queryParams.page ? queryParams.page : 0, + limit: queryParams.limit ? queryParams.limit : 10, + query: queryParams.query, }) ); } diff --git a/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.command.ts b/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.command.ts index 14ac69be6624..7b1f645336ce 100644 --- a/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.command.ts +++ b/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.command.ts @@ -1,4 +1,4 @@ -import { IsNumber } from 'class-validator'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -13,4 +13,8 @@ export class GetNotificationTemplatesCommand extends EnvironmentWithUserCommand @IsNumber() limit: number; + + @IsOptional() + @IsString() + query?: string; } diff --git a/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.usecase.ts b/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.usecase.ts index da80367d77bc..08012bf1d8c6 100644 --- a/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.usecase.ts +++ b/apps/api/src/app/workflows/usecases/get-notification-templates/get-notification-templates.usecase.ts @@ -22,7 +22,8 @@ export class GetNotificationTemplates { command.organizationId, command.environmentId, command.page * command.limit, - command.limit + command.limit, + command.query ); const workflows = await this.updateHasActiveIntegrationFlag(list, command); diff --git a/apps/api/src/app/workflows/workflow.controller.ts b/apps/api/src/app/workflows/workflow.controller.ts index bb78df8820c4..c2f139fc5a48 100644 --- a/apps/api/src/app/workflows/workflow.controller.ts +++ b/apps/api/src/app/workflows/workflow.controller.ts @@ -68,14 +68,18 @@ export class WorkflowController { description: `Workflows were previously named notification templates`, }) @ExternalApiAccessible() - getWorkflows(@UserSession() user: IJwtPayload, @Query() query: WorkflowsRequestDto): Promise { + getWorkflows( + @UserSession() user: IJwtPayload, + @Query() queryParams: WorkflowsRequestDto + ): Promise { return this.getWorkflowsUsecase.execute( GetNotificationTemplatesCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, - page: query.page ? query.page : 0, - limit: query.limit ? query.limit : 10, + page: queryParams.page ? queryParams.page : 0, + limit: queryParams.limit ? queryParams.limit : 10, + query: queryParams.query, }) ); } diff --git a/libs/dal/src/repositories/notification-template/notification-template.repository.ts b/libs/dal/src/repositories/notification-template/notification-template.repository.ts index 23707107c91e..9bc69f81f349 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.repository.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.repository.ts @@ -165,15 +165,31 @@ export class NotificationTemplateRepository extends BaseRepository< return { totalCount: totalItemsCount, data: this.mapEntities(items) }; } - async getList(organizationId: string, environmentId: string, skip = 0, limit = 10) { - const totalItemsCount = await this.count({ _environmentId: environmentId }); + async getList(organizationId: string, environmentId: string, skip = 0, limit = 10, query?: string) { + let searchQuery: FilterQuery = {}; + if (query) { + searchQuery = { + $or: [ + { name: { $regex: regExpEscape(query), $options: 'i' } }, + { 'triggers.identifier': { $regex: regExpEscape(query), $options: 'i' } }, + ], + }; + } + + const totalItemsCount = await this.count({ + _environmentId: environmentId, + ...searchQuery, + }); const requestQuery: NotificationTemplateQuery = { _environmentId: environmentId, _organizationId: organizationId, }; - const items = await this.MongooseModel.find(requestQuery) + const items = await this.MongooseModel.find({ + ...requestQuery, + ...searchQuery, + }) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) @@ -218,3 +234,7 @@ export class NotificationTemplateRepository extends BaseRepository< return process.env.BLUEPRINT_CREATOR; } } + +function regExpEscape(literalString: string): string { + return literalString.replace(/[-[\]{}()*+!<=:?./\\^$|#\s,]/g, '\\$&'); +} diff --git a/libs/dal/src/repositories/notification-template/notification-template.schema.ts b/libs/dal/src/repositories/notification-template/notification-template.schema.ts index 529de729b997..21a91c4b7bc7 100644 --- a/libs/dal/src/repositories/notification-template/notification-template.schema.ts +++ b/libs/dal/src/repositories/notification-template/notification-template.schema.ts @@ -240,6 +240,11 @@ notificationTemplateSchema.index({ 'triggers.identifier': 1, }); +notificationTemplateSchema.index({ + _environmentId: 1, + name: 1, +}); + notificationTemplateSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); // eslint-disable-next-line @typescript-eslint/naming-convention