From 1357fb7c39afa48a2a38afd8c817bf774d19606a Mon Sep 17 00:00:00 2001 From: Bassel Koshak Date: Sun, 9 Nov 2025 12:16:00 +0300 Subject: [PATCH 1/3] feat(providers): add Maqsam WhatsApp chat provider --- .../app/integrations/dtos/credentials.dto.ts | 5 + .../light/square/maqsam-whatsapp.svg | 10 ++ .../send-message/send-message-chat.usecase.ts | 12 +- .../src/factories/chat/chat.factory.ts | 2 + .../factories/chat/handlers/maqsam.handler.ts | 15 ++ .../src/models/components/providersidenum.ts | 1 + .../src/schemas/providers/chat/index.ts | 1 + packages/framework/src/shared.ts | 1 + packages/providers/src/lib/chat/index.ts | 1 + .../maqsam-whatsapp.provider.spec.ts | 140 ++++++++++++++++++ .../maqsam-whatsapp.provider.ts | 64 ++++++++ .../types/maqsam-whatsapp.types.ts | 10 ++ .../src/consts/providers/channels/chat.ts | 10 ++ .../credentials/provider-credentials.ts | 15 ++ .../integration/credential.interface.ts | 1 + packages/shared/src/types/providers.ts | 1 + 16 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/public/images/providers/light/square/maqsam-whatsapp.svg create mode 100644 libs/application-generic/src/factories/chat/handlers/maqsam.handler.ts create mode 100644 packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.ts create mode 100644 packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts create mode 100644 packages/providers/src/lib/chat/maqsam-whatsapp/types/maqsam-whatsapp.types.ts diff --git a/apps/api/src/app/integrations/dtos/credentials.dto.ts b/apps/api/src/app/integrations/dtos/credentials.dto.ts index 85dab4d0dad..3af99ef9414 100644 --- a/apps/api/src/app/integrations/dtos/credentials.dto.ts +++ b/apps/api/src/app/integrations/dtos/credentials.dto.ts @@ -236,4 +236,9 @@ export class CredentialsDto implements ICredentials { @IsOptional() @IsString() AppIOBaseUrl?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + accessSecret?: string; } diff --git a/apps/dashboard/public/images/providers/light/square/maqsam-whatsapp.svg b/apps/dashboard/public/images/providers/light/square/maqsam-whatsapp.svg new file mode 100644 index 00000000000..4c3a53a2b19 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/maqsam-whatsapp.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index 46b958797f8..97da403965f 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -297,15 +297,23 @@ export class SendMessageChat extends SendMessageBase { Object.values(ChatProviderIdEnum).includes(chan.providerId as ChatProviderIdEnum) ) || []; - // Add WhatsApp Business if subscriber has phone + // Add WhatsApp APIs if subscriber has phone if (subscriber.phone) { - // @ts-expect-error - Adding WhatsApp channel without _integrationId + // @ts-expect-error - Adding WhatsApp Business channel without _integrationId chatChannels.push({ providerId: ChatProviderIdEnum.WhatsAppBusiness, credentials: { phoneNumber: subscriber.phone, }, }); + + // @ts-expect-error - Adding Maqsam WhatsApp channel without _integrationId + chatChannels.push({ + providerId: ChatProviderIdEnum.MaqsamWhatsApp, + credentials: { + phoneNumber: subscriber.phone, + }, + }) } return chatChannels; diff --git a/libs/application-generic/src/factories/chat/chat.factory.ts b/libs/application-generic/src/factories/chat/chat.factory.ts index 0de0fac8e39..0f6588e0ff9 100644 --- a/libs/application-generic/src/factories/chat/chat.factory.ts +++ b/libs/application-generic/src/factories/chat/chat.factory.ts @@ -3,6 +3,7 @@ import { ChatWebhookHandler } from './handlers/chat-webhook.handler'; import { DiscordHandler } from './handlers/discord.handler'; import { GetstreamChatHandler } from './handlers/getstream.handler'; import { GrafanaOnCallHandler } from './handlers/grafana-on-call.handler'; +import { MaqsamWhatsAppHandler } from './handlers/maqsam.handler'; import { MattermostHandler } from './handlers/mattermost.handler'; import { MSTeamsHandler } from './handlers/msteams.handler'; import { NovuSlackHandler } from './handlers/novu-slack.handler'; @@ -27,6 +28,7 @@ export class ChatFactory implements IChatFactory { new GetstreamChatHandler(), new RocketChatHandler(), new WhatsAppBusinessHandler(), + new MaqsamWhatsAppHandler(), ]; getHandler(integration: Pick) { diff --git a/libs/application-generic/src/factories/chat/handlers/maqsam.handler.ts b/libs/application-generic/src/factories/chat/handlers/maqsam.handler.ts new file mode 100644 index 00000000000..4ea7de17c13 --- /dev/null +++ b/libs/application-generic/src/factories/chat/handlers/maqsam.handler.ts @@ -0,0 +1,15 @@ +import { MaqsamWhatsAppProvider } from '@novu/providers'; +import { ChannelTypeEnum, ChatProviderIdEnum, ICredentials } from '@novu/shared'; +import { BaseChatHandler } from './base.handler'; + +export class MaqsamWhatsAppHandler extends BaseChatHandler { + constructor() { + super(ChatProviderIdEnum.MaqsamWhatsApp, ChannelTypeEnum.CHAT); + } + + buildProvider(credentials: ICredentials) { + const config: { accessKey: string, accessSecret: string } = { accessKey: credentials.accessKey, accessSecret: credentials.accessSecret }; + + this.provider = new MaqsamWhatsAppProvider(config); + } +} diff --git a/libs/internal-sdk/src/models/components/providersidenum.ts b/libs/internal-sdk/src/models/components/providersidenum.ts index 79f4817777f..bb292fdf9cf 100644 --- a/libs/internal-sdk/src/models/components/providersidenum.ts +++ b/libs/internal-sdk/src/models/components/providersidenum.ts @@ -87,6 +87,7 @@ export const ProvidersIdEnum = { WhatsappBusiness: "whatsapp-business", ChatWebhook: "chat-webhook", NovuSlack: "novu-slack", + MaqsamWhatsApp: 'maqsam-whatsapp', } as const; /** * Provider ID of the job diff --git a/packages/framework/src/schemas/providers/chat/index.ts b/packages/framework/src/schemas/providers/chat/index.ts index 9d341f1868a..308c03e011c 100644 --- a/packages/framework/src/schemas/providers/chat/index.ts +++ b/packages/framework/src/schemas/providers/chat/index.ts @@ -15,4 +15,5 @@ export const chatProviderSchemas = { slack: slackProviderSchemas, 'whatsapp-business': genericProviderSchemas, zulip: genericProviderSchemas, + 'maqsam-whatsapp': genericProviderSchemas, } as const satisfies Record; diff --git a/packages/framework/src/shared.ts b/packages/framework/src/shared.ts index 0d0294fd149..7444c36aca6 100644 --- a/packages/framework/src/shared.ts +++ b/packages/framework/src/shared.ts @@ -175,6 +175,7 @@ export enum ChatProviderIdEnum { RocketChat = 'rocket-chat', WhatsAppBusiness = 'whatsapp-business', ChatWebhook = 'chat-webhook', + MaqsamWhatsApp = 'maqsam-whatsapp', } export enum PushProviderIdEnum { diff --git a/packages/providers/src/lib/chat/index.ts b/packages/providers/src/lib/chat/index.ts index cd5072c0e69..08130b1e750 100644 --- a/packages/providers/src/lib/chat/index.ts +++ b/packages/providers/src/lib/chat/index.ts @@ -2,6 +2,7 @@ export * from './chat-webhook/chat-webhook.provider'; export * from './discord/discord.provider'; export * from './getstream/getstream.provider'; export * from './grafana-on-call/grafana-on-call.provider'; +export * from './maqsam-whatsapp/maqsam-whatsapp.provider'; export * from './mattermost/mattermost.provider'; export * from './msTeams/msTeams.provider'; export * from './rocket-chat/rocket-chat.provider'; diff --git a/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.ts b/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.ts new file mode 100644 index 00000000000..ff438b71822 --- /dev/null +++ b/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.spec.ts @@ -0,0 +1,140 @@ +import { IChatOptions } from '@novu/stateless'; +import { nanoid } from 'nanoid'; +import { expect, test } from 'vitest'; +import { axiosSpy } from '../../../utils/test/spy-axios'; +import { MaqsamWhatsAppProvider } from './maqsam-whatsapp.provider'; + +const mockProviderConfig = { + accessKey: 'my-access-key', + accessSecret: 'my-access-secret', +}; + +const buildResponse = (messageId: string) => { + return { + data: { + message_id: messageId, + message_status: 'submitted', + }, + }; +}; + +test('should trigger maqsam library correctly with template message', async () => { + const messageId = nanoid(); + + const { mockPost, axiosMockSpy } = axiosSpy(buildResponse(messageId)); + + const provider = new MaqsamWhatsAppProvider(mockProviderConfig); + + const options: IChatOptions = { + phoneNumber: '+111111111', + content: 'Template message', + customData: { + templateId: '123', + templateVariables: { + name: 'John', + company: 'Acme', + }, + }, + }; + + const res = await provider.sendMessage(options); + + expect(mockPost).toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith('https://api.maqsam.com/v2/whatsapp/messages/send_message', { + RecipientPhone: options.phoneNumber, + TemplateId: options.customData?.templateId, + TemplateVariables: options.customData?.templateVariables, + }); + + const token = Buffer.from(`${mockProviderConfig.accessKey}:${mockProviderConfig.accessSecret}`).toString('base64'); + + expect(axiosMockSpy).toHaveBeenCalledWith({ + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + expect(res.id).toBe(messageId); +}); + +test('should trigger maqsam library correctly with template message with _passthrough', async () => { + const messageId = nanoid(); + + const { mockPost, axiosMockSpy } = axiosSpy(buildResponse(messageId)); + + const provider = new MaqsamWhatsAppProvider(mockProviderConfig); + + const options: IChatOptions = { + phoneNumber: '+111111111', + content: 'Template message', + customData: { + templateId: '123', + templateVariables: { + name: 'John', + company: 'Acme', + }, + }, + }; + + const res = await provider.sendMessage(options, { + _passthrough: { + query: { + RecipientPhone: '+111111111', + TemplateId: '123', + TemplateVariables: JSON.stringify({ + name: 'John', + company: 'Acme', + }), + }, + }, + }); + + expect(mockPost).toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith('https://api.maqsam.com/v2/whatsapp/messages/send_message', { + RecipientPhone: '+111111111', + TemplateId: '123', + TemplateVariables: { + name: 'John', + company: 'Acme', + }, + }); + + const token = Buffer.from(`${mockProviderConfig.accessKey}:${mockProviderConfig.accessSecret}`).toString('base64'); + + expect(axiosMockSpy).toHaveBeenCalledWith({ + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + expect(res.id).toBe(messageId); +}); + +test('should throw error when message status is not submitted', async () => { + const messageId = nanoid(); + + axiosSpy({ + data: { + message_id: messageId, + message_status: 'failed', + }, + }); + + const provider = new MaqsamWhatsAppProvider(mockProviderConfig); + + const options: IChatOptions = { + phoneNumber: '+111111111', + content: 'Template message', + customData: { + templateId: '123', + templateVariables: { + name: 'John', + company: 'Acme', + }, + }, + }; + + await expect(provider.sendMessage(options)).rejects.toThrow('Maqsam Chat failed:'); +}); diff --git a/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts b/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts new file mode 100644 index 00000000000..e9b8f758b2f --- /dev/null +++ b/packages/providers/src/lib/chat/maqsam-whatsapp/maqsam-whatsapp.provider.ts @@ -0,0 +1,64 @@ +import { + ChannelTypeEnum, + IChatOptions, + IChatProvider, + ISendMessageSuccessResponse, + PhoneData, +} from "@novu/stateless"; +import Axios, { AxiosInstance } from 'axios'; +import { BaseProvider, CasingEnum } from "../../../base.provider"; +import { WithPassthrough } from "../../../utils/types"; +import { ISendMessageFailureRes, ISendMessageRes } from "./types/maqsam-whatsapp.types"; + +export class MaqsamWhatsAppProvider extends BaseProvider implements IChatProvider { + id = "maqsam-whatsapp"; + channelType = ChannelTypeEnum.CHAT as ChannelTypeEnum.CHAT; + protected casing: CasingEnum = CasingEnum.CAMEL_CASE; + + private readonly axiosClient: AxiosInstance; + private readonly baseUrl = 'https://api.maqsam.com/v2/whatsapp/messages/send_message'; + + constructor(private config: { + accessKey: string; + accessSecret: string; + }) { + super(); + + const token = Buffer.from(`${this.config.accessKey}:${this.config.accessSecret}`).toString('base64'); + + this.axiosClient = Axios.create({ + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + } + + private defineMessagePayload(options: IChatOptions) { + return { + RecipientPhone: (options.channelData as PhoneData).endpoint.phoneNumber, + TemplateId: options.customData?.templateId, + TemplateVariables: options.customData?.templateVariables, + } + } + + async sendMessage( + options: IChatOptions, + bridgeProviderData: WithPassthrough> = {}, + ): Promise { + const payload = this.transform(bridgeProviderData, this.defineMessagePayload(options)); + + const { data } = await this.axiosClient.post(this.baseUrl, + payload.body, + ); + + if ('conversationId' in data && data.conversationId) { + return { + id: data.conversationId, + date: new Date().toISOString(), + }; + } + + throw new Error(`Maqsam Chat failed: ${JSON.stringify(data || {})}`); + } +} diff --git a/packages/providers/src/lib/chat/maqsam-whatsapp/types/maqsam-whatsapp.types.ts b/packages/providers/src/lib/chat/maqsam-whatsapp/types/maqsam-whatsapp.types.ts new file mode 100644 index 00000000000..4227d36f9e0 --- /dev/null +++ b/packages/providers/src/lib/chat/maqsam-whatsapp/types/maqsam-whatsapp.types.ts @@ -0,0 +1,10 @@ +export interface ISendMessageRes { + conversationId: string; + messageStatus: string; + result: string; +} + +export interface ISendMessageFailureRes { + error: string + message: string +} diff --git a/packages/shared/src/consts/providers/channels/chat.ts b/packages/shared/src/consts/providers/channels/chat.ts index 649a981dd94..5cc4ad6c721 100644 --- a/packages/shared/src/consts/providers/channels/chat.ts +++ b/packages/shared/src/consts/providers/channels/chat.ts @@ -4,6 +4,7 @@ import { chatWebhookConfig, getstreamConfig, grafanaOnCallConfig, + maqsamWhatsAppChatConfig, msTeamsConfig, rocketChatConfig, slackConfigLegacy, @@ -109,4 +110,13 @@ export const chatProviders: IProviderConfig[] = [ logoFileName: { light: 'chat-webhook.svg', dark: 'chat-webhook.svg' }, betaVersion: true, }, + { + id: ChatProviderIdEnum.MaqsamWhatsApp, + displayName: 'Maqsam WhatsApp', + channel: ChannelTypeEnum.CHAT, + credentials: maqsamWhatsAppChatConfig, + docReference: 'https://portal.maqsam.com/docs/v2/whatsapp', + logoFileName: { light: 'maqsam-whatsapp.svg', dark: 'maqsam-whatsapp.svg' }, + betaVersion: true, + } ]; diff --git a/packages/shared/src/consts/providers/credentials/provider-credentials.ts b/packages/shared/src/consts/providers/credentials/provider-credentials.ts index d8cda74f68b..123b7e63c05 100644 --- a/packages/shared/src/consts/providers/credentials/provider-credentials.ts +++ b/packages/shared/src/consts/providers/credentials/provider-credentials.ts @@ -926,6 +926,21 @@ export const maqsamConfig: IConfigCredential[] = [ ...smsConfigBase, ]; +export const maqsamWhatsAppChatConfig: IConfigCredential[] = [ + { + key: CredentialsKeyEnum.AccessKey, + displayName: 'Access Key', + type: 'string', + required: true, + }, + { + key: CredentialsKeyEnum.AccessSecret, + displayName: 'Access Secret', + type: 'string', + required: true, + } +]; + export const smsCentralConfig: IConfigCredential[] = [ { key: CredentialsKeyEnum.User, diff --git a/packages/shared/src/entities/integration/credential.interface.ts b/packages/shared/src/entities/integration/credential.interface.ts index d19a1d85d11..0ce5f1a5cb3 100644 --- a/packages/shared/src/entities/integration/credential.interface.ts +++ b/packages/shared/src/entities/integration/credential.interface.ts @@ -53,4 +53,5 @@ export interface ICredentials { AppIOOriginalSignature?: string; servicePlanId?: string; tenantId?: string; + accessSecret?: string; } diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts index 7516c6159d0..7e162614d91 100644 --- a/packages/shared/src/types/providers.ts +++ b/packages/shared/src/types/providers.ts @@ -135,6 +135,7 @@ export enum ChatProviderIdEnum { WhatsAppBusiness = 'whatsapp-business', ChatWebhook = 'chat-webhook', Novu = 'novu-slack', + MaqsamWhatsApp = 'maqsam-whatsapp', } export enum PushProviderIdEnum { From dedaa44c51f754d3646d4b8993dda6d71b99efea Mon Sep 17 00:00:00 2001 From: Bassel Koshak Date: Mon, 10 Nov 2025 11:51:19 +0300 Subject: [PATCH 2/3] fix: add missing enums --- packages/shared/src/types/providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts index 7e162614d91..c82cc8ef001 100644 --- a/packages/shared/src/types/providers.ts +++ b/packages/shared/src/types/providers.ts @@ -52,6 +52,7 @@ export enum CredentialsKeyEnum { AppIOBaseUrl = 'AppIOBaseUrl', ServicePlanId = 'servicePlanId', TenantId = 'tenantId', + AccessSecret = 'accessSecret' } export type ConfigurationKey = keyof IConfigurations; From b3663bb1b08c971a2ab50da4a510082d15850491 Mon Sep 17 00:00:00 2001 From: Bassel Koshak Date: Mon, 10 Nov 2025 12:37:26 +0300 Subject: [PATCH 3/3] fix: add accessSecret field to integration schema; add accessSecret to secure-crendentials.ts --- libs/dal/src/repositories/integration/integration.schema.ts | 1 + .../src/consts/providers/credentials/secure-credentials.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 43541996a20..c5b389ed793 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -70,6 +70,7 @@ const integrationSchema = new Schema( AppIOSubscriptionId: Schema.Types.String, AppIOBearerToken: Schema.Types.String, AppIOOriginalSignature: Schema.Types.String, + accessSecret: Schema.Types.String, }, configurations: { inboundWebhookEnabled: Schema.Types.Boolean, diff --git a/packages/shared/src/consts/providers/credentials/secure-credentials.ts b/packages/shared/src/consts/providers/credentials/secure-credentials.ts index ea9eceae8c2..b0b3ae38469 100644 --- a/packages/shared/src/consts/providers/credentials/secure-credentials.ts +++ b/packages/shared/src/consts/providers/credentials/secure-credentials.ts @@ -7,4 +7,5 @@ export const secureCredentials: CredentialsKeyEnum[] = [ CredentialsKeyEnum.Token, CredentialsKeyEnum.Password, CredentialsKeyEnum.ServiceAccount, + CredentialsKeyEnum.AccessSecret ];