From fd2942f428b62c1ac669fcc6f2beed47285cc09a Mon Sep 17 00:00:00 2001 From: desiprisg Date: Wed, 15 Jan 2025 15:11:40 +0200 Subject: [PATCH] feat(api-service,dashboard): New subscribers page and api --- apps/api/src/app.module.ts | 2 + .../subscriber.controller.e2e.ts | 184 ++++++++++++++++++ .../subscribers-v2/subscriber.controller.ts | 38 ++++ .../app/subscribers-v2/subscriber.module.ts | 13 ++ .../list-subscribers.command.ts | 33 ++++ .../list-subscribers.usecase.ts | 89 +++++++++ ...rch-by-external-subscriber-ids.use-case.ts | 4 +- apps/dashboard/src/api/subscribers.ts | 30 +++ .../components/activity/activity-filters.tsx | 10 +- .../components/activity/activity-table.tsx | 8 +- .../src/components/cursor-pagination.tsx | 51 +++++ .../icons/add-subscriber-illustration.tsx | 25 +++ .../src/components/primitives/table.tsx | 2 +- .../side-navigation/side-navigation.tsx | 33 ++-- .../subscribers/subscriber-list-empty.tsx | 31 +++ .../subscribers/subscriber-list.tsx | 131 +++++++++++++ .../components/subscribers/subscriber-row.tsx | 144 ++++++++++++++ .../subscribers/subscribers-filters.tsx | 139 +++++++++++++ .../src/hooks/use-activity-url-state.ts | 6 +- .../src/hooks/use-fetch-subscribers.ts | 43 ++++ .../src/hooks/use-subscribers-url-state.ts | 90 +++++++++ apps/dashboard/src/main.tsx | 55 +++--- apps/dashboard/src/pages/subscribers.tsx | 30 +++ 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 | 14 ++ packages/js/scripts/size-limit.mjs | 6 +- packages/shared/src/dto/index.ts | 1 + packages/shared/src/dto/pagination/index.ts | 1 + .../src/dto/pagination/pagination.dto.ts | 8 + packages/shared/src/dto/subscriber/index.ts | 1 + .../dto/subscriber/list-subscribers.dto.ts | 33 ++++ .../src/dto/subscriber/subscriber.dto.ts | 13 ++ 35 files changed, 1213 insertions(+), 65 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-empty.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/parse-page-param.ts create mode 100644 packages/shared/src/dto/pagination/index.ts create mode 100644 packages/shared/src/dto/pagination/pagination.dto.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..90682116b1d --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.e2e.ts @@ -0,0 +1,184 @@ +import { randomBytes } from 'crypto'; +import { UserSession } from '@novu/testing'; +import { expect } from 'chai'; + +const v2Prefix = '/v2'; +let session: UserSession; + +describe('List Subscriber Permutations', () => { + it('should not return subscribers if not matching query', async () => { + await createSubscriberAndValidate('XYZ'); + await createSubscriberAndValidate('XYZ2'); + const subscribers = await getAllAndValidate({ + searchQuery: 'ABC', + expectedTotalResults: 0, + expectedArraySize: 0, + }); + expect(subscribers).to.be.empty; + }); + + it('should not return subscribers if offset is bigger than available subscribers', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + await getAllAndValidate({ + searchQuery: uuid, + offset: 11, + limit: 15, + expectedTotalResults: 10, + expectedArraySize: 0, + }); + }); + + it('should return all results within range', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + await getAllAndValidate({ + searchQuery: uuid, + offset: 0, + limit: 15, + expectedTotalResults: 10, + expectedArraySize: 10, + }); + }); + + it('should return results without query', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + await getAllAndValidate({ + searchQuery: uuid, + offset: 0, + limit: 15, + expectedTotalResults: 10, + expectedArraySize: 10, + }); + }); + + it('should page subscribers without overlap', async () => { + const uuid = generateUUID(); + await create10Subscribers(uuid); + const listResponse1 = await getAllAndValidate({ + searchQuery: uuid, + offset: 0, + limit: 5, + expectedTotalResults: 10, + expectedArraySize: 5, + }); + const listResponse2 = await getAllAndValidate({ + searchQuery: uuid, + offset: 5, + limit: 5, + expectedTotalResults: 10, + expectedArraySize: 5, + }); + const idsDeduplicated = buildIdSet(listResponse1, listResponse2); + expect(idsDeduplicated.size).to.be.equal(10); + }); +}); + +// Helper functions +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}`); + } +} + +async function getListSubscribers(query: string, offset: number, limit: number) { + const res = await session.testAgent.get(`${v2Prefix}/subscribers`).query({ + query, + page: Math.floor(offset / limit) + 1, + limit, + }); + expect(res.status).to.equal(200); + + return res.body.data; +} + +interface IAllAndValidate { + msgPrefix?: string; + searchQuery: string; + offset?: number; + limit?: number; + expectedTotalResults: number; + expectedArraySize: number; +} + +async function getAllAndValidate({ + msgPrefix = '', + searchQuery = '', + offset = 0, + limit = 50, + expectedTotalResults, + expectedArraySize, +}: IAllAndValidate) { + const listResponse = await getListSubscribers(searchQuery, offset, limit); + const summary = buildLogMsg( + { + msgPrefix, + searchQuery, + offset, + limit, + expectedTotalResults, + expectedArraySize, + }, + listResponse + ); + + expect(listResponse.subscribers).to.be.an('array', summary); + expect(listResponse.subscribers).lengthOf(expectedArraySize, `subscribers length ${summary}`); + expect(listResponse.totalCount).to.be.equal(expectedTotalResults, `total Results don't match ${summary}`); + + return listResponse.subscribers; +} + +function buildLogMsg(params: IAllAndValidate, listResponse: any): string { + return `Log - msgPrefix: ${params.msgPrefix}, + searchQuery: ${params.searchQuery}, + offset: ${params.offset}, + limit: ${params.limit}, + 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..b34113d0611 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.ts @@ -0,0 +1,38 @@ +import { ClassSerializerInterceptor, Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { UserAuthGuard, UserSession } from '@novu/application-generic'; +import { DirectionEnum, IListSubscribersRequestDto, IListSubscribersResponseDto, UserSessionData } from '@novu/shared'; +import { ApiCommonResponses } from '../shared/framework/response.decorator'; + +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('') + @UseGuards(UserAuthGuard) + async getSubscribers( + @UserSession() user: UserSessionData, + @Query() query: IListSubscribersRequestDto + ): Promise { + return await this.listSubscribersUsecase.execute( + ListSubscribersCommand.create({ + user, + limit: Number(query.limit || '10'), + cursor: query.cursor, + orderDirection: query.orderDirection || DirectionEnum.DESC, + orderBy: query.orderBy || 'createdAt', + query: query.query, + 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..11be0801688 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { SharedModule } from '../shared/shared.module'; +import { SubscriberController } from './subscriber.controller'; +import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; + +const USE_CASES = [ListSubscribersUseCase]; + +@Module({ + imports: [SharedModule], + controllers: [SubscriberController], + providers: [...USE_CASES], +}) +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..f7fb9588c42 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts @@ -0,0 +1,33 @@ +import { DirectionEnum } from '@novu/shared'; +import { IsOptional, IsString, IsEnum } from 'class-validator'; +import { CursorPaginatedCommand } from '@novu/application-generic'; + +export class ListSubscribersCommand extends CursorPaginatedCommand { + @IsEnum(DirectionEnum) + @IsOptional() + orderDirection: DirectionEnum = DirectionEnum.DESC; + + @IsEnum(['updatedAt', 'createdAt', 'lastOnlineAt']) + @IsOptional() + orderBy: 'updatedAt' | 'createdAt' | 'lastOnlineAt' = 'createdAt'; + + @IsString() + @IsOptional() + query?: string; + + @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..77586c3b453 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { SubscriberRepository } from '@novu/dal'; +import { DirectionEnum, 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 query = { + _environmentId: command.user.environmentId, + _organizationId: command.user.organizationId, + } as const; + + if (command.query || command.email || command.phone || command.subscriberId || command.name) { + const searchConditions: Record[] = []; + + if (command.query) { + searchConditions.push( + ...[ + { subscriberId: { $regex: command.query, $options: 'i' } }, + { email: { $regex: command.query, $options: 'i' } }, + { phone: { $regex: command.query, $options: 'i' } }, + { + $expr: { + $regexMatch: { + input: { $concat: ['$firstName', ' ', '$lastName'] }, + regex: command.query, + options: 'i', + }, + }, + }, + ] + ); + } + + if (command.email) { + searchConditions.push({ email: { $regex: command.email, $options: 'i' } }); + } + + if (command.phone) { + searchConditions.push({ phone: { $regex: command.phone, $options: 'i' } }); + } + + if (command.subscriberId) { + searchConditions.push({ subscriberId: { $regex: command.subscriberId, $options: 'i' } }); + } + + if (command.name) { + searchConditions.push({ + $expr: { + $regexMatch: { + input: { $concat: ['$firstName', ' ', '$lastName'] }, + regex: command.name, + options: 'i', + }, + }, + }); + } + + Object.assign(query, { $or: searchConditions }); + } + + if (command.cursor) { + const operator = command.orderDirection === DirectionEnum.ASC ? '$gt' : '$lt'; + Object.assign(query, { + subscriberId: { [operator]: command.cursor }, + }); + } + + const subscribers = await this.subscriberRepository.find(query, undefined, { + limit: command.limit + 1, // Get one extra to determine if there are more items + sort: { [command.orderBy]: command.orderDirection === DirectionEnum.ASC ? 1 : -1 }, + }); + + const hasMore = subscribers.length > command.limit; + const data = hasMore ? subscribers.slice(0, -1) : subscribers; + + return { + subscribers: data, + hasMore, + pageSize: command.limit, + nextCursor: hasMore ? subscribers[subscribers.length - 1].subscriberId : undefined, + }; + } +} 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..9a6d3f5a411 --- /dev/null +++ b/apps/dashboard/src/api/subscribers.ts @@ -0,0 +1,30 @@ +import type { IEnvironment, IListSubscribersResponseDto } from '@novu/shared'; +import { getV2 } from './api.client'; + +export const getSubscribers = async ({ + environment, + cursor, + limit, + query, + email, + phone, + subscriberId, + name, +}: { + environment: IEnvironment; + cursor: string; + query: string; + limit: number; + email?: string; + phone?: string; + subscriberId?: string; + name?: string; +}): Promise => { + const { data } = await getV2<{ data: IListSubscribersResponseDto }>( + `/subscribers?cursor=${cursor}&limit=${limit}&query=${query}&email=${email}&phone=${phone}&subscriberId=${subscriberId}&name=${name}`, + { + 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 baf56dd9c54..c320ee7efa6 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -2,6 +2,7 @@ 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 { parsePageParam } from '@/utils/parse-page-param'; import { cn } from '@/utils/ui'; import { ISubscriber } from '@novu/shared'; import { format } from 'date-fns'; @@ -186,10 +187,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 123a0d2b3c3..79a8e9bd9ca 100644 --- a/apps/dashboard/src/components/primitives/table.tsx +++ b/apps/dashboard/src/components/primitives/table.tsx @@ -23,7 +23,7 @@ const Table = React.forwardRef( ({ className, containerClassname, isLoading, loadingRowsCount = 5, loadingRow, children, ...props }, ref) => (
diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 750e253253d..fc4e0f51603 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -1,3 +1,9 @@ +import { SidebarContent } from '@/components/side-navigation/sidebar'; +import { useEnvironment } from '@/context/environment/hooks'; +import { useTelemetry } from '@/hooks/use-telemetry'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { TelemetryEvent } from '@/utils/telemetry'; +import * as Sentry from '@sentry/react'; import { ReactNode, useMemo } from 'react'; import { RiBarChartBoxLine, @@ -9,20 +15,13 @@ import { RiStore3Line, RiUserAddLine, } from 'react-icons/ri'; -import { useEnvironment } from '@/context/environment/hooks'; -import { buildRoute, ROUTES } from '@/utils/routes'; -import { TelemetryEvent } from '@/utils/telemetry'; -import { useTelemetry } from '@/hooks/use-telemetry'; +import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; +import { ChangelogStack } from './changelog-cards'; import { EnvironmentDropdown } from './environment-dropdown'; -import { OrganizationDropdown } from './organization-dropdown'; import { FreeTrialCard } from './free-trial-card'; -import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; -import { SidebarContent } from '@/components/side-navigation/sidebar'; -import { NavigationLink } from './navigation-link'; import { GettingStartedMenuItem } from './getting-started-menu-item'; -import { ChangelogStack } from './changelog-cards'; -import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; -import * as Sentry from '@sentry/react'; +import { NavigationLink } from './navigation-link'; +import { OrganizationDropdown } from './organization-dropdown'; const NavigationGroup = ({ children, label }: { children: ReactNode; label?: string }) => { return ( @@ -68,14 +67,10 @@ export const SideNavigation = () => { Workflows - - track(TelemetryEvent.SUBSCRIBERS_LINK_CLICKED)}> - - - Subscribers - - - + + + Subscribers + { + return ( +
+ +
+ No subscribers yet +

+ A Subscriber is a unique entity for receiving notifications. Add them ahead of time or migrate them + dynamically when sending notifications. +

+
+ +
+ + + Migrate via API + + + + {/* */} +
+
+ ); +}; 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..cdaa520a8a3 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscriber-list.tsx @@ -0,0 +1,131 @@ +import { CursorPagination } from '@/components/cursor-pagination'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/primitives/table'; +import { SubscriberListEmpty } from '@/components/subscribers/subscriber-list-empty'; +import { SubscriberRow, SubscriberRowSkeleton } from '@/components/subscribers/subscriber-row'; +import { defaultSubscribersFilters, SubscribersFilters } from '@/components/subscribers/subscribers-filters'; +import { useFetchSubscribers } from '@/hooks/use-fetch-subscribers'; +import { useSubscribersUrlState } from '@/hooks/use-subscribers-url-state'; +import { cn } from '@/utils/ui'; +import { HTMLAttributes, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +type SubscriberListProps = HTMLAttributes; + +export const SubscriberList = (props: SubscriberListProps) => { + const { className, ...rest } = props; + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [cursorHistory, setCursorHistory] = useState(['']); + + const { filterValues, handleFiltersChange } = useSubscribersUrlState(); + const query = searchParams.get('query') || ''; + const email = searchParams.get('email') || ''; + const phone = searchParams.get('phone') || ''; + const name = searchParams.get('name') || ''; + const subscriberId = searchParams.get('subscriberId') || ''; + const limit = parseInt(searchParams.get('limit') || '10'); + const currentCursor = searchParams.get('cursor') || ''; + + const currentIndex = cursorHistory.indexOf(currentCursor); + + const { data, isPending, isError } = useFetchSubscribers({ + cursor: currentCursor, + limit, + query, + email, + phone, + subscriberId, + name, + }); + + const handleNext = () => { + if (!data?.nextCursor) return; + + const newParams = new URLSearchParams(searchParams); + newParams.set('cursor', data.nextCursor); + + // Use replace instead of navigate to avoid history stack issues + navigate(`${location.pathname}?${newParams}`, { replace: true }); + + if (data.nextCursor && !cursorHistory.includes(data.nextCursor)) { + setCursorHistory((prev) => [...prev, data.nextCursor!]); + } + }; + + const handlePrevious = () => { + if (currentIndex <= 0) return; + const previousCursor = cursorHistory[currentIndex - 1]; + const newParams = new URLSearchParams(searchParams); + + if (previousCursor === '') { + newParams.delete('cursor'); + } else { + newParams.set('cursor', previousCursor); + } + + navigate(`${location.pathname}?${newParams}`, { replace: true }); + }; + + const handleFirst = () => { + const newParams = new URLSearchParams(searchParams); + newParams.delete('cursor'); + navigate(`${location.pathname}?${newParams}`, { replace: true }); + }; + + const handleClearFilters = () => { + handleFiltersChange(defaultSubscribersFilters); + }; + + if (isError) return null; + + return ( +
+ + {!isPending && data?.subscribers.length === 0 ? ( + + ) : ( + + + + Subscriber + Email address + Phone number + Created at + Updated at + + + + + {isPending ? ( + <> + {new Array(limit).fill(0).map((_, index) => ( + + ))} + + ) : ( + <> + {data.subscribers.map((subscriber) => ( + + ))} + + )} + +
+ )} + {data && ( + 0} + onNext={handleNext} + onPrevious={handlePrevious} + onFirst={handleFirst} + /> + )} +
+ ); +}; 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..85610a20f35 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscriber-row.tsx @@ -0,0 +1,144 @@ +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 TruncatedText from '@/components/truncated-text'; +import { useEnvironment } from '@/context/environment/hooks'; +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 || '-'} + + {new Date(subscriber.createdAt).toLocaleDateString()} + + + {new Date(subscriber.updatedAt).toLocaleDateString()} + + + + + + + + + { + 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..32c279186c8 --- /dev/null +++ b/apps/dashboard/src/components/subscribers/subscribers-filters.tsx @@ -0,0 +1,139 @@ +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: (filters: SubscribersFiltersData) => void; + initialValues: SubscribersFiltersData; + onReset?: () => void; +}; + +export type SubscribersFiltersData = { + email: string; + phone: string; + name: string; + subscriberId: string; +}; + +export const defaultSubscribersFilters: SubscribersFiltersData = { + email: '', + phone: '', + name: '', + subscriberId: '', +} as const; + +export function SubscribersFilters(props: SubscribersFiltersProps) { + const { onFiltersChange, initialValues, onReset, className, ...rest } = props; + + const form = useForm({ + defaultValues: initialValues || defaultSubscribersFilters, + }); + + useEffect(() => { + const subscription = form.watch((data) => { + onFiltersChange(data as SubscribersFiltersData); + }); + + return () => subscription.unsubscribe(); + }, [form, onFiltersChange]); + + const watchedValues = form.watch(); + + const hasChanges = useMemo(() => { + return ( + watchedValues.email !== defaultSubscribersFilters.email || + watchedValues.phone !== defaultSubscribersFilters.phone || + watchedValues.name !== defaultSubscribersFilters.name || + watchedValues.subscriberId !== defaultSubscribersFilters.subscriberId + ); + }, [watchedValues]); + + const handleReset = () => { + form.reset(defaultSubscribersFilters); + onFiltersChange(defaultSubscribersFilters); + onReset?.(); + }; + + return ( +
+ + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + {hasChanges && ( + + )} + + + ); +} 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-fetch-subscribers.ts b/apps/dashboard/src/hooks/use-fetch-subscribers.ts new file mode 100644 index 00000000000..67b99ecfc20 --- /dev/null +++ b/apps/dashboard/src/hooks/use-fetch-subscribers.ts @@ -0,0 +1,43 @@ +import { getSubscribers } from '@/api/subscribers'; +import { QueryKeys } from '@/utils/query-keys'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useEnvironment } from '../context/environment/hooks'; + +interface UseSubscribersParams { + cursor?: string; + query?: string; + email?: string; + phone?: string; + name?: string; + subscriberId?: string; + limit?: number; +} + +export function useFetchSubscribers({ + cursor = '', + query = '', + email = '', + phone = '', + name = '', + subscriberId = '', + limit = 8, +}: UseSubscribersParams = {}) { + const { currentEnvironment } = useEnvironment(); + + const subscribersQuery = useQuery({ + queryKey: [ + QueryKeys.fetchSubscribers, + currentEnvironment?._id, + { cursor, limit, query, email, phone, subscriberId, name }, + ], + queryFn: () => + getSubscribers({ environment: currentEnvironment!, cursor, limit, query, email, phone, subscriberId, name }), + placeholderData: keepPreviousData, + enabled: !!currentEnvironment?._id, + refetchOnWindowFocus: true, + }); + + return { + ...subscribersQuery, + }; +} 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..a4c199621eb --- /dev/null +++ b/apps/dashboard/src/hooks/use-subscribers-url-state.ts @@ -0,0 +1,90 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SubscribersFiltersData } from '../components/subscribers/subscribers-filters'; +import { useDebounce } from '../hooks/use-debounce'; + +interface SubscribersFilters { + email?: string; + phone?: string; + name?: string; + subscriberId?: string; +} + +export interface SubscribersUrlState { + filters: SubscribersFilters; + filterValues: SubscribersFiltersData; +} + +function parseFilters(searchParams: URLSearchParams): SubscribersFilters { + const result: SubscribersFilters = {}; + + const email = searchParams.get('email'); + if (email) { + result.email = email; + } + + const phone = searchParams.get('phone'); + if (phone) { + result.phone = phone; + } + + const name = searchParams.get('name'); + if (name) { + result.name = name; + } + + const subscriberId = searchParams.get('subscriberId'); + if (subscriberId) { + result.subscriberId = subscriberId; + } + + return result; +} + +function parseFilterValues(searchParams: URLSearchParams): SubscribersFiltersData { + return { + email: searchParams.get('email') || '', + phone: searchParams.get('phone') || '', + name: searchParams.get('name') || '', + subscriberId: searchParams.get('subscriberId') || '', + }; +} + +export function useSubscribersUrlState(debounceMs: number = 300): SubscribersUrlState & { + handleFiltersChange: (data: SubscribersFiltersData) => void; +} { + const [searchParams, setSearchParams] = useSearchParams(); + + const updateSearchParams = useCallback( + (data: SubscribersFiltersData) => { + const newParams = new URLSearchParams(searchParams); + + if (data.email) { + newParams.set('email', data.email); + } + if (data.phone) { + newParams.set('phone', data.phone); + } + if (data.name) { + newParams.set('name', data.name); + } + if (data.subscriberId) { + newParams.set('subscriberId', data.subscriberId); + } + + setSearchParams(newParams, { replace: true }); + }, + [searchParams, setSearchParams] + ); + + const debouncedUpdateParams = useDebounce(updateSearchParams, debounceMs); + + const filters = useMemo(() => parseFilters(searchParams), [searchParams]); + const filterValues = useMemo(() => parseFilterValues(searchParams), [searchParams]); + + return { + filters, + filterValues, + handleFiltersChange: debouncedUpdateParams, + }; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 38cd5173ed2..09ea7e81eda 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -1,40 +1,41 @@ -import { StrictMode } from 'react'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { createRoot } from 'react-dom/client'; import ErrorPage from '@/components/error-page'; -import { RootRoute, AuthRoute, DashboardRoute, CatchAllRoute } from './routes'; -import { OnboardingParentRoute } from './routes/onboarding'; +import { ConfigureWorkflow } from '@/components/workflow-editor/configure-workflow'; +import { EditStepConditions } from '@/components/workflow-editor/steps/conditions/edit-step-conditions'; +import { ConfigureStep } from '@/components/workflow-editor/steps/configure-step'; +import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/configure-step-template'; import { - WorkflowsPage, - SignInPage, - SignUpPage, + ActivityFeed, + ApiKeysPage, + IntegrationsListPage, OrganizationListPage, QuestionnairePage, + SettingsPage, + SignInPage, + SignUpPage, UsecaseSelectPage, - ApiKeysPage, WelcomePage, - IntegrationsListPage, - SettingsPage, - ActivityFeed, + 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'; +import { CreateIntegrationSidebar } from './components/integrations/components/create-integration-sidebar'; +import { UpdateIntegrationSidebar } from './components/integrations/components/update-integration-sidebar'; +import { ChannelPreferences } from './components/workflow-editor/channel-preferences'; +import { FeatureFlagsProvider } from './context/feature-flags-provider'; import './index.css'; -import { ROUTES } from './utils/routes'; import { EditWorkflowPage } from './pages/edit-workflow'; -import { TestWorkflowPage } from './pages/test-workflow'; -import { initializeSentry } from './utils/sentry'; -import { overrideZodErrorMap } from './utils/validation'; -import { InboxUsecasePage } from './pages/inbox-usecase-page'; import { InboxEmbedPage } from './pages/inbox-embed-page'; -import { ConfigureWorkflow } from '@/components/workflow-editor/configure-workflow'; import { InboxEmbedSuccessPage } from './pages/inbox-embed-success-page'; -import { ChannelPreferences } from './components/workflow-editor/channel-preferences'; -import { FeatureFlagsProvider } from './context/feature-flags-provider'; -import { ConfigureStep } from '@/components/workflow-editor/steps/configure-step'; -import { ConfigureStepTemplate } from '@/components/workflow-editor/steps/configure-step-template'; +import { InboxUsecasePage } from './pages/inbox-usecase-page'; import { RedirectToLegacyStudioAuth } from './pages/redirect-to-legacy-studio-auth'; -import { CreateIntegrationSidebar } from './components/integrations/components/create-integration-sidebar'; -import { UpdateIntegrationSidebar } from './components/integrations/components/update-integration-sidebar'; -import { EditStepConditions } from '@/components/workflow-editor/steps/conditions/edit-step-conditions'; +import { TestWorkflowPage } from './pages/test-workflow'; +import { AuthRoute, CatchAllRoute, DashboardRoute, RootRoute } from './routes'; +import { OnboardingParentRoute } from './routes/onboarding'; +import { ROUTES } from './utils/routes'; +import { initializeSentry } from './utils/sentry'; +import { overrideZodErrorMap } from './utils/validation'; initializeSentry(); overrideZodErrorMap(); @@ -102,6 +103,10 @@ const router = createBrowserRouter([ path: ROUTES.WORKFLOWS, element: , }, + { + 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..a9b52996b6b --- /dev/null +++ b/apps/dashboard/src/pages/subscribers.tsx @@ -0,0 +1,30 @@ +import { DashboardLayout } from '@/components/dashboard-layout'; +import { PageMeta } from '@/components/page-meta'; +import { Badge } from '@/components/primitives/badge'; +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 BETA + + } + > + + + + ); +}; 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 869d2285989..d7fb1a4425f 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -29,6 +29,7 @@ export const ROUTES = { INTEGRATIONS_UPDATE: '/integrations/:integrationId/update', API_KEYS: '/env/:environmentSlug/api-keys', ACTIVITY_FEED: '/env/:environmentSlug/activity-feed', + 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 b87a0df82c3..855b7b5a736 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -42,4 +42,5 @@ export enum TelemetryEvent { TEMPLATE_MODAL_OPENED = 'Template Modal Opened - [Template Store]', TEMPLATE_CATEGORY_SELECTED = 'Template Category Selected - [Template Store]', CREATE_WORKFLOW_FROM_TEMPLATE = 'Create Workflow From Template - [Template Store]', + 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 9e18e7ba316..3b16e57d8ba 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,14 @@ export abstract class EnvironmentCommand extends BaseCommand { @IsNotEmpty() readonly organizationId: string; } +export abstract class CursorPaginatedCommand extends EnvironmentWithUserObjectCommand { + @IsDefined() + @IsNumber() + @Min(1) + @Max(100) + limit: number; + + @IsString() + @IsOptional() + cursor?: string; +} diff --git a/packages/js/scripts/size-limit.mjs b/packages/js/scripts/size-limit.mjs index 0eb499ea1bc..06026a25461 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'); @@ -15,7 +15,7 @@ const modules = [ { name: 'UMD minified', filePath: umdPath, - limitInBytes: 145_000, + limitInBytes: 150_000, }, { name: 'UMD gzip', diff --git a/packages/shared/src/dto/index.ts b/packages/shared/src/dto/index.ts index a5faebb0a92..46ba8075d99 100644 --- a/packages/shared/src/dto/index.ts +++ b/packages/shared/src/dto/index.ts @@ -16,3 +16,4 @@ export * from './topic'; export * from './widget'; export * from './workflow-override'; export * from './workflows'; +export * from './pagination'; diff --git a/packages/shared/src/dto/pagination/index.ts b/packages/shared/src/dto/pagination/index.ts new file mode 100644 index 00000000000..5f10edb7ba6 --- /dev/null +++ b/packages/shared/src/dto/pagination/index.ts @@ -0,0 +1 @@ +export * from './pagination.dto'; diff --git a/packages/shared/src/dto/pagination/pagination.dto.ts b/packages/shared/src/dto/pagination/pagination.dto.ts new file mode 100644 index 00000000000..b6b4cd85145 --- /dev/null +++ b/packages/shared/src/dto/pagination/pagination.dto.ts @@ -0,0 +1,8 @@ +import { DirectionEnum } from '../../types'; + +export class CursorPaginationDto { + limit?: number; + cursor?: string; + orderDirection?: DirectionEnum; + orderBy?: K; +} 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..47a1847ecae --- /dev/null +++ b/packages/shared/src/dto/subscriber/list-subscribers.dto.ts @@ -0,0 +1,33 @@ +import { ISubscriber } from '../../entities/subscriber'; +import { DirectionEnum } from '../../types/response'; +import { ISubscriberGetListQueryParams } from './subscriber.dto'; + +export interface IListSubscribersRequestDto extends ISubscriberGetListQueryParams { + limit: number; + + cursor?: string; + + orderDirection: DirectionEnum; + + orderBy: 'updatedAt' | 'createdAt' | 'lastOnlineAt'; + + query?: string; + + email?: string; + + phone?: string; + + subscriberId?: string; + + name?: string; +} + +export interface IListSubscribersResponseDto { + subscribers: ISubscriber[]; + + hasMore: boolean; + + pageSize: number; + + nextCursor?: string; +} diff --git a/packages/shared/src/dto/subscriber/subscriber.dto.ts b/packages/shared/src/dto/subscriber/subscriber.dto.ts index dac5a851e0c..82fe75c2ad1 100644 --- a/packages/shared/src/dto/subscriber/subscriber.dto.ts +++ b/packages/shared/src/dto/subscriber/subscriber.dto.ts @@ -1,4 +1,5 @@ import { ChatProviderIdEnum, ISubscriberChannel, PushProviderIdEnum } from '../../types'; +import { CursorPaginationDto } from '../pagination'; interface IChannelCredentials { webhookUrl?: string; @@ -24,7 +25,11 @@ export class SubscriberDto { subscriberId: string; channels?: IChannelSettings[]; deleted: boolean; + createdAt: string; + updatedAt: string; + lastOnlineAt?: string; } + export interface ISubscriberFeedResponseDto { _id?: string; firstName?: string; @@ -52,3 +57,11 @@ export interface ISubscriberResponseDto { updatedAt: string; __v?: number; } + +export interface ISubscriberGetListQueryParams + extends CursorPaginationDto { + query?: string; + email?: string; + phone?: string; + subscriberId?: string; +}