Skip to content

Commit

Permalink
feat(api-service,dashboard): New subscribers page and api
Browse files Browse the repository at this point in the history
  • Loading branch information
desiprisg committed Jan 16, 2025
1 parent c5fd5c7 commit 307737d
Show file tree
Hide file tree
Showing 21 changed files with 505 additions and 46 deletions.
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> => {
const modules: Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> = [];
Expand Down Expand Up @@ -97,6 +98,7 @@ const baseModules: Array<Type | DynamicModule | Promise<DynamicModule> | Forward
IntegrationModule,
ChangeModule,
SubscribersModule,
SubscriberModule,
FeedsModule,
LayoutsModule,
MessagesModule,
Expand Down
55 changes: 55 additions & 0 deletions apps/api/src/app/subscribers-v2/dtos/subscriber-pagination.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ApiProperty } from '@nestjs/swagger';
import { DirectionEnum, SubscriberPaginationDto } from '@novu/shared';
import { Type } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';

export class SubscriberPaginationRequestDto implements SubscriberPaginationDto {
@ApiProperty({
description: 'Number of items per page',
required: false,
default: 10,
maximum: 100,
})
@IsNumber()
@Min(1)
@Max(100)
@IsOptional()
@Type(() => Number)
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;
}
53 changes: 53 additions & 0 deletions apps/api/src/app/subscribers-v2/subscriber.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ClassSerializerInterceptor } from '@nestjs/common';
import { UserSession, UserAuthGuard } from '@novu/application-generic';
import { ApiCommonResponses } from '../shared/framework/response.decorator';
import { DirectionEnum, ListSubscriberResponse, UserSessionData } from '@novu/shared';

import { ListSubscribersCommand } from './usecases/list-subscribers/list-subscribers.command';
import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase';
import { SubscriberPaginationRequestDto } from './dtos/subscriber-pagination.dto';

interface IListSubscriberResponse {
data: ISubscriberResponseDto[];
hasMore: boolean;
page: number;
pageSize: number;
}

@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: SubscriberPaginationRequestDto
): Promise<ListSubscriberResponse> {
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,
})
);
}
}
13 changes: 13 additions & 0 deletions apps/api/src/app/subscribers-v2/subscriber.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { IsNumber, IsOptional, IsString, Max, Min, IsEnum } from 'class-validator';
import { EnvironmentCommand } from '../../../shared/commands/project.command';
import { DirectionEnum } from '@novu/shared';

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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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';

@Injectable()
export class ListSubscribersUseCase {
constructor(private subscriberRepository: SubscriberRepository) {}

@InstrumentUsecase()
async execute(command: ListSubscribersCommand): Promise<ListSubscriberResponse> {
const query = {
_environmentId: command.environmentId,
_organizationId: command.organizationId,
} as const;

if (command.query) {
Object.assign(query, {
$or: [
{ 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.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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -25,7 +25,7 @@ export class SearchByExternalSubscriberIds {
}

private mapFromEntity(entity: SubscriberEntity): SubscriberDto {
const { _id, createdAt, updatedAt, ...rest } = entity;
const { _id, ...rest } = entity;

return {
...rest,
Expand Down
22 changes: 22 additions & 0 deletions apps/dashboard/src/api/subscribers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { IEnvironment, ListSubscriberResponse } from '@novu/shared';
import { getV2 } from './api.client';

export const getSubscribers = async ({
environment,
cursor,
limit,
query,
}: {
environment: IEnvironment;
cursor: string;
query: string;
limit: number;
}): Promise<ListSubscriberResponse> => {
const { data } = await getV2<{ data: ListSubscriberResponse }>(
`/subscribers?cursor=${cursor}&limit=${limit}&query=${query}`,
{
environment,
}
);
return data;
};
33 changes: 14 additions & 19 deletions apps/dashboard/src/components/side-navigation/side-navigation.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -68,14 +67,10 @@ export const SideNavigation = () => {
<RiRouteFill className="size-4" />
<span>Workflows</span>
</NavigationLink>
<SubscribersStayTunedModal>
<span onClick={() => track(TelemetryEvent.SUBSCRIBERS_LINK_CLICKED)}>
<NavigationLink>
<RiGroup2Line className="size-4" />
<span>Subscribers</span>
</NavigationLink>
</span>
</SubscribersStayTunedModal>
<NavigationLink to={buildRoute(ROUTES.SUBSCRIBERS, { environmentSlug: currentEnvironment?.slug ?? '' })}>
<RiGroup2Line className="size-4" />
<span>Subscribers</span>
</NavigationLink>
</NavigationGroup>
<NavigationGroup label="Monitor">
<NavigationLink
Expand Down
70 changes: 70 additions & 0 deletions apps/dashboard/src/components/subscriber-list-empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { CreateWorkflowButton } from '@/components/create-workflow-button';
import { VersionControlDev } from '@/components/icons/version-control-dev';
import { VersionControlProd } from '@/components/icons/version-control-prod';
import { Button } from '@/components/primitives/button';
import { useEnvironment } from '@/context/environment/hooks';
import { RiBookMarkedLine, RiRouteFill } from 'react-icons/ri';
import { Link } from 'react-router-dom';
import { LinkButton } from './primitives/button-link';

export const SubscriberListEmpty = () => {
const { currentEnvironment, switchEnvironment, oppositeEnvironment } = useEnvironment();

const isProd = currentEnvironment?.name === 'Production';

return isProd ? (
<WorkflowListEmptyProd switchToDev={() => switchEnvironment(oppositeEnvironment?.slug)} />
) : (
<WorkflowListEmptyDev />
);
};

const WorkflowListEmptyProd = ({ switchToDev }: { switchToDev: () => void }) => (
<div className="flex h-full w-full flex-col items-center justify-center gap-6">
<VersionControlProd />
<div className="flex flex-col items-center gap-2 text-center">
<span className="text-foreground-900 block font-medium">No workflows in production</span>
<p className="text-foreground-400 max-w-[60ch] text-sm">
To sync workflows to production, switch to Development environment, select a workflow and click on 'Sync to
Production,' or sync via novu CLI for code-first workflows.
</p>
</div>

<div className="flex items-center justify-center gap-6">
<Link to={'https://docs.novu.co/concepts/workflows'} target="_blank">
<LinkButton trailingIcon={RiBookMarkedLine}>View docs</LinkButton>
</Link>

<Button variant="secondary" className="gap-2" onClick={switchToDev}>
Switch to Development
</Button>
</div>
</div>
);

const WorkflowListEmptyDev = () => (
<div className="flex h-full w-full flex-col items-center justify-center gap-6">
<VersionControlDev />
<div className="flex flex-col items-center gap-2 text-center">
<span className="text-foreground-900 block font-medium">Create your first workflow to send notifications</span>
<p className="text-foreground-400 max-w-[60ch] text-sm">
Workflows handle notifications across multiple channels in a single, version-controlled flow, with the ability
to manage preference for each subscriber.
</p>
</div>

<div className="flex items-center justify-center gap-6">
<Link to={'https://docs.novu.co/concepts/workflows'} target="_blank">
<LinkButton variant="gray" trailingIcon={RiBookMarkedLine}>
View docs
</LinkButton>
</Link>

<CreateWorkflowButton asChild>
<Button variant="primary" leadingIcon={RiRouteFill} className="gap-2">
Create workflow
</Button>
</CreateWorkflowButton>
</div>
</div>
);
Loading

0 comments on commit 307737d

Please sign in to comment.