diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000000..e69de29bb2d diff --git a/_templates/provider/new/prompt.js b/_templates/provider/new/prompt.js index ea43070d0c2..ad9bd4c99fe 100644 --- a/_templates/provider/new/prompt.js +++ b/_templates/provider/new/prompt.js @@ -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', diff --git a/apps/api/package.json b/apps/api/package.json index 752b4f17928..0e7bb4e99e7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", @@ -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", diff --git a/apps/api/src/app/change/usecases/promote-message-template-change/promote-message-template-change.ts b/apps/api/src/app/change/usecases/promote-message-template-change/promote-message-template-change.ts index 364aead263b..7235d831984 100644 --- a/apps/api/src/app/change/usecases/promote-message-template-change/promote-message-template-change.ts +++ b/apps/api/src/app/change/usecases/promote-message-template-change/promote-message-template-change.ts @@ -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, @@ -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, } diff --git a/apps/api/src/app/events/services/push-service/handlers/base.handler.ts b/apps/api/src/app/events/services/push-service/handlers/base.handler.ts new file mode 100644 index 00000000000..414d6bdaffb --- /dev/null +++ b/apps/api/src/app/events/services/push-service/handlers/base.handler.ts @@ -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); +} diff --git a/apps/api/src/app/events/services/push-service/handlers/fcm.handler.ts b/apps/api/src/app/events/services/push-service/handlers/fcm.handler.ts new file mode 100644 index 00000000000..5310334ea1c --- /dev/null +++ b/apps/api/src/app/events/services/push-service/handlers/fcm.handler.ts @@ -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, + }); + } +} diff --git a/apps/api/src/app/events/services/push-service/handlers/index.ts b/apps/api/src/app/events/services/push-service/handlers/index.ts new file mode 100644 index 00000000000..9a6f914d897 --- /dev/null +++ b/apps/api/src/app/events/services/push-service/handlers/index.ts @@ -0,0 +1 @@ +export * from './fcm.handler'; diff --git a/apps/api/src/app/events/services/push-service/interfaces/index.ts b/apps/api/src/app/events/services/push-service/interfaces/index.ts new file mode 100644 index 00000000000..f073a991219 --- /dev/null +++ b/apps/api/src/app/events/services/push-service/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './push.factory.interface'; +export * from './push.handler.interface'; diff --git a/apps/api/src/app/events/services/push-service/interfaces/push.factory.interface.ts b/apps/api/src/app/events/services/push-service/interfaces/push.factory.interface.ts new file mode 100644 index 00000000000..9e8339ddd66 --- /dev/null +++ b/apps/api/src/app/events/services/push-service/interfaces/push.factory.interface.ts @@ -0,0 +1,6 @@ +import { IntegrationEntity } from '@novu/dal'; +import { IPushHandler } from './push.handler.interface'; + +export interface IPushFactory { + getHandler(integration: IntegrationEntity): IPushHandler; +} diff --git a/apps/api/src/app/events/services/push-service/interfaces/push.handler.interface.ts b/apps/api/src/app/events/services/push-service/interfaces/push.handler.interface.ts new file mode 100644 index 00000000000..d4d88e55a96 --- /dev/null +++ b/apps/api/src/app/events/services/push-service/interfaces/push.handler.interface.ts @@ -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; +} diff --git a/apps/api/src/app/events/services/push-service/push.factory.ts b/apps/api/src/app/events/services/push-service/push.factory.ts new file mode 100644 index 00000000000..d3ae3364648 --- /dev/null +++ b/apps/api/src/app/events/services/push-service/push.factory.ts @@ -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}`); + } + } +} diff --git a/apps/api/src/app/events/usecases/index.ts b/apps/api/src/app/events/usecases/index.ts index e61d8566d27..3940f8b0b7d 100644 --- a/apps/api/src/app/events/usecases/index.ts +++ b/apps/api/src/app/events/usecases/index.ts @@ -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, @@ -13,5 +14,6 @@ export const USE_CASES = [ SendMessageSms, SendMessageEmail, SendMessageInApp, + SendMessagePush, QueueNextJob, ]; diff --git a/apps/api/src/app/events/usecases/process-subscriber/process-subscriber.usecase.ts b/apps/api/src/app/events/usecases/process-subscriber/process-subscriber.usecase.ts index fabdcf55616..b0e90b346c7 100644 --- a/apps/api/src/app/events/usecases/process-subscriber/process-subscriber.usecase.ts +++ b/apps/api/src/app/events/usecases/process-subscriber/process-subscriber.usecase.ts @@ -96,6 +96,7 @@ export class ProcessSubscriber { lastName: subscriberPayload?.lastName, phone: subscriberPayload?.phone, avatar: subscriberPayload?.avatar, + notificationIdentifiers: subscriberPayload?.notificationIdentifiers, }) ); } @@ -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) ); } diff --git a/apps/api/src/app/events/usecases/send-message/send-message-push.usecase.ts b/apps/api/src/app/events/usecases/send-message/send-message-push.usecase.ts new file mode 100644 index 00000000000..a0b9c0d1cfb --- /dev/null +++ b/apps/api/src/app/events/usecases/send-message/send-message-push.usecase.ts @@ -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' + ); + } + } +} diff --git a/apps/api/src/app/events/usecases/send-message/send-message.usecase.ts b/apps/api/src/app/events/usecases/send-message/send-message.usecase.ts index 99ccb397c44..f451fd75fd3 100644 --- a/apps/api/src/app/events/usecases/send-message/send-message.usecase.ts +++ b/apps/api/src/app/events/usecases/send-message/send-message.usecase.ts @@ -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) { @@ -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); } } } diff --git a/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts b/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts index 64bf4334f0a..5941d816040 100644 --- a/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts +++ b/apps/api/src/app/events/usecases/trigger-event/trigger-event.usecase.ts @@ -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) { diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts index 86ea10b0b60..04d8abb958f 100644 --- a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts +++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.command.ts @@ -1,4 +1,14 @@ -import { IsDefined, IsEnum, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; +import { + IsDefined, + IsEnum, + IsMongoId, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + ValidateIf, + ValidateNested, +} from 'class-validator'; import { ChannelTypeEnum, IEmailBlock } from '@novu/shared'; import { CommandHelper } from '../../../shared/commands/command.helper'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -19,6 +29,9 @@ export class CreateMessageTemplateCommand extends EnvironmentWithUserCommand { @IsOptional() subject?: string; + @IsOptional() + title?: string; + @IsDefined() content: string | IEmailBlock[]; diff --git a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts index 98b2499383b..50239301928 100644 --- a/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts +++ b/apps/api/src/app/message-template/usecases/create-message-template/create-message-template.usecase.ts @@ -17,6 +17,7 @@ export class CreateMessageTemplate { content: command.contentType === 'editor' ? sanitizeMessageContent(command.content) : command.content, contentType: command.contentType, subject: command.subject, + title: command.title, type: command.type, _organizationId: command.organizationId, _environmentId: command.environmentId, diff --git a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts index 3a315eec735..49c212574e2 100644 --- a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts +++ b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.command.ts @@ -1,4 +1,14 @@ -import { IsDefined, IsEnum, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; +import { + IsDefined, + IsEnum, + IsMongoId, + IsNotEmpty, + IsOptional, + IsString, + IsUrl, + ValidateIf, + ValidateNested, +} from 'class-validator'; import { ChannelTypeEnum, IEmailBlock } from '@novu/shared'; import { CommandHelper } from '../../../shared/commands/command.helper'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; @@ -23,6 +33,9 @@ export class UpdateMessageTemplateCommand extends EnvironmentWithUserCommand { @IsOptional() subject?: string; + @IsOptional() + title?: string; + @IsOptional() content: string | IEmailBlock[]; diff --git a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts index d77e64f132a..aaae21d0426 100644 --- a/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts +++ b/apps/api/src/app/message-template/usecases/update-message-template/update-message-template.usecase.ts @@ -40,6 +40,10 @@ export class UpdateMessageTemplate { updatePayload.subject = command.subject; } + if (command.title) { + updatePayload.title = command.title; + } + if (!Object.keys(updatePayload).length) { throw new BadRequestException('No properties found for update'); } diff --git a/apps/api/src/app/notification-template/dto/message-template.dto.ts b/apps/api/src/app/notification-template/dto/message-template.dto.ts index 750e6d293fa..a7f1e55805e 100644 --- a/apps/api/src/app/notification-template/dto/message-template.dto.ts +++ b/apps/api/src/app/notification-template/dto/message-template.dto.ts @@ -1,5 +1,5 @@ import { ChannelTypeEnum, IEmailBlock, ChannelCTATypeEnum } from '@novu/shared'; -import { IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { IsDefined, IsEnum, IsOptional, IsString, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; class ChannelCTADto { @IsEnum(ChannelCTATypeEnum) @@ -33,4 +33,8 @@ export class MessageTemplateDto { @IsOptional() @IsString() subject?: string; + + @IsOptional() + @IsString() + title?: string; } diff --git a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts index 32c932df0f5..468d5b5dd6d 100644 --- a/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts +++ b/apps/api/src/app/notification-template/usecases/create-notification-template/create-notification-template.usecase.ts @@ -62,6 +62,7 @@ export class CreateNotificationTemplate { userId: command.userId, cta: message.template.cta, subject: message.template.subject, + title: message.template.title, parentChangeId, }) ); diff --git a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts index 83667523413..96ce2e7949a 100644 --- a/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts +++ b/apps/api/src/app/notification-template/usecases/update-notification-template/update-notification-template.usecase.ts @@ -89,6 +89,7 @@ export class UpdateNotificationTemplate { contentType: message.template.contentType, cta: message.template.cta, subject: message.template.subject, + title: message.template.title, parentChangeId, }) ); @@ -112,6 +113,7 @@ export class UpdateNotificationTemplate { userId: command.userId, cta: message.template.cta, subject: message.template.subject, + title: message.template.title, parentChangeId, }) ); diff --git a/apps/api/src/app/subscribers/dto/create-subscriber.dto.ts b/apps/api/src/app/subscribers/dto/create-subscriber.dto.ts index 0de8ea5b3cd..e796c11a26c 100644 --- a/apps/api/src/app/subscribers/dto/create-subscriber.dto.ts +++ b/apps/api/src/app/subscribers/dto/create-subscriber.dto.ts @@ -1,4 +1,4 @@ -import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsDefined, IsEmail, IsOptional, IsString, ValidateNested } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateSubscriberBodyDto { @@ -26,4 +26,9 @@ export class CreateSubscriberBodyDto { @IsString() @IsOptional() avatar?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + notificationIdentifiers?: string[]; } diff --git a/apps/api/src/app/subscribers/dto/update-subscriber.dto.ts b/apps/api/src/app/subscribers/dto/update-subscriber.dto.ts index 888973e7601..0a26028263b 100644 --- a/apps/api/src/app/subscribers/dto/update-subscriber.dto.ts +++ b/apps/api/src/app/subscribers/dto/update-subscriber.dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsEmail, IsOptional, IsString } from 'class-validator'; export class UpdateSubscriberBodyDto { @IsEmail() @@ -20,4 +20,9 @@ export class UpdateSubscriberBodyDto { @IsString() @IsOptional() avatar?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + notificationIdentifiers?: string[]; } diff --git a/apps/api/src/app/subscribers/subscribers.controller.ts b/apps/api/src/app/subscribers/subscribers.controller.ts index b3afdacff2f..599e04cdf47 100644 --- a/apps/api/src/app/subscribers/subscribers.controller.ts +++ b/apps/api/src/app/subscribers/subscribers.controller.ts @@ -31,6 +31,7 @@ export class SubscribersController { email: body.email, phone: body.phone, avatar: body.avatar, + notificationIdentifiers: body.notificationIdentifiers, }) ); } @@ -53,6 +54,7 @@ export class SubscribersController { email: body.email, phone: body.phone, avatar: body.avatar, + notificationIdentifiers: body.notificationIdentifiers, }) ); } diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts index bb1eda5d982..f955b0e078c 100644 --- a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.command.ts @@ -1,4 +1,4 @@ -import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; import { CommandHelper } from '../../../shared/commands/command.helper'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; @@ -30,4 +30,9 @@ export class CreateSubscriberCommand extends EnvironmentCommand { @IsString() @IsOptional() avatar?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + notificationIdentifiers?: string[]; } diff --git a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts index 4a74fc87ffb..3ea0238a4a4 100644 --- a/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/create-subscriber/create-subscriber.usecase.ts @@ -20,6 +20,7 @@ export class CreateSubscriber { email: command.email, phone: command.phone, avatar: command.avatar, + notificationIdentifiers: command.notificationIdentifiers, }); } else { subscriber = await this.updateSubscriber.execute( @@ -32,6 +33,7 @@ export class CreateSubscriber { email: command.email, phone: command.phone, avatar: command.avatar, + notificationIdentifiers: command.notificationIdentifiers, }) ); } diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts index 2164af6dfa9..3281ad28079 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.command.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsEmail, IsOptional, IsString } from 'class-validator'; import { CommandHelper } from '../../../shared/commands/command.helper'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; @@ -27,4 +27,9 @@ export class UpdateSubscriberCommand extends EnvironmentCommand { @IsString() @IsOptional() avatar?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + notificationIdentifiers?: string[]; } diff --git a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts index 94e164a92de..0d2aa0b0cd3 100644 --- a/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts +++ b/apps/api/src/app/subscribers/usecases/update-subscriber/update-subscriber.usecase.ts @@ -38,6 +38,10 @@ export class UpdateSubscriber { updatePayload.avatar = command.avatar; } + if (command.notificationIdentifiers != null) { + updatePayload.notificationIdentifiers = command.notificationIdentifiers; + } + await this.subscriberRepository.update( { _id: foundSubscriber, diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 890db7cedea..31624ac5345 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -9,5 +9,6 @@ "allowJs": false, "esModuleInterop": false, "declarationMap": true - } + }, + "exclude": ["node_modules"] } diff --git a/apps/web/cypress.config.ts b/apps/web/cypress.config.ts index e3af0cbeceb..86229728d60 100644 --- a/apps/web/cypress.config.ts +++ b/apps/web/cypress.config.ts @@ -6,19 +6,19 @@ export default defineConfig({ video: false, retries: { runMode: 2, - openMode: 1 + openMode: 1, }, e2e: { setupNodeEvents(on, config) { return require('./cypress/plugins/index.ts')(on, config); }, - baseUrl: "http://localhost:4200", - specPattern: 'cypress/tests/**/*.{js,jsx,ts,tsx}' + baseUrl: 'http://localhost:4200', + specPattern: 'cypress/tests/**/*.{js,jsx,ts,tsx}', }, env: { - NODE_ENV: "test", - apiUrl: "http://localhost:1336", - coverage: false + NODE_ENV: 'test', + apiUrl: 'http://localhost:1336', + coverage: false, }, - projectId: "cayav5", -}); \ No newline at end of file + projectId: 'cayav5', +}); diff --git a/apps/web/package.json b/apps/web/package.json index ca08552b5c1..e4b7227f41f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,7 +26,6 @@ "@sentry/tracing": "^6.3.1", "@storybook/addon-docs": "^6.4.19", "@storybook/theming": "^6.4.19", - "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/jest": "^26.0.15", @@ -86,8 +85,10 @@ "@storybook/node-logger": "^6.4.14", "@storybook/preset-create-react-app": "^4.1.0", "@storybook/react": "^6.4.21", + "@testing-library/jest-dom": "^4.2.4", "@types/react": "^17.0.0", "@types/styled-components": "^5.1.7", + "@types/testing-library__jest-dom": "^5.14.5", "babel-plugin-styled-components": "^1.12.0", "cypress": "^10.2.0", "cypress-localstorage-commands": "^1.7.0", diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 134517fe70b..8e139b0f2e4 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,6 +1,6 @@ -import React from 'react'; import { render, screen } from '@testing-library/react'; import App from './App'; +import './setupTests'; test('renders learn react link', () => { render(); diff --git a/apps/web/src/components/templates/TemplateEditor.tsx b/apps/web/src/components/templates/TemplateEditor.tsx index 94b72e0902f..b4fc64b9096 100644 --- a/apps/web/src/components/templates/TemplateEditor.tsx +++ b/apps/web/src/components/templates/TemplateEditor.tsx @@ -5,6 +5,7 @@ import { TemplateInAppEditor } from './in-app-editor/TemplateInAppEditor'; import { TemplateSMSEditor } from './TemplateSMSEditor'; import { useTemplateController } from './use-template-controller.hook'; import { ActivePageEnum } from '../../pages/templates/editor/TemplateEditorPage'; +import { TemplatePushEditor } from './TemplatePushEditor'; export const TemplateEditor = ({ activePage, templateId, activeStep }) => { const { integrations } = useActiveIntegrations(); @@ -53,6 +54,23 @@ export const TemplateEditor = ({ activePage, templateId, activeStep }) => { })} )} + {activePage === ActivePageEnum.PUSH && ( +
+ {steps.map((message, index) => { + return message.template.type === ChannelTypeEnum.PUSH && activeStep === index ? ( + integration.channel === ChannelTypeEnum.PUSH) + } + /> + ) : null; + })} +
+ )} ); }; diff --git a/apps/web/src/components/templates/TemplateFormProvider.tsx b/apps/web/src/components/templates/TemplateFormProvider.tsx index 0a0e41b81ac..df6cda6b9ad 100644 --- a/apps/web/src/components/templates/TemplateFormProvider.tsx +++ b/apps/web/src/components/templates/TemplateFormProvider.tsx @@ -47,6 +47,7 @@ const schema = z .object({ content: z.any(), subject: z.any(), + title: z.any(), }) .passthrough() .superRefine((template: any, ctx) => { diff --git a/apps/web/src/components/templates/TemplatePageHeader.tsx b/apps/web/src/components/templates/TemplatePageHeader.tsx index dd4301eb3ec..009a5128c29 100644 --- a/apps/web/src/components/templates/TemplatePageHeader.tsx +++ b/apps/web/src/components/templates/TemplatePageHeader.tsx @@ -25,6 +25,10 @@ const Header = ({ activePage, editMode }: { editMode: boolean; activePage: Activ return <>{'Edit Email Template'}; } + if (activePage === ActivePageEnum.PUSH) { + return <>{'Edit Push Template'}; + } + if (activePage === ActivePageEnum.IN_APP) { return <>{'Edit Notification Template'}; } diff --git a/apps/web/src/components/templates/TemplatePushEditor.tsx b/apps/web/src/components/templates/TemplatePushEditor.tsx new file mode 100644 index 00000000000..b0f0bfc96a1 --- /dev/null +++ b/apps/web/src/components/templates/TemplatePushEditor.tsx @@ -0,0 +1,111 @@ +import { Control, Controller, useFormContext } from 'react-hook-form'; +import { Textarea } from '@mantine/core'; +import { LackIntegrationError } from './LackIntegrationError'; +import { IForm } from './use-template-controller.hook'; +import { colors } from '../../design-system'; +import { useEnvController } from '../../store/use-env-controller'; + +export function TemplatePushEditor({ + control, + index, + isIntegrationActive, +}: { + control: Control; + index: number; + errors: any; + isIntegrationActive: boolean; +}) { + const { readonly } = useEnvController(); + const { + formState: { errors }, + } = useFormContext(); + + return ( + <> + {!isIntegrationActive ? : null} + ( +