From 94ef2559366ae580b0d36c712c3de4a250ecaac4 Mon Sep 17 00:00:00 2001 From: George Desipris <73396808+desiprisg@users.noreply.github.com> Date: Tue, 28 Jan 2025 10:02:37 +0200 Subject: [PATCH] feat(api-service,dashboard): New subscribers page and api (#7525) --- apps/api/src/app.module.ts | 2 + .../subscriber.controller.e2e.ts | 364 ++++++++++++++++++ .../subscribers-v2/subscriber.controller.ts | 39 ++ .../app/subscribers-v2/subscriber.module.ts | 12 + .../list-subscribers.command.ts | 29 ++ .../list-subscribers.usecase.ts | 45 +++ ...rch-by-external-subscriber-ids.use-case.ts | 4 +- apps/dashboard/src/api/subscribers.ts | 44 +++ .../components/activity/activity-filters.tsx | 10 +- .../components/activity/activity-table.tsx | 14 +- .../src/components/cursor-pagination.tsx | 49 +++ .../icons/add-subscriber-illustration.tsx | 25 ++ .../src/components/primitives/table.tsx | 12 +- .../side-navigation/side-navigation.tsx | 30 +- .../subscribers/subscriber-list-blank.tsx | 31 ++ .../subscriber-list-no-results.tsx | 3 + .../subscribers/subscriber-list.tsx | 178 +++++++++ .../components/subscribers/subscriber-row.tsx | 150 ++++++++ .../subscribers/subscribers-filters.tsx | 124 ++++++ .../components/time-display-hover-card.tsx | 38 +- .../src/components/workflow-list.tsx | 12 +- .../dashboard/src/components/workflow-row.tsx | 7 +- .../src/hooks/use-activity-url-state.ts | 6 +- .../dashboard/src/hooks/use-billing-portal.ts | 4 +- .../src/hooks/use-checkout-session.ts | 6 +- .../hooks/use-fetch-bridge-health-check.ts | 11 +- .../src/hooks/use-fetch-subscribers.ts | 63 +++ .../src/hooks/use-fetch-subscription.ts | 11 +- .../src/hooks/use-fetch-workflows.ts | 5 +- .../src/hooks/use-subscribers-url-state.ts | 169 ++++++++ apps/dashboard/src/main.tsx | 5 + apps/dashboard/src/pages/subscribers.tsx | 23 ++ apps/dashboard/src/pages/workflows.tsx | 8 +- apps/dashboard/src/routes/root.tsx | 36 +- apps/dashboard/src/utils/format-date.ts | 13 + apps/dashboard/src/utils/parse-page-param.ts | 7 + apps/dashboard/src/utils/query-keys.ts | 1 + apps/dashboard/src/utils/routes.ts | 1 + apps/dashboard/src/utils/telemetry.ts | 1 + .../src/commands/project.command.ts | 18 + libs/dal/src/repositories/base-repository.ts | 149 +++++++ .../subscriber/subscriber.repository.ts | 80 +++- .../subscriber/subscriber.schema.ts | 28 ++ packages/js/scripts/size-limit.mjs | 4 +- packages/shared/src/dto/subscriber/index.ts | 1 + .../dto/subscriber/list-subscribers.dto.ts | 30 ++ .../src/dto/subscriber/subscriber.dto.ts | 4 + packages/shared/src/types/feature-flags.ts | 1 + 48 files changed, 1815 insertions(+), 92 deletions(-) create mode 100644 apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts create mode 100644 apps/api/src/app/subscribers-v2/subscriber.controller.ts create mode 100644 apps/api/src/app/subscribers-v2/subscriber.module.ts create mode 100644 apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts create mode 100644 apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts create mode 100644 apps/dashboard/src/api/subscribers.ts create mode 100644 apps/dashboard/src/components/cursor-pagination.tsx create mode 100644 apps/dashboard/src/components/icons/add-subscriber-illustration.tsx create mode 100644 apps/dashboard/src/components/subscribers/subscriber-list-blank.tsx create mode 100644 apps/dashboard/src/components/subscribers/subscriber-list-no-results.tsx create mode 100644 apps/dashboard/src/components/subscribers/subscriber-list.tsx create mode 100644 apps/dashboard/src/components/subscribers/subscriber-row.tsx create mode 100644 apps/dashboard/src/components/subscribers/subscribers-filters.tsx create mode 100644 apps/dashboard/src/hooks/use-fetch-subscribers.ts create mode 100644 apps/dashboard/src/hooks/use-subscribers-url-state.ts create mode 100644 apps/dashboard/src/pages/subscribers.tsx create mode 100644 apps/dashboard/src/utils/format-date.ts create mode 100644 apps/dashboard/src/utils/parse-page-param.ts create mode 100644 packages/shared/src/dto/subscriber/list-subscribers.dto.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 585fbd3626c..bd5fd74ae3e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -49,6 +49,7 @@ import { WorkflowModule } from './app/workflows-v2/workflow.module'; import { WorkflowModuleV1 } from './app/workflows-v1/workflow-v1.module'; import { EnvironmentsModuleV1 } from './app/environments-v1/environments-v1.module'; import { EnvironmentsModule } from './app/environments-v2/environments.module'; +import { SubscriberModule } from './app/subscribers-v2/subscriber.module'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; @@ -97,6 +98,7 @@ const baseModules: Array | Forward IntegrationModule, ChangeModule, SubscribersModule, + SubscriberModule, FeedsModule, LayoutsModule, MessagesModule, diff --git a/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts b/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts new file mode 100644 index 00000000000..9caa71a0f38 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts @@ -0,0 +1,364 @@ +import { randomBytes } from 'crypto'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; +import { DirectionEnum } from '@novu/shared'; + +const v2Prefix = '/v2'; +let session: UserSession; + +describe('List Subscriber Permutations', () => { + it('should not return subscribers if not matching search params', async () => { + await createSubscriberAndValidate('XYZ'); + await createSubscriberAndValidate('XYZ2'); + const subscribers = await getAllAndValidate({ + searchParams: { email: 'nonexistent@email.com' }, + expectedTotalResults: 0, + expectedArraySize: 0, + }); + expect(subscribers).to.be.empty; + }); + + it('should return results without any search params', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + await getAllAndValidate({ + limit: 15, + expectedTotalResults: 10, + expectedArraySize: 10, + }); + }); + + it('should page subscribers without overlap using cursors', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 5, + }); + + const secondPage = await getListSubscribers({ + after: firstPage.next, + limit: 5, + }); + + const idsDeduplicated = buildIdSet(firstPage.subscribers, secondPage.subscribers); + expect(idsDeduplicated.size).to.be.equal(10); + }); +}); + +describe('List Subscriber Search Filters', () => { + it('should find subscriber by email', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { email: `test-${uuid}@subscriber` }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].email).to.contain(uuid); + }); + + it('should find subscriber by phone', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { phone: '1234567' }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].phone).to.equal('+1234567890'); + }); + + it('should find subscriber by full name', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { name: `Test ${uuid} Subscriber` }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].firstName).to.equal(`Test ${uuid}`); + expect(subscribers[0].lastName).to.equal('Subscriber'); + }); + + it('should find subscriber by subscriberId', async () => { + const uuid = generateUUID(); + await createSubscriberAndValidate(uuid); + + const subscribers = await getAllAndValidate({ + searchParams: { subscriberId: `test-subscriber-${uuid}` }, + expectedTotalResults: 1, + expectedArraySize: 1, + }); + + expect(subscribers[0].subscriberId).to.equal(`test-subscriber-${uuid}`); + }); +}); + +describe('List Subscriber Cursor Pagination', () => { + it('should paginate forward using after cursor', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 5, + }); + + const secondPage = await getListSubscribers({ + after: firstPage.next, + limit: 5, + }); + + expect(firstPage.subscribers).to.have.lengthOf(5); + expect(secondPage.subscribers).to.have.lengthOf(5); + expect(firstPage.next).to.exist; + expect(secondPage.previous).to.exist; + + const idsDeduplicated = buildIdSet(firstPage.subscribers, secondPage.subscribers); + expect(idsDeduplicated.size).to.equal(10); + }); + + it('should paginate backward using before cursor', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 5, + }); + + const secondPage = await getListSubscribers({ + after: firstPage.next, + limit: 5, + }); + + const previousPage = await getListSubscribers({ + before: secondPage.previous, + limit: 5, + }); + + expect(previousPage.subscribers).to.have.lengthOf(5); + expect(previousPage.next).to.exist; + expect(previousPage.subscribers).to.deep.equal(firstPage.subscribers); + }); + + it('should handle pagination with limit=1', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + limit: 1, + }); + + expect(firstPage.subscribers).to.have.lengthOf(1); + expect(firstPage.next).to.exist; + expect(firstPage.previous).to.not.exist; + }); + + it('should return empty array when no more results after cursor', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const allResults = await getListSubscribers({ + limit: 10, + }); + + const nextPage = await getListSubscribers({ + after: allResults.next, + limit: 5, + }); + + expect(nextPage.subscribers).to.have.lengthOf(0); + expect(nextPage.next).to.not.exist; + expect(nextPage.previous).to.exist; + }); +}); + +describe('List Subscriber Sorting', () => { + it('should sort subscribers by createdAt in ascending order', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: DirectionEnum.ASC, + limit: 10, + }); + + const timestamps = response.subscribers.map((sub) => new Date(sub.createdAt).getTime()); + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + expect(timestamps).to.deep.equal(sortedTimestamps); + }); + + it('should sort subscribers by createdAt in descending order', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: DirectionEnum.DESC, + limit: 10, + }); + + const timestamps = response.subscribers.map((sub) => new Date(sub.createdAt).getTime()); + const sortedTimestamps = [...timestamps].sort((a, b) => b - a); + expect(timestamps).to.deep.equal(sortedTimestamps); + }); + + it('should sort subscribers by subscriberId', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const response = await getListSubscribers({ + sortBy: 'subscriberId', + sortDirection: DirectionEnum.ASC, + limit: 10, + }); + + const ids = response.subscribers.map((sub) => sub.subscriberId); + const sortedIds = [...ids].sort(); + expect(ids).to.deep.equal(sortedIds); + }); + + it('should maintain sort order across pages', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + + const firstPage = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: DirectionEnum.ASC, + limit: 5, + }); + + const secondPage = await getListSubscribers({ + sortBy: 'createdAt', + sortDirection: DirectionEnum.ASC, + after: firstPage.next, + limit: 5, + }); + + const allTimestamps = [ + ...firstPage.subscribers.map((sub) => new Date(sub.createdAt).getTime()), + ...secondPage.subscribers.map((sub) => new Date(sub.createdAt).getTime()), + ]; + + const sortedTimestamps = [...allTimestamps].sort((a, b) => a - b); + expect(allTimestamps).to.deep.equal(sortedTimestamps); + }); +}); + +async function createSubscriberAndValidate(nameSuffix: string = '') { + const createSubscriberDto = { + subscriberId: `test-subscriber-${nameSuffix}`, + firstName: `Test ${nameSuffix}`, + lastName: 'Subscriber', + email: `test-${nameSuffix}@subscriber.com`, + phone: '+1234567890', + }; + + const res = await session.testAgent.post(`/v1/subscribers`).send(createSubscriberDto); + expect(res.status).to.equal(201); + + const subscriber = res.body.data; + validateCreateSubscriberResponse(subscriber, createSubscriberDto); + + return subscriber; +} + +async function create10Subscribers(uuid: string) { + for (let i = 0; i < 10; i += 1) { + await createSubscriberAndValidate(`${uuid}-${i}`); + } +} + +interface IListSubscribersQuery { + email?: string; + phone?: string; + name?: string; + subscriberId?: string; + after?: string; + before?: string; + limit?: number; + sortBy?: string; + sortDirection?: DirectionEnum; +} + +async function getListSubscribers(params: IListSubscribersQuery = {}) { + const res = await session.testAgent.get(`${v2Prefix}/subscribers`).query(params); + expect(res.status).to.equal(200); + + return res.body.data; +} + +interface IAllAndValidate { + msgPrefix?: string; + searchParams?: IListSubscribersQuery; + limit?: number; + expectedTotalResults: number; + expectedArraySize: number; +} + +async function getAllAndValidate({ + msgPrefix = '', + searchParams = {}, + limit = 15, + expectedTotalResults, + expectedArraySize, +}: IAllAndValidate) { + const listResponse = await getListSubscribers({ + ...searchParams, + limit, + }); + const summary = buildLogMsg( + { + msgPrefix, + searchParams, + expectedTotalResults, + expectedArraySize, + }, + listResponse + ); + + expect(listResponse.subscribers).to.be.an('array', summary); + expect(listResponse.subscribers).lengthOf(expectedArraySize, `subscribers length ${summary}`); + + return listResponse.subscribers; +} + +function buildLogMsg(params: IAllAndValidate, listResponse: any): string { + return `Log - msgPrefix: ${params.msgPrefix}, + searchParams: ${JSON.stringify(params.searchParams || 'Not specified', null, 2)}, + expectedTotalResults: ${params.expectedTotalResults ?? 'Not specified'}, + expectedArraySize: ${params.expectedArraySize ?? 'Not specified'} + response: + ${JSON.stringify(listResponse || 'Not specified', null, 2)}`; +} + +function buildIdSet(listResponse1: any[], listResponse2: any[]) { + return new Set([...extractIDs(listResponse1), ...extractIDs(listResponse2)]); +} + +function extractIDs(subscribers: any[]) { + return subscribers.map((subscriber) => subscriber._id); +} + +function generateUUID(): string { + const randomHex = () => randomBytes(2).toString('hex'); + + return `${randomHex()}${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}${randomHex()}${randomHex()}`; +} + +function validateCreateSubscriberResponse(subscriber: any, createDto: any) { + expect(subscriber).to.be.ok; + expect(subscriber._id).to.be.ok; + expect(subscriber.subscriberId).to.equal(createDto.subscriberId); + expect(subscriber.firstName).to.equal(createDto.firstName); + expect(subscriber.lastName).to.equal(createDto.lastName); + expect(subscriber.email).to.equal(createDto.email); + expect(subscriber.phone).to.equal(createDto.phone); +} diff --git a/apps/api/src/app/subscribers-v2/subscriber.controller.ts b/apps/api/src/app/subscribers-v2/subscriber.controller.ts new file mode 100644 index 00000000000..eb64e8511c8 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.ts @@ -0,0 +1,39 @@ +import { ClassSerializerInterceptor, Controller, Get, Query, UseInterceptors } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { UserSession } from '@novu/application-generic'; +import { DirectionEnum, IListSubscribersRequestDto, IListSubscribersResponseDto, UserSessionData } from '@novu/shared'; +import { ApiCommonResponses } from '../shared/framework/response.decorator'; + +import { UserAuthentication } from '../shared/framework/swagger/api.key.security'; +import { ListSubscribersCommand } from './usecases/list-subscribers/list-subscribers.command'; +import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; + +@Controller({ path: '/subscribers', version: '2' }) +@UseInterceptors(ClassSerializerInterceptor) +@ApiTags('Subscribers') +@ApiCommonResponses() +export class SubscriberController { + constructor(private listSubscribersUsecase: ListSubscribersUseCase) {} + + @Get('') + @UserAuthentication() + async getSubscribers( + @UserSession() user: UserSessionData, + @Query() query: IListSubscribersRequestDto + ): Promise { + return await this.listSubscribersUsecase.execute( + ListSubscribersCommand.create({ + user, + limit: Number(query.limit || '10'), + after: query.after, + before: query.before, + orderDirection: query.orderDirection || DirectionEnum.DESC, + orderBy: query.orderBy || 'createdAt', + email: query.email, + phone: query.phone, + subscriberId: query.subscriberId, + name: query.name, + }) + ); + } +} diff --git a/apps/api/src/app/subscribers-v2/subscriber.module.ts b/apps/api/src/app/subscribers-v2/subscriber.module.ts new file mode 100644 index 00000000000..484285d6a46 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SubscriberRepository } from '@novu/dal'; +import { SubscriberController } from './subscriber.controller'; +import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; + +const USE_CASES = [ListSubscribersUseCase]; + +@Module({ + controllers: [SubscriberController], + providers: [...USE_CASES, SubscriberRepository], +}) +export class SubscriberModule {} diff --git a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts new file mode 100644 index 00000000000..9d56f9c2aaa --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts @@ -0,0 +1,29 @@ +import { DirectionEnum } from '@novu/shared'; +import { IsOptional, IsString, IsEnum } from 'class-validator'; +import { CursorBasedPaginatedCommand } from '@novu/application-generic'; + +export class ListSubscribersCommand extends CursorBasedPaginatedCommand { + @IsEnum(DirectionEnum) + @IsOptional() + orderDirection: DirectionEnum = DirectionEnum.DESC; + + @IsEnum(['updatedAt', 'createdAt']) + @IsOptional() + orderBy: 'updatedAt' | 'createdAt' = 'createdAt'; + + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + phone?: string; + + @IsString() + @IsOptional() + subscriberId?: string; + + @IsString() + @IsOptional() + name?: string; +} diff --git a/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts new file mode 100644 index 00000000000..e0286503600 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { SubscriberRepository } from '@novu/dal'; +import { IListSubscribersResponseDto } from '@novu/shared'; +import { ListSubscribersCommand } from './list-subscribers.command'; + +@Injectable() +export class ListSubscribersUseCase { + constructor(private subscriberRepository: SubscriberRepository) {} + + @InstrumentUsecase() + async execute(command: ListSubscribersCommand): Promise { + const pagination = await this.subscriberRepository.listSubscribers({ + after: command.after, + before: command.before, + limit: command.limit, + sortDirection: command.orderDirection, + sortBy: command.orderBy, + email: command.email, + name: command.name, + phone: command.phone, + subscriberId: command.subscriberId, + environmentId: command.user.environmentId, + organizationId: command.user.organizationId, + }); + + return { + subscribers: pagination.subscribers.map((subscriber) => ({ + _id: subscriber._id, + firstName: subscriber.firstName, + lastName: subscriber.lastName, + email: subscriber.email, + phone: subscriber.phone, + subscriberId: subscriber.subscriberId, + createdAt: subscriber.createdAt, + updatedAt: subscriber.updatedAt, + _environmentId: subscriber._environmentId, + _organizationId: subscriber._organizationId, + deleted: subscriber.deleted, + })), + next: pagination.next, + previous: pagination.previous, + }; + } +} diff --git a/apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.use-case.ts b/apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.use-case.ts index a7ac3a7a41c..937c3396078 100644 --- a/apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.use-case.ts +++ b/apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.use-case.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { IExternalSubscribersEntity, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { SubscriberDto } from '@novu/shared'; @@ -25,7 +25,7 @@ export class SearchByExternalSubscriberIds { } private mapFromEntity(entity: SubscriberEntity): SubscriberDto { - const { _id, createdAt, updatedAt, ...rest } = entity; + const { _id, ...rest } = entity; return { ...rest, diff --git a/apps/dashboard/src/api/subscribers.ts b/apps/dashboard/src/api/subscribers.ts new file mode 100644 index 00000000000..d760739388a --- /dev/null +++ b/apps/dashboard/src/api/subscribers.ts @@ -0,0 +1,44 @@ +import type { DirectionEnum, IEnvironment, IListSubscribersResponseDto } from '@novu/shared'; +import { getV2 } from './api.client'; + +export const getSubscribers = async ({ + environment, + after, + before, + limit, + email, + orderDirection, + orderBy, + phone, + subscriberId, + name, +}: { + environment: IEnvironment; + after?: string; + before?: string; + limit: number; + email?: string; + phone?: string; + subscriberId?: string; + name?: string; + orderDirection?: DirectionEnum; + orderBy?: string; +}): Promise => { + const params = new URLSearchParams({ + limit: limit.toString(), + ...(after && { after }), + ...(before && { before }), + ...(orderDirection && { orderDirection }), + ...(email && { email }), + ...(phone && { phone }), + ...(subscriberId && { subscriberId }), + ...(name && { name }), + ...(orderBy && { orderBy }), + ...(orderDirection && { orderDirection }), + }); + + const { data } = await getV2<{ data: IListSubscribersResponseDto }>(`/subscribers?${params}`, { + environment, + }); + return data; +}; diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 463e521f77a..95c564199ac 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo } from 'react'; import { ChannelTypeEnum } from '@novu/shared'; -import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; -import { useForm } from 'react-hook-form'; -import { Form, FormItem, FormField } from '../primitives/form/form'; -import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; import { CalendarIcon } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; import { Button } from '../primitives/button'; +import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; +import { Form, FormField, FormItem } from '../primitives/form/form'; export type ActivityFilters = { onFiltersChange: (filters: ActivityFiltersData) => void; diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index f7afd1ca8b4..78de1edb006 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -2,9 +2,10 @@ import { ActivityFilters } from '@/api/activity'; import { Skeleton } from '@/components/primitives/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/primitives/table'; import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; +import { formatDate } from '@/utils/format-date'; +import { parsePageParam } from '@/utils/parse-page-param'; import { cn } from '@/utils/ui'; import { ISubscriber } from '@novu/shared'; -import { format } from 'date-fns'; import { AnimatePresence, motion } from 'motion/react'; import { useEffect } from 'react'; import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; @@ -142,10 +143,6 @@ export function ActivityTable({ ); } -function formatDate(date: string) { - return format(new Date(date), 'MMM d yyyy, HH:mm:ss'); -} - function SkeletonRow() { return ( @@ -186,10 +183,3 @@ function getSubscriberDisplay(subscriber?: Pick void; + onPrevious: () => void; + onFirst: () => void; +} + +export function CursorPagination({ hasNext, hasPrevious, onNext, onPrevious, onFirst }: CursorPaginationProps) { + return ( +
+
+ + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/icons/add-subscriber-illustration.tsx b/apps/dashboard/src/components/icons/add-subscriber-illustration.tsx new file mode 100644 index 00000000000..87734bcde54 --- /dev/null +++ b/apps/dashboard/src/components/icons/add-subscriber-illustration.tsx @@ -0,0 +1,25 @@ +import type { HTMLAttributes } from 'react'; + +type AddSubscriberIllustrationProps = HTMLAttributes; +export const AddSubscriberIllustration = (props: AddSubscriberIllustrationProps) => { + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/dashboard/src/components/primitives/table.tsx b/apps/dashboard/src/components/primitives/table.tsx index 035fe8954db..a2406589f9f 100644 --- a/apps/dashboard/src/components/primitives/table.tsx +++ b/apps/dashboard/src/components/primitives/table.tsx @@ -1,4 +1,5 @@ import { cn } from '@/utils/ui'; +import { DirectionEnum } from '@novu/shared'; import { cva } from 'class-variance-authority'; import * as React from 'react'; import { RiArrowDownSFill, RiArrowUpSFill, RiExpandUpDownFill } from 'react-icons/ri'; @@ -10,9 +11,10 @@ interface TableProps extends React.HTMLAttributes { loadingRow?: React.ReactNode; } +export type TableHeadSortDirection = DirectionEnum | false; interface TableHeadProps extends React.ThHTMLAttributes { sortable?: boolean; - sortDirection?: 'asc' | 'desc' | false; + sortDirection?: TableHeadSortDirection; onSort?: () => void; } @@ -34,7 +36,7 @@ const Table = React.forwardRef( ({ className, containerClassname, isLoading, loadingRowsCount = 5, loadingRow, children, ...props }, ref) => (
@@ -60,7 +62,7 @@ Table.displayName = 'Table'; const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( )); @@ -73,8 +75,8 @@ const TableHead = React.forwardRef( {children} {sortable && ( <> - {sortDirection === 'asc' && } - {sortDirection === 'desc' && } + {sortDirection === DirectionEnum.ASC && } + {sortDirection === DirectionEnum.DESC && } {!sortDirection && } )} diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 01ed5d097dc..fd1290c9b86 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -1,9 +1,12 @@ import { Badge } from '@/components/primitives/badge'; import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { SubscribersStayTunedModal } from '@/components/side-navigation/subscribers-stay-tuned-modal'; import { useEnvironment } from '@/context/environment/hooks'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useTelemetry } from '@/hooks/use-telemetry'; import { buildRoute, ROUTES } from '@/utils/routes'; import { TelemetryEvent } from '@/utils/telemetry'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; import * as Sentry from '@sentry/react'; import { ReactNode } from 'react'; import { @@ -24,7 +27,6 @@ import { FreeTrialCard } from './free-trial-card'; import { GettingStartedMenuItem } from './getting-started-menu-item'; import { NavigationLink } from './navigation-link'; import { OrganizationDropdown } from './organization-dropdown'; -import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; const NavigationGroup = ({ children, label }: { children: ReactNode; label?: string }) => { return ( @@ -38,6 +40,7 @@ const NavigationGroup = ({ children, label }: { children: ReactNode; label?: str export const SideNavigation = () => { const { subscription, daysLeft, isLoading: isLoadingSubscription } = useFetchSubscription(); const isFreeTrialActive = subscription?.trial.isActive; + const isSubscribersPageEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_SUBSCRIBERS_PAGE_ENABLED); const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); const track = useTelemetry(); @@ -73,14 +76,23 @@ export const SideNavigation = () => { Workflows - - track(TelemetryEvent.SUBSCRIBERS_LINK_CLICKED)}> - - - Subscribers - - - + {isSubscribersPageEnabled ? ( + + + Subscribers + + ) : ( + + track(TelemetryEvent.SUBSCRIBERS_LINK_CLICKED)}> + + + Subscribers + + + + )} { + return ( +
+ +
+ No subscribers yet +

+ A subscriber represents a notification recipient. Subscribers are created automatically while triggering a + workflow or can be imported via the API. +

+
+ +
+ + + Import via API + + + + {/* */} +
+
+ ); +}; diff --git a/apps/dashboard/src/components/subscribers/subscriber-list-no-results.tsx b/apps/dashboard/src/components/subscribers/subscriber-list-no-results.tsx new file mode 100644 index 00000000000..8f69f1d57a6 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscriber-list-no-results.tsx @@ -0,0 +1,3 @@ +export const SubscriberListNoResults = () => { + return
No subscribers found
; +}; diff --git a/apps/dashboard/src/components/subscribers/subscriber-list.tsx b/apps/dashboard/src/components/subscribers/subscriber-list.tsx new file mode 100644 index 00000000000..30f7e140465 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscriber-list.tsx @@ -0,0 +1,178 @@ +import { CursorPagination } from '@/components/cursor-pagination'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/primitives/table'; +import { SubscriberListBlank } from '@/components/subscribers/subscriber-list-blank'; +import { SubscriberListNoResults } from '@/components/subscribers/subscriber-list-no-results'; +import { SubscriberRow, SubscriberRowSkeleton } from '@/components/subscribers/subscriber-row'; +import { SubscribersFilters } from '@/components/subscribers/subscribers-filters'; +import { useFetchSubscribers } from '@/hooks/use-fetch-subscribers'; +import { + SubscribersFilter, + SubscribersSortableColumn, + SubscribersUrlState, + useSubscribersUrlState, +} from '@/hooks/use-subscribers-url-state'; +import { cn } from '@/utils/ui'; +import { DirectionEnum } from '@novu/shared'; +import { HTMLAttributes, useEffect, useState } from 'react'; + +type SubscriberListFiltersProps = HTMLAttributes & + Pick; + +const SubscriberListWrapper = (props: SubscriberListFiltersProps) => { + const { className, children, filterValues, handleFiltersChange, resetFilters, ...rest } = props; + + return ( +
+ + {children} +
+ ); +}; + +type SubscriberListTableProps = HTMLAttributes & { + toggleSort: ReturnType['toggleSort']; + orderBy?: SubscribersSortableColumn; + orderDirection?: DirectionEnum; +}; +const SubscriberListTable = (props: SubscriberListTableProps) => { + const { children, orderBy, orderDirection, toggleSort, ...rest } = props; + return ( + + + + Subscriber + Email address + Phone number + toggleSort('createdAt')} + > + Created at + + toggleSort('updatedAt')} + > + Updated at + + + + + {children} +
+ ); +}; + +type SubscriberListProps = HTMLAttributes; + +export const SubscriberList = (props: SubscriberListProps) => { + const { className, ...rest } = props; + const [nextPageAfter, setNextPageAfter] = useState(undefined); + const [previousPageBefore, setPreviousPageBefore] = useState(undefined); + const { filterValues, handleFiltersChange, toggleSort, resetFilters, handleNext, handlePrevious, handleFirst } = + useSubscribersUrlState({ + debounceMs: 300, + after: nextPageAfter, + before: previousPageBefore, + }); + const areFiltersApplied = (Object.keys(filterValues) as (keyof SubscribersFilter)[]).some( + (key) => ['email', 'phone', 'name', 'subscriberId', 'before', 'after'].includes(key) && filterValues[key] !== '' + ); + const limit = 10; + + const { data, isPending } = useFetchSubscribers(filterValues, { + meta: { errorMessage: 'Issue fetching subscribers' }, + }); + + useEffect(() => { + if (data?.next) { + setNextPageAfter(data.next); + } + if (data?.previous) { + setPreviousPageBefore(data.previous); + } + }, [data]); + + if (isPending) { + return ( + + + {new Array(limit).fill(0).map((_, index) => ( + + ))} + + + ); + } + + if (!areFiltersApplied && !data?.subscribers.length) { + return ( + + + + ); + } + + if (!data?.subscribers.length) { + return ( + + + + ); + } + + return ( + + + {data.subscribers.map((subscriber) => ( + + ))} + + + {!!(data.next || data.previous) && ( + + )} + + ); +}; diff --git a/apps/dashboard/src/components/subscribers/subscriber-row.tsx b/apps/dashboard/src/components/subscribers/subscriber-row.tsx new file mode 100644 index 00000000000..a3b0c57966a --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscriber-row.tsx @@ -0,0 +1,150 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/primitives/dropdown-menu'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { TableCell, TableRow } from '@/components/primitives/table'; +import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; +import TruncatedText from '@/components/truncated-text'; +import { useEnvironment } from '@/context/environment/hooks'; +import { formatDateSimple } from '@/utils/format-date'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { cn } from '@/utils/ui'; +import { ISubscriberResponseDto } from '@novu/shared'; +import { ComponentProps } from 'react'; +import { RiFileCopyLine, RiMore2Fill, RiPulseFill } from 'react-icons/ri'; +import { Link } from 'react-router-dom'; +import { Avatar, AvatarFallback, AvatarImage } from '../primitives/avatar'; +import { CompactButton } from '../primitives/button-compact'; +import { CopyButton } from '../primitives/copy-button'; + +type SubscriberRowProps = { + subscriber: ISubscriberResponseDto; +}; + +type SubscriberLinkTableCellProps = ComponentProps & { + subscriber: ISubscriberResponseDto; +}; + +const SubscriberLinkTableCell = (props: SubscriberLinkTableCellProps) => { + const { subscriber, children, className, ...rest } = props; + + return ( + + {children} + + ); +}; + +export const SubscriberRow = ({ subscriber }: SubscriberRowProps) => { + const { currentEnvironment } = useEnvironment(); + + return ( + + +
+ + + + {subscriber.firstName?.[0] || subscriber.email?.[0] || subscriber.subscriberId[0]} + + +
+ + {subscriber.firstName || subscriber.email || subscriber.subscriberId} + +
+ + {subscriber.subscriberId} + + +
+
+
+
+ {subscriber.email || '-'} + {subscriber.phone || '-'} + + + {formatDateSimple(subscriber.createdAt)} + + + + + {formatDateSimple(subscriber.updatedAt)} + + + + + + + + + + { + navigator.clipboard.writeText(subscriber.subscriberId); + }} + > + + Copy identifier + + + + + View activity + + + + + + +
+ ); +}; + +export const SubscriberRowSkeleton = () => { + return ( + + + +
+ + +
+
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/dashboard/src/components/subscribers/subscribers-filters.tsx b/apps/dashboard/src/components/subscribers/subscribers-filters.tsx new file mode 100644 index 00000000000..da4f794a616 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscribers-filters.tsx @@ -0,0 +1,124 @@ +import { defaultSubscribersFilter, SubscribersFilter } from '@/hooks/use-subscribers-url-state'; +import { cn } from '@/utils/ui'; +import { HTMLAttributes, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button } from '../primitives/button'; +import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; +import { Form, FormField, FormItem } from '../primitives/form/form'; + +export type SubscribersFiltersProps = HTMLAttributes & { + onFiltersChange: (filter: SubscribersFilter) => void; + filterValues: SubscribersFilter; + onReset?: () => void; +}; + +export function SubscribersFilters(props: SubscribersFiltersProps) { + const { onFiltersChange, filterValues, onReset, className, ...rest } = props; + + const form = useForm({ + values: { + email: filterValues.email, + phone: filterValues.phone, + name: filterValues.name, + subscriberId: filterValues.subscriberId, + }, + }); + + useEffect(() => { + const subscription = form.watch((value) => { + onFiltersChange(value as SubscribersFilter); + }); + + return () => subscription.unsubscribe(); + }, [form, onFiltersChange]); + + const filterHasValue = useMemo(() => { + return Object.values(form.getValues()).some((value) => value !== ''); + }, [form.getValues()]); + + const handleReset = () => { + form.reset(defaultSubscribersFilter); + onFiltersChange(defaultSubscribersFilter); + onReset?.(); + }; + + return ( +
+ + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + {filterHasValue && ( + + )} + + + ); +} diff --git a/apps/dashboard/src/components/time-display-hover-card.tsx b/apps/dashboard/src/components/time-display-hover-card.tsx index 08ae23c7cce..6ab21b2f839 100644 --- a/apps/dashboard/src/components/time-display-hover-card.tsx +++ b/apps/dashboard/src/components/time-display-hover-card.tsx @@ -1,5 +1,5 @@ +import { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger } from '@/components/primitives/hover-card'; import { formatDistanceToNow } from 'date-fns'; -import { HoverCard, HoverCardContent, HoverCardTrigger } from './primitives/hover-card'; interface TimeDisplayHoverCardProps { date: Date; @@ -33,25 +33,27 @@ export function TimeDisplayHoverCard({ date, children, className }: TimeDisplayH {children} - -
-
Time Details
-
-
- UTC - {utcTime} -
-
- Local - {localTime} -
-
- Relative - {timeAgo} + + +
+
Time Details
+
+
+ UTC + {utcTime} +
+
+ Local + {localTime} +
+
+ Relative + {timeAgo} +
-
- + + ); } diff --git a/apps/dashboard/src/components/workflow-list.tsx b/apps/dashboard/src/components/workflow-list.tsx index 665a876a656..3ea12708b07 100644 --- a/apps/dashboard/src/components/workflow-list.tsx +++ b/apps/dashboard/src/components/workflow-list.tsx @@ -7,11 +7,12 @@ import { TableFooter, TableHead, TableHeader, + TableHeadSortDirection, TableRow, } from '@/components/primitives/table'; import { WorkflowListEmpty } from '@/components/workflow-list-empty'; import { WorkflowRow } from '@/components/workflow-row'; -import { ListWorkflowResponse } from '@novu/shared'; +import { DirectionEnum, ListWorkflowResponse } from '@novu/shared'; import { RiMore2Fill } from 'react-icons/ri'; import { createSearchParams, useLocation, useSearchParams } from 'react-router-dom'; import { ServerErrorPage } from './shared/server-error-page'; @@ -24,7 +25,7 @@ interface WorkflowListProps { isError?: boolean; limit?: number; orderBy?: SortableColumn; - orderDirection?: 'asc' | 'desc'; + orderDirection?: TableHeadSortDirection; hasActiveFilters?: boolean; onClearFilters?: () => void; } @@ -52,7 +53,12 @@ export function WorkflowList({ const offset = parseInt(searchParams.get('offset') || '0'); const toggleSort = (column: SortableColumn) => { - const newDirection = column === orderBy ? (orderDirection === 'desc' ? 'asc' : 'desc') : 'desc'; + const newDirection = + column === orderBy + ? orderDirection === DirectionEnum.DESC + ? DirectionEnum.ASC + : DirectionEnum.DESC + : DirectionEnum.DESC; searchParams.set('orderDirection', newDirection); searchParams.set('orderBy', column); setSearchParams(searchParams); diff --git a/apps/dashboard/src/components/workflow-row.tsx b/apps/dashboard/src/components/workflow-row.tsx index b18c37a013d..fa3bee4a489 100644 --- a/apps/dashboard/src/components/workflow-row.tsx +++ b/apps/dashboard/src/components/workflow-row.tsx @@ -25,6 +25,7 @@ import { useDeleteWorkflow } from '@/hooks/use-delete-workflow'; import { usePatchWorkflow } from '@/hooks/use-patch-workflow'; import { useSyncWorkflow } from '@/hooks/use-sync-workflow'; import { WorkflowOriginEnum, WorkflowStatusEnum } from '@/utils/enums'; +import { formatDateSimple } from '@/utils/format-date'; import { buildRoute, ROUTES } from '@/utils/routes'; import { cn } from '@/utils/ui'; import { IEnvironment, WorkflowListResponseDto } from '@novu/shared'; @@ -219,11 +220,7 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => { - {new Date(workflow.updatedAt).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - })} + {formatDateSimple(workflow.updatedAt)} diff --git a/apps/dashboard/src/hooks/use-activity-url-state.ts b/apps/dashboard/src/hooks/use-activity-url-state.ts index 82971c71f73..d9ddedff252 100644 --- a/apps/dashboard/src/hooks/use-activity-url-state.ts +++ b/apps/dashboard/src/hooks/use-activity-url-state.ts @@ -1,8 +1,8 @@ -import { useSearchParams } from 'react-router-dom'; -import { useCallback, useMemo } from 'react'; -import { ChannelTypeEnum } from '@novu/shared'; import { ActivityFilters } from '@/api/activity'; import { ActivityFiltersData, ActivityUrlState } from '@/types/activity'; +import { ChannelTypeEnum } from '@novu/shared'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; const DEFAULT_DATE_RANGE = '30d'; diff --git a/apps/dashboard/src/hooks/use-billing-portal.ts b/apps/dashboard/src/hooks/use-billing-portal.ts index 08a9a612044..85785ff6505 100644 --- a/apps/dashboard/src/hooks/use-billing-portal.ts +++ b/apps/dashboard/src/hooks/use-billing-portal.ts @@ -1,8 +1,8 @@ import { useMutation } from '@tanstack/react-query'; -import { get } from '../api/api.client'; import { toast } from 'sonner'; -import { useTelemetry } from './use-telemetry'; +import { get } from '../api/api.client'; import { TelemetryEvent } from '../utils/telemetry'; +import { useTelemetry } from './use-telemetry'; export function useBillingPortal(billingInterval?: 'month' | 'year') { const track = useTelemetry(); diff --git a/apps/dashboard/src/hooks/use-checkout-session.ts b/apps/dashboard/src/hooks/use-checkout-session.ts index cb508410eff..213f4a5e779 100644 --- a/apps/dashboard/src/hooks/use-checkout-session.ts +++ b/apps/dashboard/src/hooks/use-checkout-session.ts @@ -1,9 +1,9 @@ -import { useMutation } from '@tanstack/react-query'; import { ApiServiceLevelEnum } from '@novu/shared'; -import { post } from '../api/api.client'; +import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { useTelemetry } from './use-telemetry'; +import { post } from '../api/api.client'; import { TelemetryEvent } from '../utils/telemetry'; +import { useTelemetry } from './use-telemetry'; interface CheckoutResponse { data: { diff --git a/apps/dashboard/src/hooks/use-fetch-bridge-health-check.ts b/apps/dashboard/src/hooks/use-fetch-bridge-health-check.ts index b81f319b138..4894f28f678 100644 --- a/apps/dashboard/src/hooks/use-fetch-bridge-health-check.ts +++ b/apps/dashboard/src/hooks/use-fetch-bridge-health-check.ts @@ -1,10 +1,10 @@ -import { ConnectionStatus } from '@/utils/types'; import { getBridgeHealthCheck } from '@/api/bridge'; -import { QueryKeys } from '@/utils/query-keys'; import { useEnvironment } from '@/context/environment/hooks'; -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { QueryKeys } from '@/utils/query-keys'; +import { ConnectionStatus } from '@/utils/types'; import type { HealthCheck } from '@novu/framework/internal'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; const BRIDGE_STATUS_REFRESH_INTERVAL_IN_MS = 10 * 1000; @@ -19,6 +19,9 @@ export const useFetchBridgeHealthCheck = () => { networkMode: 'always', refetchOnWindowFocus: true, refetchInterval: BRIDGE_STATUS_REFRESH_INTERVAL_IN_MS, + meta: { + showError: false, + }, }); const status = useMemo(() => { diff --git a/apps/dashboard/src/hooks/use-fetch-subscribers.ts b/apps/dashboard/src/hooks/use-fetch-subscribers.ts new file mode 100644 index 00000000000..e401c9eb616 --- /dev/null +++ b/apps/dashboard/src/hooks/use-fetch-subscribers.ts @@ -0,0 +1,63 @@ +import { getSubscribers } from '@/api/subscribers'; +import { QueryKeys } from '@/utils/query-keys'; +import { DirectionEnum } from '@novu/shared'; +import { keepPreviousData, useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { useEnvironment } from '../context/environment/hooks'; + +interface UseSubscribersParams { + after?: string; + before?: string; + email?: string; + phone?: string; + orderDirection?: DirectionEnum; + orderBy?: string; + name?: string; + subscriberId?: string; + limit?: number; +} + +type SubscribersResponse = Awaited>; + +export function useFetchSubscribers( + { + after = '', + before = '', + email = '', + phone = '', + orderDirection = DirectionEnum.DESC, + orderBy = 'createdAt', + name = '', + subscriberId = '', + limit = 10, + }: UseSubscribersParams = {}, + options: Omit, 'queryKey' | 'queryFn'> = {} +) { + const { currentEnvironment } = useEnvironment(); + + const subscribersQuery = useQuery({ + queryKey: [ + QueryKeys.fetchSubscribers, + currentEnvironment?._id, + { after, before, limit, email, phone, subscriberId, name, orderDirection, orderBy }, + ], + queryFn: () => + getSubscribers({ + environment: currentEnvironment!, + after, + before, + limit, + email, + phone, + subscriberId, + name, + orderDirection, + orderBy, + }), + placeholderData: keepPreviousData, + enabled: !!currentEnvironment?._id, + refetchOnWindowFocus: true, + ...options, + }); + + return subscribersQuery; +} diff --git a/apps/dashboard/src/hooks/use-fetch-subscription.ts b/apps/dashboard/src/hooks/use-fetch-subscription.ts index b0e280a632c..2ece3a9ea98 100644 --- a/apps/dashboard/src/hooks/use-fetch-subscription.ts +++ b/apps/dashboard/src/hooks/use-fetch-subscription.ts @@ -1,11 +1,11 @@ -import { differenceInDays, isSameDay } from 'date-fns'; import { getSubscription } from '@/api/billing'; -import { QueryKeys } from '@/utils/query-keys'; import { useAuth } from '@/context/auth/hooks'; import { useEnvironment } from '@/context/environment/hooks'; -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { QueryKeys } from '@/utils/query-keys'; import type { GetSubscriptionDto } from '@novu/shared'; +import { useQuery } from '@tanstack/react-query'; +import { differenceInDays, isSameDay } from 'date-fns'; +import { useMemo } from 'react'; const today = new Date(); @@ -19,6 +19,9 @@ export const useFetchSubscription = () => { queryKey: [QueryKeys.billingSubscription, currentOrganization?._id], queryFn: () => getSubscription({ environment: currentEnvironment! }), enabled: !!currentOrganization, + meta: { + showError: false, + }, }); const daysLeft = useMemo(() => { diff --git a/apps/dashboard/src/hooks/use-fetch-workflows.ts b/apps/dashboard/src/hooks/use-fetch-workflows.ts index a8082124317..b6f26c8eb32 100644 --- a/apps/dashboard/src/hooks/use-fetch-workflows.ts +++ b/apps/dashboard/src/hooks/use-fetch-workflows.ts @@ -1,5 +1,6 @@ import { getWorkflows } from '@/api/workflows'; import { QueryKeys } from '@/utils/query-keys'; +import { DirectionEnum } from '@novu/shared'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useEnvironment } from '../context/environment/hooks'; @@ -8,7 +9,7 @@ interface UseWorkflowsParams { offset?: number; query?: string; orderBy?: string; - orderDirection?: 'asc' | 'desc'; + orderDirection?: DirectionEnum; } export function useFetchWorkflows({ @@ -16,7 +17,7 @@ export function useFetchWorkflows({ offset = 0, query = '', orderBy = '', - orderDirection = 'desc', + orderDirection = DirectionEnum.DESC, }: UseWorkflowsParams = {}) { const { currentEnvironment } = useEnvironment(); diff --git a/apps/dashboard/src/hooks/use-subscribers-url-state.ts b/apps/dashboard/src/hooks/use-subscribers-url-state.ts new file mode 100644 index 00000000000..635eabcdeb3 --- /dev/null +++ b/apps/dashboard/src/hooks/use-subscribers-url-state.ts @@ -0,0 +1,169 @@ +import { DirectionEnum } from '@novu/shared'; +import { useCallback, useMemo } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useDebounce } from '../hooks/use-debounce'; + +export type SubscribersSortableColumn = 'createdAt' | 'updatedAt'; +export interface SubscribersFilter { + email?: string; + phone?: string; + name?: string; + subscriberId?: string; + limit?: number; + after?: string; + before?: string; + orderBy?: SubscribersSortableColumn; + orderDirection?: DirectionEnum; +} + +export const defaultSubscribersFilter: SubscribersFilter = { + email: '', + phone: '', + name: '', + subscriberId: '', + limit: 10, + after: '', + before: '', + orderBy: 'createdAt', + orderDirection: DirectionEnum.DESC, +}; + +export interface SubscribersUrlState { + filterValues: SubscribersFilter; + handleFiltersChange: (data: SubscribersFilter) => void; + resetFilters: () => void; + toggleSort: (column: SubscribersSortableColumn) => void; + handleNext: () => void; + handlePrevious: () => void; + handleFirst: () => void; +} + +type UseSubscribersUrlStateProps = { + after?: string; + before?: string; + debounceMs: number; +}; + +export function useSubscribersUrlState(props: UseSubscribersUrlStateProps): SubscribersUrlState { + const { after, before, debounceMs } = props; + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + const filterValues = useMemo( + () => ({ + email: searchParams.get('email') || '', + phone: searchParams.get('phone') || '', + name: searchParams.get('name') || '', + subscriberId: searchParams.get('subscriberId') || '', + limit: parseInt(searchParams.get('limit') || '10', 10), + after: searchParams.get('after') || '', + before: searchParams.get('before') || '', + orderBy: (searchParams.get('orderBy') as SubscribersSortableColumn) || 'createdAt', + orderDirection: (searchParams.get('orderDirection') as DirectionEnum) || DirectionEnum.DESC, + }), + [searchParams] + ); + + const updateSearchParams = useCallback( + (data: SubscribersFilter) => { + const newParams = new URLSearchParams(searchParams.toString()); + + const resetPaginationFilterKeys: (keyof SubscribersFilter)[] = [ + 'phone', + 'subscriberId', + 'email', + 'name', + 'orderBy', + 'orderDirection', + ]; + + const isResetPaginationFilterChanged = resetPaginationFilterKeys.some((key) => data[key] !== filterValues[key]); + + if (isResetPaginationFilterChanged) { + newParams.delete('after'); + newParams.delete('before'); + } + + Object.entries(data).forEach(([key, value]) => { + const typedKey = key as keyof SubscribersFilter; + const defaultValue = defaultSubscribersFilter[typedKey]; + + const shouldInclude = + value && + value !== defaultValue && + !(isResetPaginationFilterChanged && (typedKey === 'after' || typedKey === 'before')); + + if (shouldInclude) { + newParams.set(key, value.toString()); + } else { + newParams.delete(key); + } + }); + + setSearchParams(newParams, { replace: true }); + }, + [setSearchParams, filterValues, searchParams] + ); + + const resetFilters = useCallback(() => { + setSearchParams(new URLSearchParams(), { replace: true }); + }, [setSearchParams]); + + const debouncedUpdateParams = useDebounce(updateSearchParams, debounceMs); + + const toggleSort = useCallback( + (column: SubscribersSortableColumn) => { + const newDirection = + column === filterValues.orderBy + ? filterValues.orderDirection === DirectionEnum.DESC + ? DirectionEnum.ASC + : DirectionEnum.DESC + : DirectionEnum.DESC; + + updateSearchParams({ + ...filterValues, + orderDirection: newDirection, + orderBy: column, + }); + }, + [updateSearchParams, filterValues] + ); + + const handleNext = () => { + if (!after) return; + + const newParams = new URLSearchParams(searchParams); + newParams.delete('before'); + + newParams.set('after', after); + + navigate(`${location.pathname}?${newParams}`); + }; + + const handlePrevious = () => { + if (!before) return; + + const newParams = new URLSearchParams(searchParams); + newParams.delete('after'); + + newParams.set('before', before); + + navigate(`${location.pathname}?${newParams}`); + }; + + const handleFirst = () => { + const newParams = new URLSearchParams(searchParams); + newParams.delete('after'); + newParams.delete('before'); + navigate(`${location.pathname}?${newParams}`, { replace: true }); + }; + + return { + filterValues, + handleFiltersChange: debouncedUpdateParams, + resetFilters, + toggleSort, + handleNext, + handlePrevious, + handleFirst, + }; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 2035ac91b69..8e2f9a3a072 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -19,6 +19,7 @@ import { WorkflowsPage, } from '@/pages'; +import { SubscribersPage } from '@/pages/subscribers'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; @@ -120,6 +121,10 @@ const router = createBrowserRouter([ }, ], }, + { + path: ROUTES.SUBSCRIBERS, + element: , + }, { path: ROUTES.API_KEYS, element: , diff --git a/apps/dashboard/src/pages/subscribers.tsx b/apps/dashboard/src/pages/subscribers.tsx new file mode 100644 index 00000000000..a9b829b47d2 --- /dev/null +++ b/apps/dashboard/src/pages/subscribers.tsx @@ -0,0 +1,23 @@ +import { DashboardLayout } from '@/components/dashboard-layout'; +import { PageMeta } from '@/components/page-meta'; +import { SubscriberList } from '@/components/subscribers/subscriber-list'; +import { useTelemetry } from '@/hooks/use-telemetry'; +import { TelemetryEvent } from '@/utils/telemetry'; +import { useEffect } from 'react'; + +export const SubscribersPage = () => { + const track = useTelemetry(); + + useEffect(() => { + track(TelemetryEvent.SUBSCRIBERS_PAGE_VISIT); + }, [track]); + + return ( + <> + + Subscribers}> + + + + ); +}; diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index ae02e7db394..4dceb2d3b43 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -8,7 +8,7 @@ import { useDebounce } from '@/hooks/use-debounce'; import { useFetchWorkflows } from '@/hooks/use-fetch-workflows'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; -import { StepTypeEnum } from '@novu/shared'; +import { DirectionEnum, StepTypeEnum } from '@novu/shared'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { @@ -44,7 +44,7 @@ export const WorkflowsPage = () => { const track = useTelemetry(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams({ - orderDirection: 'desc', + orderDirection: DirectionEnum.DESC, orderBy: 'updatedAt', query: '', }); @@ -93,7 +93,7 @@ export const WorkflowsPage = () => { limit, offset, orderBy: searchParams.get('orderBy') as SortableColumn, - orderDirection: searchParams.get('orderDirection') as 'asc' | 'desc', + orderDirection: searchParams.get('orderDirection') as DirectionEnum, query: searchParams.get('query') || '', }); @@ -253,7 +253,7 @@ export const WorkflowsPage = () => { hasActiveFilters={!!hasActiveFilters} onClearFilters={clearFilters} orderBy={searchParams.get('orderBy') as SortableColumn} - orderDirection={searchParams.get('orderDirection') as 'asc' | 'desc'} + orderDirection={searchParams.get('orderDirection') as DirectionEnum} data={workflowsData} isLoading={isPending} isError={isError} diff --git a/apps/dashboard/src/routes/root.tsx b/apps/dashboard/src/routes/root.tsx index 5be4fc60b82..acea5a246d5 100644 --- a/apps/dashboard/src/routes/root.tsx +++ b/apps/dashboard/src/routes/root.tsx @@ -1,12 +1,14 @@ -import { Outlet } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { HelmetProvider } from 'react-helmet-async'; -import { withProfiler, ErrorBoundary } from '@sentry/react'; -import { SegmentProvider } from '@/context/segment'; +import { ToastIcon } from '@/components/primitives/sonner'; +import { showToast } from '@/components/primitives/sonner-helpers'; +import { TooltipProvider } from '@/components/primitives/tooltip'; import { AuthProvider } from '@/context/auth/auth-provider'; import { ClerkProvider } from '@/context/clerk-provider'; -import { TooltipProvider } from '@/components/primitives/tooltip'; import { IdentityProvider } from '@/context/identity-provider'; +import { SegmentProvider } from '@/context/segment'; +import { ErrorBoundary, withProfiler } from '@sentry/react'; +import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { HelmetProvider } from 'react-helmet-async'; +import { Outlet } from 'react-router-dom'; const queryClient = new QueryClient({ defaultOptions: { @@ -14,6 +16,28 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, + queryCache: new QueryCache({ + onError: (error, query) => { + if (query.meta?.showError !== false) { + showToast({ + children: () => ( + <> + + + {(query.meta?.errorMessage as string | undefined) || error.message || 'Issue fetching.'} + + + ), + options: { + position: 'bottom-right', + classNames: { + toast: 'mb-4 right-0', + }, + }, + }); + } + }, + }), }); const RootRouteInternal = () => { diff --git a/apps/dashboard/src/utils/format-date.ts b/apps/dashboard/src/utils/format-date.ts new file mode 100644 index 00000000000..98b36a3afe5 --- /dev/null +++ b/apps/dashboard/src/utils/format-date.ts @@ -0,0 +1,13 @@ +import { format } from 'date-fns'; + +export function formatDate(date: string) { + return format(new Date(date), 'MMM d yyyy, HH:mm:ss'); +} + +export function formatDateSimple(date: string) { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} diff --git a/apps/dashboard/src/utils/parse-page-param.ts b/apps/dashboard/src/utils/parse-page-param.ts new file mode 100644 index 00000000000..711bb07e8bf --- /dev/null +++ b/apps/dashboard/src/utils/parse-page-param.ts @@ -0,0 +1,7 @@ +export function parsePageParam(param: string | null): number { + if (!param) return 0; + + const parsed = Number.parseInt(param, 10); + + return Math.max(0, parsed || 0); +} diff --git a/apps/dashboard/src/utils/query-keys.ts b/apps/dashboard/src/utils/query-keys.ts index 4d6bad6cd32..a8d614f9204 100644 --- a/apps/dashboard/src/utils/query-keys.ts +++ b/apps/dashboard/src/utils/query-keys.ts @@ -9,4 +9,5 @@ export const QueryKeys = Object.freeze({ getApiKeys: 'getApiKeys', fetchIntegrations: 'fetchIntegrations', fetchActivity: 'fetchActivity', + fetchSubscribers: 'fetchSubscribers', }); diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 1129017e15a..9059ddd2e2c 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -33,6 +33,7 @@ export const ROUTES = { TEMPLATE_STORE: '/env/:environmentSlug/workflows/templates', WORKFLOWS_CREATE: '/env/:environmentSlug/workflows/create', TEMPLATE_STORE_CREATE_WORKFLOW: '/env/:environmentSlug/workflows/templates/:templateId', + SUBSCRIBERS: '/env/:environmentSlug/subscribers', } as const; export const buildRoute = (route: string, params: Record) => { diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index 95e1dc69d5a..73a210747f1 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -48,4 +48,5 @@ export enum TelemetryEvent { ENVIRONMENTS_PAGE_VIEWED = 'Environments Page Viewed', CREATE_ENVIRONMENT_CLICK = 'Create Environment Click', UPGRADE_TO_BUSINESS_TIER_CLICK = 'Upgrade to Business Tier Click', + SUBSCRIBERS_PAGE_VISIT = 'Subscribers page visit', } diff --git a/libs/application-generic/src/commands/project.command.ts b/libs/application-generic/src/commands/project.command.ts index fb8bda7cdc6..a6df6245a13 100644 --- a/libs/application-generic/src/commands/project.command.ts +++ b/libs/application-generic/src/commands/project.command.ts @@ -3,7 +3,10 @@ import { IsEnum, IsNotEmpty, IsNumber, + IsOptional, IsString, + Max, + Min, } from 'class-validator'; import { DirectionEnum, UserSessionData } from '@novu/shared'; @@ -96,3 +99,18 @@ export abstract class EnvironmentCommand extends BaseCommand { @IsNotEmpty() readonly organizationId: string; } +export abstract class CursorBasedPaginatedCommand extends EnvironmentWithUserObjectCommand { + @IsDefined() + @IsNumber() + @Min(1) + @Max(100) + limit: number; + + @IsString() + @IsOptional() + after?: string; + + @IsString() + @IsOptional() + before?: string; +} diff --git a/libs/dal/src/repositories/base-repository.ts b/libs/dal/src/repositories/base-repository.ts index 906e3044c13..93303026725 100644 --- a/libs/dal/src/repositories/base-repository.ts +++ b/libs/dal/src/repositories/base-repository.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ClassConstructor, plainToInstance } from 'class-transformer'; +import { DirectionEnum } from '@novu/shared'; import { ClientSession, FilterQuery, @@ -156,6 +157,10 @@ export class BaseRepository { return enhancedCursorOrStatements.length > 0 ? enhancedCursorOrStatements : cursorOrStatements; } + /** + * @deprecated This method is deprecated + * Please use findWithCursorBasedPagination() instead. + */ async cursorPagination({ query, limit, @@ -320,6 +325,150 @@ export class BaseRepository { async withTransaction(fn: Parameters[0]) { return (await this._model.db.startSession()).withTransaction(fn); } + + async findWithCursorBasedPagination({ + query = {} as FilterQuery & T_Enforcement, + limit, + before, + after, + sortBy, + sortDirection = DirectionEnum.DESC, + paginateField, + enhanceQuery, + }: { + query?: FilterQuery & T_Enforcement; + limit: number; + before?: { sortBy: string; paginateField: any }; + after?: { sortBy: string; paginateField: any }; + sortBy: string; + sortDirection: DirectionEnum; + paginateField: string; + enhanceQuery?: (query: QueryWithHelpers, T_DBModel>) => any; + }): Promise<{ data: T_MappedEntity[]; next: string | null; previous: string | null }> { + if (before && after) { + throw new DalException('Cannot specify both "before" and "after" cursors at the same time.'); + } + + const isDesc = sortDirection === DirectionEnum.DESC; + const sortValue = isDesc ? -1 : 1; + + const paginationQuery: any = { ...query }; + + if (before) { + paginationQuery.$or = [ + { + [sortBy]: isDesc ? { $gt: before.sortBy } : { $lt: before.sortBy }, + }, + { + $and: [ + { [sortBy]: { $eq: before.sortBy } }, + { [paginateField]: isDesc ? { $gt: before.paginateField } : { $lt: before.paginateField } }, + ], + }, + ]; + } else if (after) { + paginationQuery.$or = [ + { + [sortBy]: isDesc ? { $lt: after.sortBy } : { $gt: after.sortBy }, + }, + { + $and: [ + { [sortBy]: { $eq: after.sortBy } }, + { [paginateField]: isDesc ? { $lt: after.paginateField } : { $gt: after.paginateField } }, + ], + }, + ]; + } + + let builder = this.MongooseModel.find(paginationQuery) + .sort({ [sortBy]: sortValue, [paginateField]: sortValue }) + .limit(limit + 1); + + if (enhanceQuery) { + builder = enhanceQuery(builder); + } + + const rawResults = await builder.exec(); + + const hasExtraItem = rawResults.length > limit; + const pageResults = rawResults.slice(0, limit); + + if (pageResults.length === 0) { + return { + data: [], + next: null, + previous: null, + }; + } + + let nextCursor: string | null = null; + let prevCursor: string | null = null; + + const firstItem = pageResults[0]; + const lastItem = pageResults[pageResults.length - 1]; + + if (hasExtraItem) { + if (before) { + prevCursor = firstItem[paginateField].toString(); + } else { + nextCursor = lastItem[paginateField].toString(); + } + } + + if (before) { + const nextQuery: any = { ...query }; + + nextQuery.$or = [ + { + [sortBy]: isDesc ? { $lt: lastItem[sortBy] } : { $gt: lastItem[sortBy] }, + }, + { + $and: [ + { [sortBy]: { $eq: lastItem[sortBy] } }, + { [paginateField]: isDesc ? { $lt: lastItem[paginateField] } : { $gt: lastItem[paginateField] } }, + ], + }, + ]; + + const maybeNext = await this.MongooseModel.findOne(nextQuery) + .sort({ [sortBy]: sortValue, [paginateField]: sortValue }) + .limit(1) + .exec(); + + if (maybeNext) { + nextCursor = lastItem[paginateField].toString(); + } + } else { + const prevQuery: any = { ...query }; + + prevQuery.$or = [ + { + [sortBy]: isDesc ? { $gt: firstItem[sortBy] } : { $lt: firstItem[sortBy] }, + }, + { + $and: [ + { [sortBy]: { $eq: firstItem[sortBy] } }, + { [paginateField]: isDesc ? { $gt: firstItem[paginateField] } : { $lt: firstItem[paginateField] } }, + ], + }, + ]; + + const maybePrev = await this.MongooseModel.findOne(prevQuery) + .sort({ [sortBy]: sortValue, [paginateField]: sortValue }) + .limit(1) + .exec(); + + if (maybePrev) { + prevCursor = firstItem[paginateField].toString(); + } + } + + return { + data: this.mapEntities(pageResults), + next: nextCursor, + previous: prevCursor, + }; + } } interface IOptions { diff --git a/libs/dal/src/repositories/subscriber/subscriber.repository.ts b/libs/dal/src/repositories/subscriber/subscriber.repository.ts index 0bd7e777268..9c058cfb3c9 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.repository.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.repository.ts @@ -1,5 +1,5 @@ import { FilterQuery } from 'mongoose'; -import { EnvironmentId, ISubscribersDefine, OrganizationId } from '@novu/shared'; +import { DirectionEnum, EnvironmentId, ISubscribersDefine, OrganizationId, SubscriberDto } from '@novu/shared'; import { SubscriberDBModel, SubscriberEntity } from './subscriber.entity'; import { Subscriber } from './subscriber.schema'; import { IExternalSubscribersEntity } from './types'; @@ -8,8 +8,6 @@ import { DalException } from '../../shared'; import type { EnforceEnvOrOrgIds } from '../../types'; import { BulkCreateSubscriberEntity } from './bulk.create.subscriber.entity'; -type SubscriberQuery = FilterQuery & EnforceEnvOrOrgIds; - export class SubscriberRepository extends BaseRepository { constructor() { super(Subscriber, SubscriberEntity); @@ -162,6 +160,82 @@ export class SubscriberRepository extends BaseRepository { return this._model.estimatedDocumentCount(); } + + async listSubscribers(query: { + environmentId: string; + organizationId: string; + limit: number; + sortBy: 'updatedAt' | 'createdAt'; + sortDirection: DirectionEnum; + after?: string; + before?: string; + email?: string; + phone?: string; + subscriberId?: string; + name?: string; + }): Promise<{ subscribers: SubscriberEntity[]; next: string | null; previous: string | null }> { + if (query.before && query.after) { + throw new DalException('Cannot specify both "before" and "after" cursors at the same time.'); + } + + const id = query.before || query.after; + let subscriber: SubscriberEntity | null = null; + if (id) { + subscriber = await this.findOne({ + _environmentId: query.environmentId, + _organizationId: query.organizationId, + _id: id, + }); + if (!subscriber) { + throw new DalException('Subscriber not found'); + } + } + + const after = + query.after && subscriber ? { sortBy: subscriber[query.sortBy], paginateField: subscriber._id } : undefined; + const before = + query.before && subscriber ? { sortBy: subscriber[query.sortBy], paginateField: subscriber._id } : undefined; + + const pagination = await this.findWithCursorBasedPagination({ + after, + before, + paginateField: '_id', + limit: query.limit, + sortDirection: query.sortDirection, + sortBy: query.sortBy, + query: { + _environmentId: query.environmentId, + _organizationId: query.organizationId, + $and: [ + { + ...(query.email && { email: query.email }), + ...(query.phone && { phone: query.phone }), + ...(query.subscriberId && { subscriberId: query.subscriberId }), + ...(query.name && { + $expr: { + $eq: [ + { + $trim: { + input: { + $concat: [{ $ifNull: ['$firstName', ''] }, ' ', { $ifNull: ['$lastName', ''] }], + }, + }, + }, + query.name, + ], + }, + }), + }, + ], + }, + }); + + return { + subscribers: pagination.data, + next: pagination.next, + previous: pagination.previous, + }; + } } function mapToSubscriberObject(subscriberId: string) { diff --git a/libs/dal/src/repositories/subscriber/subscriber.schema.ts b/libs/dal/src/repositories/subscriber/subscriber.schema.ts index 68f0a4c87a8..bea528f63d0 100644 --- a/libs/dal/src/repositories/subscriber/subscriber.schema.ts +++ b/libs/dal/src/repositories/subscriber/subscriber.schema.ts @@ -193,6 +193,34 @@ subscriberSchema.index({ deleted: 1, }); +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + createdAt: 1, + _id: 1, +}); + +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + createdAt: -1, + _id: -1, +}); + +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + updatedAt: 1, + _id: 1, +}); + +subscriberSchema.index({ + _environmentId: 1, + _organizationId: 1, + updatedAt: -1, + _id: -1, +}); + subscriberSchema.plugin(mongooseDelete, { deletedAt: true, deletedBy: true, overrideMethods: 'all' }); export const Subscriber = diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 0eb499ea1bc..b3923d4ba58 100644 --- a/packages/js/scripts/size-limit.mjs +++ b/packages/js/scripts/size-limit.mjs @@ -1,7 +1,7 @@ -import fs from 'fs/promises'; -import path from 'path'; import bytes from 'bytes-iec'; import chalk from 'chalk'; +import fs from 'fs/promises'; +import path from 'path'; const baseDir = process.cwd(); const umdPath = path.resolve(baseDir, './dist/novu.min.js'); diff --git a/packages/shared/src/dto/subscriber/index.ts b/packages/shared/src/dto/subscriber/index.ts index b615fa1eb31..4e655420b72 100644 --- a/packages/shared/src/dto/subscriber/index.ts +++ b/packages/shared/src/dto/subscriber/index.ts @@ -1 +1,2 @@ export * from './subscriber.dto'; +export * from './list-subscribers.dto'; diff --git a/packages/shared/src/dto/subscriber/list-subscribers.dto.ts b/packages/shared/src/dto/subscriber/list-subscribers.dto.ts new file mode 100644 index 00000000000..a2b3f66638b --- /dev/null +++ b/packages/shared/src/dto/subscriber/list-subscribers.dto.ts @@ -0,0 +1,30 @@ +import { DirectionEnum } from '../../types/response'; +import { SubscriberDto } from './subscriber.dto'; + +export interface IListSubscribersRequestDto { + limit: number; + + before?: string; + + after?: string; + + orderDirection: DirectionEnum; + + orderBy: 'updatedAt' | 'createdAt'; + + email?: string; + + phone?: string; + + subscriberId?: string; + + name?: string; +} + +export interface IListSubscribersResponseDto { + subscribers: SubscriberDto[]; + + next: string | null; + + previous: string | null; +} diff --git a/packages/shared/src/dto/subscriber/subscriber.dto.ts b/packages/shared/src/dto/subscriber/subscriber.dto.ts index dac5a851e0c..656ffcd4408 100644 --- a/packages/shared/src/dto/subscriber/subscriber.dto.ts +++ b/packages/shared/src/dto/subscriber/subscriber.dto.ts @@ -24,7 +24,11 @@ export class SubscriberDto { subscriberId: string; channels?: IChannelSettings[]; deleted: boolean; + createdAt: string; + updatedAt: string; + lastOnlineAt?: string; } + export interface ISubscriberFeedResponseDto { _id?: string; firstName?: string; diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index b93644cd174..5fac75c9ec6 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -46,4 +46,5 @@ export enum FeatureFlagsKeysEnum { IS_V2_ENABLED = 'IS_V2_ENABLED', IS_STEP_CONDITIONS_ENABLED = 'IS_STEP_CONDITIONS_ENABLED', IS_WORKFLOW_NODE_PREVIEW_ENABLED = 'IS_WORKFLOW_NODE_PREVIEW_ENABLED', + IS_SUBSCRIBERS_PAGE_ENABLED = 'IS_SUBSCRIBERS_PAGE_ENABLED', }