Skip to content

Commit

Permalink
refactor(api): improve notification and email handling (#5683)
Browse files Browse the repository at this point in the history
* refactor(api): improve notification and email handling

* refactor(invites): update webhook DTO and use typed headers

* feat(env): add HubSpot environment variables

* refactor(invites): rename invite-nudge-webhook to invite-nudge
  • Loading branch information
rifont authored Jun 5, 2024
1 parent dccff5d commit 2f4290e
Show file tree
Hide file tree
Showing 11 changed files with 135 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { merge } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import {
buildNotificationTemplateIdentifierKey,
buildHasNotificationKey,
CachedEntity,
Instrument,
InstrumentUsecase,
Expand All @@ -15,6 +16,7 @@ import {
AnalyticsService,
GetFeatureFlag,
GetFeatureFlagCommand,
InvalidateCacheService,
} from '@novu/application-generic';
import {
FeatureFlagsKeysEnum,
Expand Down Expand Up @@ -55,11 +57,12 @@ export class ParseEventRequest {
private tenantRepository: TenantRepository,
private workflowOverrideRepository: WorkflowOverrideRepository,
private analyticsService: AnalyticsService,
private getFeatureFlag: GetFeatureFlag
private getFeatureFlag: GetFeatureFlag,
private invalidateCacheService: InvalidateCacheService
) {}

@InstrumentUsecase()
async execute(command: ParseEventRequestCommand) {
public async execute(command: ParseEventRequestCommand) {
const transactionId = command.transactionId || uuidv4();

const template = await this.getNotificationTemplateByTriggerIdentifier({
Expand Down Expand Up @@ -155,18 +158,7 @@ export class ParseEventRequest {
transactionId,
};

const isEnabled = await this.getFeatureFlag.execute(
GetFeatureFlagCommand.create({
key: FeatureFlagsKeysEnum.IS_TEAM_MEMBER_INVITE_NUDGE_ENABLED,
organizationId: command.organizationId,
userId: 'system',
environmentId: 'system',
})
);

if (isEnabled && (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production')) {
await this.sendInAppNudgeForTeamMemberInvite(command);
}
await this.sendInAppNudgeForTeamMemberInvite(command);

await this.workflowQueueService.add({ name: transactionId, data: jobData, groupId: command.organizationId });

Expand Down Expand Up @@ -223,7 +215,7 @@ export class ParseEventRequest {
}
}

private modifyAttachments(command: ParseEventRequestCommand) {
private modifyAttachments(command: ParseEventRequestCommand): void {
command.payload.attachments = command.payload.attachments.map((attachment) => ({
...attachment,
name: attachment.name,
Expand All @@ -232,52 +224,89 @@ export class ParseEventRequest {
}));
}

public getReservedVariablesTypes(template: NotificationTemplateEntity): TriggerContextTypeEnum[] {
private getReservedVariablesTypes(template: NotificationTemplateEntity): TriggerContextTypeEnum[] {
const reservedVariables = template.triggers[0].reservedVariables;

return reservedVariables?.map((reservedVariable) => reservedVariable.type) || [];
}

public async sendInAppNudgeForTeamMemberInvite(command: ParseEventRequestCommand) {
@Instrument()
@CachedEntity({
builder: (command: ParseEventRequestCommand) =>
buildHasNotificationKey({
_organizationId: command.organizationId,
}),
})
private async getNotificationCount(command: ParseEventRequestCommand): Promise<number> {
return await this.notificationRepository.count(
{
_organizationId: command.organizationId,
},
1
);
}

@Instrument()
private async sendInAppNudgeForTeamMemberInvite(command: ParseEventRequestCommand): Promise<void> {
const isEnabled = await this.getFeatureFlag.execute(
GetFeatureFlagCommand.create({
key: FeatureFlagsKeysEnum.IS_TEAM_MEMBER_INVITE_NUDGE_ENABLED,
organizationId: command.organizationId,
userId: 'system',
environmentId: 'system',
})
);

if (!isEnabled) return;

// check if this is first trigger
const notification = await this.notificationRepository.findOne({
_organizationId: command.organizationId,
_environmentId: command.environmentId,
});
const notificationCount = await this.getNotificationCount(command);

if (notificationCount > 0) return;

if (notification) return;
// After the first trigger, we invalidate the cache to ensure the next event trigger
// will update the cache with a count of 1.
this.invalidateCacheService.invalidateByKey({
key: buildHasNotificationKey({
_organizationId: command.organizationId,
}),
});

// check if user is using personal email
const user = await this.userRepository.findOne({
_id: command.userId,
});

if (this.checkEmail(user?.email)) return;
if (!user) throw new ApiException('User not found');

if (this.isBlockedEmail(user.email)) return;

// check if organization has more than 1 member
const membersCount = await this.memberRepository.count({
_organizationId: command.organizationId,
});
const membersCount = await this.memberRepository.count(
{
_organizationId: command.organizationId,
},
2
);

if (membersCount > 1) return;

if ((process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production') && process.env.NOVU_API_KEY) {
Logger.log('No notification found', LOG_CONTEXT);

if (process.env.NOVU_API_KEY) {
if (!command.payload[INVITE_TEAM_MEMBER_NUDGE_PAYLOAD_KEY]) {
const novu = new Novu(process.env.NOVU_API_KEY);

novu.trigger(
process.env.NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER || 'in-app-invite-team-member-nudge',
{
to: {
subscriberId: command.userId,
email: user?.email as string,
},
payload: {
[INVITE_TEAM_MEMBER_NUDGE_PAYLOAD_KEY]: true,
webhookUrl: `${process.env.API_ROOT_URL}/v1/invites/webhook`,
},
}
);
await novu.trigger(process.env.NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER, {
to: {
subscriberId: command.userId,
email: user?.email as string,
},
payload: {
[INVITE_TEAM_MEMBER_NUDGE_PAYLOAD_KEY]: true,
webhookUrl: `${process.env.API_ROOT_URL}/v1/invites/webhook`,
},
});

this.analyticsService.track('Invite Nudge Sent', command.userId, {
_organization: command.organizationId,
Expand All @@ -286,19 +315,19 @@ export class ParseEventRequest {
}
}

public checkEmail(email) {
const includedDomains = [
'@gmail',
'@outlook',
'@yahoo',
'@icloud',
'@mail',
'@hotmail',
'@protonmail',
'@gmx',
'@novu',
];

return includedDomains.some((domain) => email.includes(domain));
private isBlockedEmail(email: string): boolean {
return BLOCKED_DOMAINS.some((domain) => email.includes(domain));
}
}

const BLOCKED_DOMAINS = [
'@gmail',
'@outlook',
'@yahoo',
'@icloud',
'@mail',
'@hotmail',
'@protonmail',
'@gmx',
'@novu',
];
11 changes: 10 additions & 1 deletion apps/api/src/app/invites/dtos/invite-member.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
import { IsEmail, IsNotEmpty, IsObject, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { SubscriberEntity } from '@novu/dal';

export class InviteMemberDto {
@IsEmail()
@IsNotEmpty()
email: string;
}

export class InviteWebhookDto {
@IsObject()
@ValidateNested()
@Type(() => SubscriberEntity)
subscriber: SubscriberEntity;
}
12 changes: 6 additions & 6 deletions apps/api/src/app/invites/invites.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { UserSession } from '../shared/framework/user.decorator';
import { GetInviteCommand } from './usecases/get-invite/get-invite.command';
import { AcceptInviteCommand } from './usecases/accept-invite/accept-invite.command';
import { Roles } from '../auth/framework/roles.decorator';
import { InviteMemberDto } from './dtos/invite-member.dto';
import { InviteMemberDto, InviteWebhookDto } from './dtos/invite-member.dto';
import { InviteMemberCommand } from './usecases/invite-member/invite-member.command';
import { BulkInviteMembersDto } from './dtos/bulk-invite-members.dto';
import { BulkInviteCommand } from './usecases/bulk-invite/bulk-invite.command';
Expand All @@ -35,8 +35,8 @@ import { ApiExcludeController, ApiTags } from '@nestjs/swagger';
import { ThrottlerCost } from '../rate-limiting/guards';
import { ApiCommonResponses } from '../shared/framework/response.decorator';
import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { InviteNudgeWebhookCommand } from './usecases/invite-nudge-webhook/invite-nudge-command';
import { InviteNudgeWebhook } from './usecases/invite-nudge-webhook/invite-nudge-usecase';
import { InviteNudgeWebhookCommand } from './usecases/invite-nudge/invite-nudge.command';
import { InviteNudgeWebhook } from './usecases/invite-nudge/invite-nudge.usecase';

@UseInterceptors(ClassSerializerInterceptor)
@ApiCommonResponses()
Expand Down Expand Up @@ -134,10 +134,10 @@ export class InvitesController {
}

@Post('/webhook')
async inviteCheckWebhook(@Headers() headers: Record<string, string>, @Body() body: Record<string, any>) {
async inviteCheckWebhook(@Headers('nv-hmac-256') hmacHeader: string, @Body() body: InviteWebhookDto) {
const command = InviteNudgeWebhookCommand.create({
headers,
body,
hmacHeader,
subscriber: body.subscriber,
});

const response = await this.inviteNudgeWebhookUsecase.execute(command);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/invites/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { GetInvite } from './get-invite/get-invite.usecase';
import { BulkInvite } from './bulk-invite/bulk-invite.usecase';
import { InviteMember } from './invite-member/invite-member.usecase';
import { ResendInvite } from './resend-invite/resend-invite.usecase';
import { InviteNudgeWebhook } from './invite-nudge-webhook/invite-nudge-usecase';
import { InviteNudgeWebhook } from './invite-nudge/invite-nudge.usecase';

export const USE_CASES = [AcceptInvite, GetInvite, BulkInvite, InviteMember, ResendInvite, InviteNudgeWebhook];

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsObject, IsString } from 'class-validator';
import { SubscriberEntity } from '@novu/dal';
import { BaseCommand } from '../../../shared/commands/base.command';

export class InviteNudgeWebhookCommand extends BaseCommand {
@IsString()
hmacHeader: string;

@IsObject()
subscriber: SubscriberEntity;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { GetFeatureFlag, GetFeatureFlagCommand, createHash } from '@novu/applica
import { FeatureFlagsKeysEnum } from '@novu/shared';
import axios from 'axios';

import { InviteNudgeWebhookCommand } from './invite-nudge-command';
import { InviteNudgeWebhookCommand } from './invite-nudge.command';

const axiosInstance = axios.create();

Expand All @@ -18,33 +18,29 @@ export class InviteNudgeWebhook {
const isEnabled = await this.getFeatureFlag.execute(
GetFeatureFlagCommand.create({
key: FeatureFlagsKeysEnum.IS_TEAM_MEMBER_INVITE_NUDGE_ENABLED,
organizationId: command.body?.subscriber?._organizationId,
organizationId: command.subscriber._organizationId,
userId: 'system',
environmentId: 'system',
})
);

if (
isEnabled &&
(process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'production') &&
process.env.NOVU_API_KEY
) {
const hmacHash = createHash(process.env.NOVU_API_KEY || '', command?.body?.subscriber?._environmentId || '');
const hmacHashFromWebhook = command?.headers?.['nv-hmac-256'];
if (isEnabled && process.env.NOVU_API_KEY) {
const hmacHash = createHash(process.env.NOVU_API_KEY, command.subscriber._environmentId);
const hmacHashFromWebhook = command.hmacHeader;

if (hmacHash !== hmacHashFromWebhook) {
throw new Error('Unauthorized request');
}

const membersCount = await this.memberRepository.count({
_organizationId: command?.body?.subscriber?._organizationId,
_organizationId: command.subscriber._organizationId,
});

if (membersCount === 1) {
await axiosInstance.post(
`https://api.hubapi.com/contacts/v1/lists/${process.env.HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID}/add`,
{
emails: [command?.body?.subscriber?.email],
emails: [command.subscriber.email],
},
{
headers: {
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/types/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ declare global {
NOTIFICATION_RETENTION_DAYS?: number;
MESSAGE_GENERIC_RETENTION_DAYS?: number;
MESSAGE_IN_APP_RETENTION_DAYS?: number;
NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: string;
HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID: string;
HUBSPOT_PRIVATE_APP_ACCESS_TOKEN: string;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ export const buildEvaluateApiRateLimitKey = ({
identifier: apiRateLimitCategory,
});

export const buildHasNotificationKey = ({
_organizationId,
}: {
_organizationId: string;
}): string =>
buildOrganizationScopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.HAS_NOTIFICATION,
organizationId: _organizationId,
});

export const buildUsageKey = ({
_organizationId,
resourceType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum CacheKeyPrefixEnum {
SERVICE_CONFIG = 'service_config',
SUBSCRIPTION = 'subscription',
USAGE = 'usage',
HAS_NOTIFICATION = 'has_notification',
}

/**
Expand Down
4 changes: 2 additions & 2 deletions libs/dal/src/repositories/user/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ export class UserEntity {

resetTokenCount?: IUserResetTokenCount;

firstName?: string | null;
firstName: string;

lastName?: string | null;

email?: string | null;
email: string;

profilePicture?: string | null;

Expand Down

0 comments on commit 2f4290e

Please sign in to comment.