Skip to content

Commit

Permalink
Merge branch 'next' into update-javis-to-use-docker
Browse files Browse the repository at this point in the history
  • Loading branch information
Cliftonz authored Jun 6, 2024
2 parents a78d8d5 + cac9cba commit 12a556e
Show file tree
Hide file tree
Showing 53 changed files with 1,407 additions and 438 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,17 +158,10 @@ 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')) {
try {
await this.sendInAppNudgeForTeamMemberInvite(command);
} catch (error) {
Logger.error(error, 'Invite nudge failed', LOG_CONTEXT);
}

await this.workflowQueueService.add({ name: transactionId, data: jobData, groupId: command.organizationId });
Expand Down Expand Up @@ -223,7 +219,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 +228,91 @@ 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 (notification) return;
if (notificationCount > 0) 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 +321,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
@@ -1,10 +1,10 @@
import { Injectable, Scope } from '@nestjs/common';
import { Injectable, Scope, Logger } from '@nestjs/common';
import { MemberRepository } from '@novu/dal';
import { GetFeatureFlag, GetFeatureFlagCommand, createHash } from '@novu/application-generic';
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,40 +18,41 @@ 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(
const hubspotAddUserIntoListResponse = 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: {
Authorization: `Bearer ${process.env.HUBSPOT_PRIVATE_APP_ACCESS_TOKEN}`,
},
}
);
if (hubspotAddUserIntoListResponse.data.updated.length !== 1) {
Logger.log(
`Failed to add user ${command.subscriber.email} into list ${process.env.HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID}`
);
}
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/widgets/widgets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ export class WidgetsController {
): Promise<LogUsageResponseDto> {
this.analyticsService.track(body.name, subscriberSession._organizationId, {
environmentId: subscriberSession._environmentId,
_organization: subscriberSession._organizationId,
...(body.payload || {}),
});

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;
}
}
}
4 changes: 2 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@
"@faker-js/faker": "^6.0.0",
"@novu/dal": "workspace:*",
"@novu/testing": "workspace:*",
"@pandacss/dev": "^0.38.0",
"@pandacss/studio": "^0.38.0",
"@pandacss/dev": "^0.40.1",
"@pandacss/studio": "^0.40.1",
"@playwright/test": "^1.44.0",
"@storybook/addon-actions": "^7.4.2",
"@storybook/addon-essentials": "^7.4.2",
Expand Down
Loading

0 comments on commit 12a556e

Please sign in to comment.