From 1f79320aaf178e5eea7184e3023cb64768fe6675 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 + .../dtos/list-subscribers-request.dto.ts.ts | 79 ++++++++++++++++++ .../dtos/list-subscribers-response.dto.ts | 24 ++++++ .../subscribers-v2/subscriber.controller.ts | 47 +++++++++++ .../app/subscribers-v2/subscriber.module.ts | 13 +++ .../list-subscribers.command.ts | 34 ++++++++ .../list-subscribers.usecase.ts | 73 +++++++++++++++++ ...rch-by-external-subscriber-ids.use-case.ts | 4 +- apps/dashboard/src/api/subscribers.ts | 28 +++++++ .../icons/add-subscriber-illustration.tsx | 25 ++++++ .../side-navigation/side-navigation.tsx | 33 ++++---- .../src/components/subscriber-list-empty.tsx | 31 +++++++ .../src/components/subscriber-list.tsx | 80 +++++++++++++++++++ .../src/hooks/use-fetch-subscribers.ts | 41 ++++++++++ apps/dashboard/src/main.tsx | 55 +++++++------ apps/dashboard/src/pages/subscribers.tsx | 26 ++++++ apps/dashboard/src/utils/query-keys.ts | 1 + apps/dashboard/src/utils/routes.ts | 1 + apps/dashboard/src/utils/telemetry.ts | 1 + packages/shared/src/dto/index.ts | 1 + packages/shared/src/dto/pagination/index.ts | 1 + .../src/dto/pagination/pagination.dto.ts | 8 ++ .../src/dto/subscriber/subscriber.dto.ts | 23 ++++++ 23 files changed, 585 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/app/subscribers-v2/dtos/list-subscribers-request.dto.ts.ts create mode 100644 apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.dto.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/icons/add-subscriber-illustration.tsx create mode 100644 apps/dashboard/src/components/subscriber-list-empty.tsx create mode 100644 apps/dashboard/src/components/subscriber-list.tsx create mode 100644 apps/dashboard/src/hooks/use-fetch-subscribers.ts create mode 100644 apps/dashboard/src/pages/subscribers.tsx create mode 100644 packages/shared/src/dto/pagination/index.ts create mode 100644 packages/shared/src/dto/pagination/pagination.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/dtos/list-subscribers-request.dto.ts.ts b/apps/api/src/app/subscribers-v2/dtos/list-subscribers-request.dto.ts.ts new file mode 100644 index 00000000000..49ce6ffd156 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/dtos/list-subscribers-request.dto.ts.ts @@ -0,0 +1,79 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { DirectionEnum, SubscriberGetListQueryParams } from '@novu/shared'; +import { Type } from 'class-transformer'; +import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListSubscribersRequestDto implements SubscriberGetListQueryParams { + @ApiProperty({ + description: 'Number of items per page', + required: false, + default: 10, + maximum: 100, + }) + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + @IsOptional() + limit?: number = 10; + + @ApiProperty({ + description: 'Cursor for pagination', + required: false, + }) + @IsString() + @IsOptional() + cursor?: string; + + @ApiProperty({ + description: 'Sort direction', + required: false, + enum: DirectionEnum, + default: DirectionEnum.DESC, + }) + @IsEnum(DirectionEnum) + @IsOptional() + orderDirection?: DirectionEnum = DirectionEnum.DESC; + + @ApiProperty({ + description: 'Field to order by', + required: false, + enum: ['updatedAt', 'createdAt', 'lastOnlineAt'], + default: 'createdAt', + }) + @IsEnum(['updatedAt', 'createdAt', 'lastOnlineAt']) + @IsOptional() + orderBy?: 'updatedAt' | 'createdAt' | 'lastOnlineAt' = 'createdAt'; + + @ApiProperty({ + description: 'Search query to filter subscribers by name, email, phone, or subscriberId', + required: false, + }) + @IsString() + @IsOptional() + query?: string; + + @ApiProperty({ + description: 'Email to filter subscribers by', + required: false, + }) + @IsString() + @IsOptional() + email?: string; + + @ApiProperty({ + description: 'Subscriber ID to filter subscribers by', + required: false, + }) + @IsString() + @IsOptional() + subscriberId?: string; + + @ApiProperty({ + description: 'Phone number to filter subscribers by', + required: false, + }) + @IsString() + @IsOptional() + phone?: string; +} diff --git a/apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.dto.ts b/apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.dto.ts new file mode 100644 index 00000000000..f2ca3ca36ae --- /dev/null +++ b/apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ISubscriber } from '@novu/shared'; +import { SubscriberResponseDto } from '../../subscribers/dtos'; + +export class ListSubscribersResponseDto { + @ApiProperty({ + description: 'Array of subscriber objects', + type: [SubscriberResponseDto], + }) + subscribers: ISubscriber[]; + + @ApiProperty({ + description: 'Indicates if there are more subscribers to fetch', + required: false, + type: Boolean, + }) + hasMore: boolean; + + @ApiProperty({ + description: 'Number of subscribers in the current page', + type: Number, + }) + pageSize: number; +} 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..a701a756445 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/subscriber.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Query, UseGuards, UseInterceptors, ClassSerializerInterceptor } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UserSession, UserAuthGuard } from '@novu/application-generic'; +import { DirectionEnum, ListSubscriberResponse, 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'; +import { ListSubscribersRequestDto } from './dtos/list-subscribers-request.dto.ts'; + +@Controller({ path: '/subscribers', version: '2' }) +@UseInterceptors(ClassSerializerInterceptor) +@UseGuards(UserAuthGuard) +@ApiTags('Subscribers') +@ApiCommonResponses() +export class SubscriberController { + constructor(private listSubscribersUsecase: ListSubscribersUseCase) {} + + @Get('') + @ApiOperation({ + summary: 'List subscribers', + description: 'Returns a paginated list of subscribers', + }) + @ApiResponse({ + status: 200, + description: 'A list of subscribers with pagination information', + type: ListSubscriberResponse, + }) + async getSubscribers( + @UserSession() user: UserSessionData, + @Query() query: ListSubscribersRequestDto + ): Promise { + return await this.listSubscribersUsecase.execute( + ListSubscribersCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + limit: query.limit || 10, + cursor: query.cursor, + orderDirection: query.orderDirection || DirectionEnum.DESC, + orderBy: query.orderBy || 'createdAt', + query: query.query, + email: query.email, + phone: query.phone, + }) + ); + } +} 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..fd10171d2a7 --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts @@ -0,0 +1,34 @@ +import { DirectionEnum } from '@novu/shared'; +import { IsNumber, IsOptional, IsString, Max, Min, IsEnum } from 'class-validator'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class ListSubscribersCommand extends EnvironmentCommand { + @IsNumber() + @Min(1) + @Max(100) + limit: number; + + @IsString() + @IsOptional() + cursor?: string; + + @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; +} 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..1c0abd04fdb --- /dev/null +++ b/apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { SubscriberRepository } from '@novu/dal'; +import { InstrumentUsecase } from '@novu/application-generic'; +import { DirectionEnum, ISubscriberResponseDto, ListSubscriberResponse } from '@novu/shared'; +import { ListSubscribersCommand } from './list-subscribers.command'; +import { ListSubscribersResponseDto } from '../../dtos/list-subscribers-response.dto'; + +@Injectable() +export class ListSubscribersUseCase { + constructor(private subscriberRepository: SubscriberRepository) {} + + @InstrumentUsecase() + async execute(command: ListSubscribersCommand): Promise { + const query = { + _environmentId: command.environmentId, + _organizationId: command.organizationId, + } as const; + + if (command.query || command.email || command.phone) { + 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' } }); + } + + Object.assign(query, { $or: searchConditions }); + } + + if (command.cursor) { + const operator = command.orderDirection === DirectionEnum.ASC ? '$gt' : '$lt'; + Object.assign(query, { + [command.orderBy]: { [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, + }; + } +} 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..01a91f80a99 --- /dev/null +++ b/apps/dashboard/src/api/subscribers.ts @@ -0,0 +1,28 @@ +import type { IEnvironment, ListSubscriberResponse } from '@novu/shared'; +import { getV2 } from './api.client'; + +export const getSubscribers = async ({ + environment, + cursor, + limit, + query, + email, + phone, + subscriberId, +}: { + environment: IEnvironment; + cursor: string; + query: string; + limit: number; + email?: string; + phone?: string; + subscriberId?: string; +}): Promise => { + const { data } = await getV2<{ data: ListSubscriberResponse }>( + `/subscribers?cursor=${cursor}&limit=${limit}&query=${query}&email=${email}&phone=${phone}&subscriberId=${subscriberId}`, + { + environment, + } + ); + return data; +}; 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/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/subscriber-list.tsx b/apps/dashboard/src/components/subscriber-list.tsx new file mode 100644 index 00000000000..bb11b148ce4 --- /dev/null +++ b/apps/dashboard/src/components/subscriber-list.tsx @@ -0,0 +1,80 @@ +import { Skeleton } from '@/components/primitives/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/primitives/table'; +import { SubscriberListEmpty } from '@/components/subscriber-list-empty'; +import { useFetchSubscribers } from '@/hooks/use-fetch-subscribers'; +import { RiMore2Fill } from 'react-icons/ri'; +import { useSearchParams } from 'react-router-dom'; + +export function SubscriberList() { + const [searchParams] = useSearchParams(); + + const cursor = searchParams.get('cursor') || ''; + const query = searchParams.get('query') || ''; + const email = searchParams.get('email') || ''; + const phone = searchParams.get('phone') || ''; + const subscriberId = searchParams.get('subscriberId') || ''; + const limit = parseInt(searchParams.get('limit') || '12'); + + const { data, isPending, isError } = useFetchSubscribers({ + cursor, + limit, + query, + email, + phone, + subscriberId, + }); + + if (isError) return null; + + if (!isPending && data?.subscribers.length === 0) { + return ; + } + + return ( +
+ + + + Subscribers + Email address + Phone number + Created at + Updated at + + + + + {isPending ? ( + <> + {new Array(limit).fill(0).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + + ))} + + ) : ( + <>{data.subscribers.map((subscriber) => subscriber.subscriberId)} + )} + +
+
+ ); +} 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..4a359e1db91 --- /dev/null +++ b/apps/dashboard/src/hooks/use-fetch-subscribers.ts @@ -0,0 +1,41 @@ +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; + subscriberId?: string; + limit?: number; +} + +export function useFetchSubscribers({ + cursor = '', + query = '', + email = '', + phone = '', + subscriberId = '', + limit = 12, +}: UseSubscribersParams = {}) { + const { currentEnvironment } = useEnvironment(); + + const subscribersQuery = useQuery({ + queryKey: [ + QueryKeys.fetchSubscribers, + currentEnvironment?._id, + { cursor, limit, query, email, phone, subscriberId }, + ], + queryFn: () => + getSubscribers({ environment: currentEnvironment!, cursor, limit, query, email, phone, subscriberId }), + placeholderData: keepPreviousData, + enabled: !!currentEnvironment?._id, + refetchOnWindowFocus: true, + }); + + return { + ...subscribersQuery, + }; +} 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..2dfe4ffadb9 --- /dev/null +++ b/apps/dashboard/src/pages/subscribers.tsx @@ -0,0 +1,26 @@ +import { DashboardLayout } from '@/components/dashboard-layout'; +import { PageMeta } from '@/components/page-meta'; +import { SubscriberList } from '@/components/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/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 361a526f848..2349d2a1e07 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -39,4 +39,5 @@ export enum TelemetryEvent { SHARE_FEEDBACK_LINK_CLICKED = 'Share feedback link clicked', VARIABLE_POPOVER_OPENED = 'Variable popover opened - [Variable Editor]', VARIABLE_POPOVER_APPLIED = 'Variable popover applied - [Variable Editor]', + SUBSCRIBERS_PAGE_VISIT = 'Subscribers page visit', } 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/subscriber.dto.ts b/packages/shared/src/dto/subscriber/subscriber.dto.ts index dac5a851e0c..044598ffbd7 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,21 @@ export interface ISubscriberResponseDto { updatedAt: string; __v?: number; } + +export type KeysOfSubscriberDto = keyof SubscriberDto; + +export class SubscriberGetListQueryParams extends CursorPaginationDto< + ISubscriberResponseDto, + 'updatedAt' | 'createdAt' | 'lastOnlineAt' +> { + query?: string; + email?: string; + phone?: string; + subscriberId?: string; +} + +export class ListSubscriberResponse { + subscribers: ISubscriberResponseDto[]; + hasMore: boolean; + pageSize: number; +}