Skip to content

Commit

Permalink
feat: add push category + fcm base
Browse files Browse the repository at this point in the history
  • Loading branch information
raikasdev committed Jun 29, 2022
1 parent 138abf3 commit 162936c
Show file tree
Hide file tree
Showing 75 changed files with 1,444 additions and 95 deletions.
Empty file added .npmrc
Empty file.
2 changes: 1 addition & 1 deletion _templates/provider/new/prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module.exports = [
type: 'select',
name: 'type',
message: 'What type of provider is this?',
choices: ['EMAIL', 'SMS'],
choices: ['EMAIL', 'SMS', 'PUSH'],
},
{
type: 'input',
Expand Down
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@nestjsx/crud": "^4.6.2",
"@novu/dal": "^0.5.1",
"@novu/emailjs": "^0.5.1",
"@novu/fcm": "^0.5.1",
"@novu/mailgun": "^0.5.1",
"@novu/mailjet": "^0.5.1",
"@novu/mandrill": "^0.5.1",
Expand Down Expand Up @@ -81,6 +82,7 @@
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-jwt": "^4.0.0",
"passport-oauth2": "^1.6.1",
"recursive-diff": "^1.0.8",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.5.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class PromoteMessageTemplateChange {
subject: newItem.subject,
content: newItem.content,
contentType: newItem.contentType,
title: newItem.title,
cta: newItem.cta,
active: newItem.active,
_parentId: newItem._id,
Expand All @@ -40,6 +41,7 @@ export class PromoteMessageTemplateChange {
subject: newItem.subject,
content: newItem.content,
contentType: newItem.contentType,
title: newItem.title,
cta: newItem.cta,
active: newItem.active,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { IPushOptions, IPushProvider } from '@novu/stateless';
import { ChannelTypeEnum } from '@novu/shared';
import { ICredentials } from '@novu/dal';
import { IPushHandler } from '../interfaces';

export abstract class BasePushHandler implements IPushHandler {
protected provider: IPushProvider;

protected constructor(private providerId: string, private channelType: string) {}

canHandle(providerId: string, channelType: ChannelTypeEnum) {
return providerId === this.providerId && channelType === this.channelType;
}

async send(options: IPushOptions) {
if (process.env.NODE_ENV === 'test') {
throw new Error('Currently 3rd-party packages test are not support on test env');
}

return await this.provider.sendMessage(options);
}

abstract buildProvider(credentials: ICredentials);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ChannelTypeEnum } from '@novu/shared';
import { FcmPushProvider } from '@novu/fcm';
import { BasePushHandler } from './base.handler';
import { ICredentials } from '@novu/dal';

export class FCMHandler extends BasePushHandler {
constructor() {
super('fcm', ChannelTypeEnum.PUSH);
}

buildProvider(credentials: ICredentials) {
this.provider = new FcmPushProvider({
secretKey: credentials.secretKey,
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './fcm.handler';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './push.factory.interface';
export * from './push.handler.interface';
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IntegrationEntity } from '@novu/dal';
import { IPushHandler } from './push.handler.interface';

export interface IPushFactory {
getHandler(integration: IntegrationEntity): IPushHandler;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IPushOptions, ISendMessageSuccessResponse } from '@novu/stateless';
import { ICredentials } from '@novu/dal';
import { ChannelTypeEnum } from '@novu/shared';

export interface IPushHandler {
canHandle(providerId: string, channelType: ChannelTypeEnum);

buildProvider(credentials: ICredentials);

send(smsOptions: IPushOptions): Promise<ISendMessageSuccessResponse>;
}
22 changes: 22 additions & 0 deletions apps/api/src/app/events/services/push-service/push.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { IntegrationEntity } from '@novu/dal';
import { IPushFactory, IPushHandler } from './interfaces';
import { FCMHandler } from './handlers';

export class PushFactory implements IPushFactory {
handlers: IPushHandler[] = [new FCMHandler()];

getHandler(integration: IntegrationEntity): IPushHandler {
try {
const handler =
this.handlers.find((handlerItem) => handlerItem.canHandle(integration.providerId, integration.channel)) ?? null;

if (!handler) return null;

handler.buildProvider(integration.credentials);

return handler;
} catch (error) {
throw new Error(`Could not build push handler id: ${integration._id}, error: ${error}`);
}
}
}
2 changes: 2 additions & 0 deletions apps/api/src/app/events/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SendMessageSms } from './send-message/send-message-sms.usecase';
import { SendMessageEmail } from './send-message/send-message-email.usecase';
import { SendMessageInApp } from './send-message/send-message-in-app.usecase';
import { QueueNextJob } from './queue-next-job/queue-next-job.usecase';
import { SendMessagePush } from './send-message/send-message-push.usecase';

export const USE_CASES = [
TriggerEvent,
Expand All @@ -13,5 +14,6 @@ export const USE_CASES = [
SendMessageSms,
SendMessageEmail,
SendMessageInApp,
SendMessagePush,
QueueNextJob,
];
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class ProcessSubscriber {
lastName: subscriberPayload?.lastName,
phone: subscriberPayload?.phone,
avatar: subscriberPayload?.avatar,
notificationIdentifiers: subscriberPayload?.notificationIdentifiers,
})
);
}
Expand All @@ -106,7 +107,9 @@ export class ProcessSubscriber {
(subscriberPayload?.firstName && subscriber?.firstName !== subscriberPayload?.firstName) ||
(subscriberPayload?.lastName && subscriber?.lastName !== subscriberPayload?.lastName) ||
(subscriberPayload?.phone && subscriber?.phone !== subscriberPayload?.phone) ||
(subscriberPayload?.avatar && subscriber?.avatar !== subscriberPayload?.avatar)
(subscriberPayload?.avatar && subscriber?.avatar !== subscriberPayload?.avatar) ||
(subscriberPayload?.notificationIdentifiers &&
subscriber?.notificationIdentifiers !== subscriberPayload?.notificationIdentifiers)
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Injectable } from '@nestjs/common';
import {
IntegrationRepository,
MessageRepository,
NotificationStepEntity,
NotificationRepository,
SubscriberEntity,
SubscriberRepository,
NotificationEntity,
MessageEntity,
} from '@novu/dal';
import { ChannelTypeEnum, LogCodeEnum, LogStatusEnum } from '@novu/shared';
import * as Sentry from '@sentry/node';
import { ContentService } from '../../../shared/helpers/content.service';
import { CreateLog } from '../../../logs/usecases/create-log/create-log.usecase';
import { CreateLogCommand } from '../../../logs/usecases/create-log/create-log.command';
import { PushFactory } from '../../services/push-service/push.factory';
import { SendMessageCommand } from './send-message.command';
import { SendMessageType } from './send-message-type.usecase';

@Injectable()
export class SendMessagePush extends SendMessageType {
private pushFactory = new PushFactory();

constructor(
private subscriberRepository: SubscriberRepository,
private notificationRepository: NotificationRepository,
protected messageRepository: MessageRepository,
protected createLogUsecase: CreateLog,
private integrationRepository: IntegrationRepository
) {
super(messageRepository, createLogUsecase);
}

public async execute(command: SendMessageCommand) {
Sentry.addBreadcrumb({
message: 'Sending Push',
});
const pushChannel: NotificationStepEntity = command.step;
const notification = await this.notificationRepository.findById(command.notificationId);
const subscriber: SubscriberEntity = await this.subscriberRepository.findOne({
_environmentId: command.environmentId,
_id: command.subscriberId,
});
const contentService = new ContentService();
const messageVariables = contentService.buildMessageVariables(command.payload, subscriber);
const content = contentService.replaceVariables(pushChannel.template.content as string, messageVariables);
const title = contentService.replaceVariables(pushChannel.template.title as string, messageVariables);
const notificationIdentifiers = command.payload.notificationIdentifiers || subscriber.notificationIdentifiers;

const messagePayload = Object.assign({}, command.payload);
delete messagePayload.attachments;

const message: MessageEntity = await this.messageRepository.create({
_notificationId: notification._id,
_environmentId: command.environmentId,
_organizationId: command.organizationId,
_subscriberId: command.subscriberId,
_templateId: notification._templateId,
_messageTemplateId: pushChannel.template._id,
channel: ChannelTypeEnum.PUSH,
transactionId: command.transactionId,
notificationIdentifiers,
content,
title,
payload: messagePayload,
});

const integration = await this.integrationRepository.findOne({
_environmentId: command.environmentId,
channel: ChannelTypeEnum.PUSH,
active: true,
});

if (notificationIdentifiers && integration) {
await this.sendMessage(
integration,
notificationIdentifiers,
title,
content,
message,
command,
notification,
command.payload
);

return;
}

await this.sendErrors(notificationIdentifiers, integration, message, command, notification);
}

private async sendErrors(
phone,
integration,
message: MessageEntity,
command: SendMessageCommand,
notification: NotificationEntity
) {
if (!phone) {
await this.createLogUsecase.execute(
CreateLogCommand.create({
transactionId: command.transactionId,
status: LogStatusEnum.ERROR,
environmentId: command.environmentId,
organizationId: command.organizationId,
text: 'Subscriber does not have active phone',
userId: command.userId,
subscriberId: command.subscriberId,
code: LogCodeEnum.SUBSCRIBER_MISSING_PHONE,
templateId: notification._templateId,
raw: {
payload: command.payload,
triggerIdentifier: command.identifier,
},
})
);
await this.messageRepository.updateMessageStatus(
message._id,
'warning',
null,
'no_subscriber_phone',
'Subscriber does not have active phone'
);
}
if (!integration) {
await this.sendErrorStatus(
message,
'warning',
'push_missing_integration_error',
'Subscriber does not have an active push integration',
command,
notification,
LogCodeEnum.MISSING_PUSH_INTEGRATION
);
}
if (!integration?.credentials?.from) {
await this.sendErrorStatus(
message,
'warning',
'no_integration_from_phone',
'Integration does not have from phone configured',
command,
notification,
LogCodeEnum.MISSING_PUSH_PROVIDER
);
}
}

private async sendMessage(
integration,
target: string,
title: string,
content: string,
message: MessageEntity,
command: SendMessageCommand,
notification: NotificationEntity,
payload: object
) {
try {
const pushHandler = this.pushFactory.getHandler(integration);

await pushHandler.send({
target,
title,
content,
payload,
});
} catch (e) {
await this.createLogUsecase.execute(
CreateLogCommand.create({
transactionId: command.transactionId,
status: LogStatusEnum.ERROR,
environmentId: command.environmentId,
organizationId: command.organizationId,
text: e.message || e.name || 'Un-expect Push provider error',
userId: command.userId,
code: LogCodeEnum.PUSH_ERROR,
templateId: notification._templateId,
raw: {
payload: command.payload,
triggerIdentifier: command.identifier,
},
})
);

await this.messageRepository.updateMessageStatus(
message._id,
'error',
e,
'unexpected_push_error',
e.message || e.name || 'Un-expect Push provider error'
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { SendMessageCommand } from './send-message.command';
import { SendMessageEmail } from './send-message-email.usecase';
import { SendMessageSms } from './send-message-sms.usecase';
import { SendMessageInApp } from './send-message-in-app.usecase';
import { SendMessagePush } from './send-message-push.usecase';

@Injectable()
export class SendMessage {
constructor(
private sendMessageEmail: SendMessageEmail,
private sendMessageSms: SendMessageSms,
private sendMessageInApp: SendMessageInApp
private sendMessageInApp: SendMessageInApp,
private sendMessagePush: SendMessagePush
) {}

public async execute(command: SendMessageCommand) {
Expand All @@ -21,6 +23,8 @@ export class SendMessage {
return await this.sendMessageInApp.execute(command);
case ChannelTypeEnum.EMAIL:
return await this.sendMessageEmail.execute(command);
case ChannelTypeEnum.PUSH:
return await this.sendMessagePush.execute(command);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class TriggerEvent {
smsChannel: !!steps.filter((step) => step.template.type === ChannelTypeEnum.SMS)?.length,
emailChannel: !!steps.filter((step) => step.template.type === ChannelTypeEnum.EMAIL)?.length,
inAppChannel: !!steps.filter((step) => step.template.type === ChannelTypeEnum.IN_APP)?.length,
pushChannel: !!steps.filter((step) => step.template.type === ChannelTypeEnum.PUSH)?.length,
});

for (const job of jobs) {
Expand Down
Loading

0 comments on commit 162936c

Please sign in to comment.