diff --git a/.cspell.json b/.cspell.json index 8c798e6f979..ca91640c256 100644 --- a/.cspell.json +++ b/.cspell.json @@ -17,6 +17,7 @@ "amannn", "analagous", "Angularjs", + "sinch", "evalsha", "antd", "anthonybaxter", diff --git a/apps/dashboard/public/images/providers/light/square/sinch.svg b/apps/dashboard/public/images/providers/light/square/sinch.svg new file mode 100644 index 00000000000..21acf438412 --- /dev/null +++ b/apps/dashboard/public/images/providers/light/square/sinch.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/apps/web/public/static/images/providers/dark/sinch.png b/apps/web/public/static/images/providers/dark/sinch.png new file mode 100644 index 00000000000..0f2032305c0 --- /dev/null +++ b/apps/web/public/static/images/providers/dark/sinch.png @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== diff --git a/apps/web/public/static/images/providers/dark/square/sinch.svg b/apps/web/public/static/images/providers/dark/square/sinch.svg new file mode 100644 index 00000000000..21acf438412 --- /dev/null +++ b/apps/web/public/static/images/providers/dark/square/sinch.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/apps/web/public/static/images/providers/light/sinch.png b/apps/web/public/static/images/providers/light/sinch.png new file mode 100644 index 00000000000..0f2032305c0 --- /dev/null +++ b/apps/web/public/static/images/providers/light/sinch.png @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg== diff --git a/apps/web/public/static/images/providers/light/square/sinch.svg b/apps/web/public/static/images/providers/light/square/sinch.svg new file mode 100644 index 00000000000..21acf438412 --- /dev/null +++ b/apps/web/public/static/images/providers/light/square/sinch.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts b/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts index d6ee50919ac..2af0347d0b4 100644 --- a/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts +++ b/apps/web/src/pages/integrations/components/multi-provider/sort-providers.ts @@ -76,6 +76,7 @@ const providers: Record = { SmsProviderIdEnum.BrevoSms, SmsProviderIdEnum.ISendSms, SmsProviderIdEnum.EazySms, + SmsProviderIdEnum.Sinch, ].sort(), ], }; diff --git a/libs/application-generic/src/factories/sms/handlers/index.ts b/libs/application-generic/src/factories/sms/handlers/index.ts index beffe9cae7b..f85320dc077 100644 --- a/libs/application-generic/src/factories/sms/handlers/index.ts +++ b/libs/application-generic/src/factories/sms/handlers/index.ts @@ -25,6 +25,7 @@ export * from './plivo.handler'; export * from './ring-central.handler'; export * from './sendchamp.handler'; export * from './simpletexting.handler'; +export * from './sinch.handler'; export * from './sms-central.handler'; export * from './sms77.handler'; export * from './sns.handler'; diff --git a/libs/application-generic/src/factories/sms/handlers/sinch.handler.ts b/libs/application-generic/src/factories/sms/handlers/sinch.handler.ts new file mode 100644 index 00000000000..03225eb5db0 --- /dev/null +++ b/libs/application-generic/src/factories/sms/handlers/sinch.handler.ts @@ -0,0 +1,19 @@ +import { SinchSmsProvider } from '@novu/providers'; +import { ChannelTypeEnum, ICredentials, SmsProviderIdEnum } from '@novu/shared'; +import { BaseSmsHandler } from './base.handler'; + +export class SinchHandler extends BaseSmsHandler { + constructor() { + super(SmsProviderIdEnum.Sinch, ChannelTypeEnum.SMS); + } + + buildProvider(credentials: ICredentials) { + const config = credentials as Record; + this.provider = new SinchSmsProvider({ + servicePlanId: config.servicePlanId, + apiToken: config.apiToken, + from: config.from, + region: config.region, + }); + } +} diff --git a/libs/application-generic/src/factories/sms/sms.factory.ts b/libs/application-generic/src/factories/sms/sms.factory.ts index 265d7955468..58846ed1eb6 100644 --- a/libs/application-generic/src/factories/sms/sms.factory.ts +++ b/libs/application-generic/src/factories/sms/sms.factory.ts @@ -27,6 +27,7 @@ import { RingCentralHandler, SendchampSmsHandler, SimpletextingSmsHandler, + SinchHandler, Sms77Handler, SmsCentralHandler, SnsHandler, @@ -60,6 +61,7 @@ export class SmsFactory implements ISmsFactory { new SendchampSmsHandler(), new ClicksendSmsHandler(), new SimpletextingSmsHandler(), + new SinchHandler(), new BandwidthHandler(), new GenericSmsHandler(), new MessageBirdHandler(), diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 489093eb8d1..67d9c687518 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -64,6 +64,7 @@ const integrationSchema = new Schema( accessKey: Schema.Types.String, appSid: Schema.Types.String, senderId: Schema.Types.String, + servicePlanId: Schema.Types.String, }, configurations: { inboundWebhookEnabled: Schema.Types.Boolean, diff --git a/packages/framework/src/schemas/providers/sms/index.ts b/packages/framework/src/schemas/providers/sms/index.ts index f8b5d38393b..979f81ebce7 100644 --- a/packages/framework/src/schemas/providers/sms/index.ts +++ b/packages/framework/src/schemas/providers/sms/index.ts @@ -40,4 +40,5 @@ export const smsProviderSchemas = { 'afro-message': genericProviderSchemas, unifonic: genericProviderSchemas, imedia: genericProviderSchemas, + sinch: genericProviderSchemas, } as const satisfies Record; diff --git a/packages/framework/src/shared.ts b/packages/framework/src/shared.ts index 440b766fb2a..13420eff91a 100644 --- a/packages/framework/src/shared.ts +++ b/packages/framework/src/shared.ts @@ -159,6 +159,7 @@ export enum SmsProviderIdEnum { Unifonic = 'unifonic', Smsmode = 'smsmode', IMedia = 'imedia', + Sinch = 'sinch', } export enum ChatProviderIdEnum { diff --git a/packages/providers/src/lib/sms/index.ts b/packages/providers/src/lib/sms/index.ts index e6e4fc8e64c..bb7bb0ee6c7 100644 --- a/packages/providers/src/lib/sms/index.ts +++ b/packages/providers/src/lib/sms/index.ts @@ -24,6 +24,7 @@ export * from './plivo/plivo.provider'; export * from './ring-central/ring-central.provider'; export * from './sendchamp/sendchamp.provider'; export * from './simpletexting/simpletexting.provider'; +export * from './sinch/sinch.provider'; export * from './sms-central/sms-central.provider'; export * from './sms77/sms77.provider'; export * from './smsmode/smsmode.provider'; diff --git a/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts b/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts new file mode 100644 index 00000000000..d38de234f28 --- /dev/null +++ b/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts @@ -0,0 +1,111 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SinchSmsProvider } from './sinch.provider'; + +vi.mock('axios'); + +describe('SinchSmsProvider', () => { + const mockConfig = { + servicePlanId: 'test-service-plan-id', + apiToken: 'test-api-token', + from: '+1234567890', + region: 'eu', + }; + + let provider: SinchSmsProvider; + + beforeEach(() => { + provider = new SinchSmsProvider(mockConfig); + vi.clearAllMocks(); + }); + + describe('sendMessage', () => { + it('should send an SMS message successfully', async () => { + const mockResponse = { + data: { + id: 'batch-123', + created_at: '2023-01-01T00:00:00Z', + }, + }; + + vi.mocked(axios.post).mockResolvedValue(mockResponse); + + const result = await provider.sendMessage({ + to: '+9876543210', + content: 'Test message', + }); + + expect(axios.post).toHaveBeenCalledWith( + 'https://eu.sms.api.sinch.com/xms/v1/test-service-plan-id/batches', + { + from: '+1234567890', + to: ['+9876543210'], + body: 'Test message', + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-api-token', + }, + } + ); + + expect(result).toEqual({ + id: 'batch-123', + date: '2023-01-01T00:00:00Z', + }); + }); + + it('should use custom from number if provided', async () => { + const mockResponse = { + data: { + id: 'batch-456', + created_at: '2023-01-02T00:00:00Z', + }, + }; + + vi.mocked(axios.post).mockResolvedValue(mockResponse); + + await provider.sendMessage({ + to: '+9876543210', + content: 'Test message', + from: '+1111111111', + }); + + expect(axios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + from: '+1111111111', + }), + expect.any(Object) + ); + }); + + it('should use different region if configured', async () => { + const caProvider = new SinchSmsProvider({ + ...mockConfig, + region: 'ca', + }); + + const mockResponse = { + data: { + id: 'batch-789', + created_at: '2023-01-03T00:00:00Z', + }, + }; + + vi.mocked(axios.post).mockResolvedValue(mockResponse); + + await caProvider.sendMessage({ + to: '+9876543210', + content: 'Test message', + }); + + expect(axios.post).toHaveBeenCalledWith( + 'https://ca.sms.api.sinch.com/xms/v1/test-service-plan-id/batches', + expect.any(Object), + expect.any(Object) + ); + }); + }); +}); diff --git a/packages/providers/src/lib/sms/sinch/sinch.provider.ts b/packages/providers/src/lib/sms/sinch/sinch.provider.ts new file mode 100644 index 00000000000..0c695f886bc --- /dev/null +++ b/packages/providers/src/lib/sms/sinch/sinch.provider.ts @@ -0,0 +1,49 @@ +import { SmsProviderIdEnum } from '@novu/shared'; +import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; + +import axios from 'axios'; +import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { WithPassthrough } from '../../../utils/types'; + +export class SinchSmsProvider extends BaseProvider implements ISmsProvider { + id = SmsProviderIdEnum.Sinch; + protected casing = CasingEnum.CAMEL_CASE; + channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; + + constructor( + private config: { + servicePlanId?: string; + apiToken?: string; + from?: string; + region?: string; + } + ) { + super(); + } + + async sendMessage( + options: ISmsOptions, + bridgeProviderData: WithPassthrough> = {} + ): Promise { + const region = this.config.region || 'eu'; + const url = `https://${region}.sms.api.sinch.com/xms/v1/${this.config.servicePlanId}/batches`; + + const payload = this.transform>(bridgeProviderData, { + from: options.from || this.config.from, + to: [options.to], + body: options.content, + }).body; + + const response = await axios.post(url, payload, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiToken}`, + }, + }); + + return { + id: response.data.id, + date: response.data.created_at || new Date().toISOString(), + }; + } +} diff --git a/packages/shared/src/consts/providers/channels/sms.ts b/packages/shared/src/consts/providers/channels/sms.ts index 01c31c79123..1b16ea8e804 100644 --- a/packages/shared/src/consts/providers/channels/sms.ts +++ b/packages/shared/src/consts/providers/channels/sms.ts @@ -27,6 +27,7 @@ import { ringCentralConfig, sendchampConfig, simpleTextingConfig, + sinchConfig, sms77Config, smsCentralConfig, smsmodeProviderConfig, @@ -291,6 +292,14 @@ export const smsProviders: IProviderConfig[] = [ docReference: 'https://telkosh.com/mobishastra/', logoFileName: { light: 'mobishastra.png', dark: 'mobishastra.png' }, }, + { + id: SmsProviderIdEnum.Sinch, + displayName: 'Sinch', + channel: ChannelTypeEnum.SMS, + credentials: sinchConfig, + docReference: `https://docs.novu.co/integrations/providers/sms/sinch${UTM_CAMPAIGN_QUERY_PARAM}`, + logoFileName: { light: 'sinch.png', dark: 'sinch.png' }, + }, { id: SmsProviderIdEnum.AfroSms, displayName: 'Afro Message', @@ -307,7 +316,7 @@ export const smsProviders: IProviderConfig[] = [ docReference: 'https://docs.unifonic.com/articles/#!products-documentation/getting-started-with-unifonic', logoFileName: { light: 'unifonic.svg', dark: 'unifonic.svg' }, }, - { + { id: SmsProviderIdEnum.Smsmode, displayName: 'smsmode', channel: ChannelTypeEnum.SMS, diff --git a/packages/shared/src/consts/providers/credentials/provider-credentials.ts b/packages/shared/src/consts/providers/credentials/provider-credentials.ts index ca8a9235576..2d9fa378212 100644 --- a/packages/shared/src/consts/providers/credentials/provider-credentials.ts +++ b/packages/shared/src/consts/providers/credentials/provider-credentials.ts @@ -1274,3 +1274,35 @@ export const smsmodeProviderConfig: IConfigCredential[] = [ }, ...smsConfigBase, ]; + +export const sinchConfig: IConfigCredential[] = [ + { + key: CredentialsKeyEnum.ServicePlanId, + displayName: 'Service Plan ID', + description: 'Your Sinch Service Plan ID', + type: 'string', + required: true, + }, + { + key: CredentialsKeyEnum.ApiToken, + displayName: 'API Token', + type: 'string', + required: true, + }, + { + key: CredentialsKeyEnum.Region, + displayName: 'Region', + description: 'Select your Sinch region', + type: 'dropdown', + required: true, + value: 'eu', + dropdown: [ + { name: 'EU (Ireland, Sweden)', value: 'eu' }, + { name: 'US', value: 'us' }, + { name: 'Australia', value: 'au' }, + { name: 'Brazil', value: 'br' }, + { name: 'Canada', value: 'ca' }, + ], + }, + ...smsConfigBase, +]; diff --git a/packages/shared/src/entities/integration/credential.interface.ts b/packages/shared/src/entities/integration/credential.interface.ts index afcd1bda74c..ef05147de8c 100644 --- a/packages/shared/src/entities/integration/credential.interface.ts +++ b/packages/shared/src/entities/integration/credential.interface.ts @@ -47,4 +47,5 @@ export interface ICredentials { apiVersion?: string; appSid?: string; senderId?: string; + servicePlanId?: string; } diff --git a/packages/shared/src/types/providers.ts b/packages/shared/src/types/providers.ts index eb860628225..8b3ff9bb811 100644 --- a/packages/shared/src/types/providers.ts +++ b/packages/shared/src/types/providers.ts @@ -49,6 +49,7 @@ export enum CredentialsKeyEnum { ApiVersion = 'apiVersion', AppSid = 'appSid', SenderId = 'senderId', + ServicePlanId = 'servicePlanId', } export type ConfigurationKey = keyof IConfigurations; @@ -115,6 +116,7 @@ export enum SmsProviderIdEnum { // cspell:disable-next-line Smsmode = 'smsmode', IMedia = 'imedia', + Sinch = 'sinch', } export enum ChatProviderIdEnum {