Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(api): improve notification and email handling #5683

Merged
merged 4 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading