From 7e9e47f7550d77f07c917477d881b89b52aef02d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 27 Feb 2026 06:33:41 +0300 Subject: [PATCH] feat: add sms notification --- package.json | 2 + src/app.module.ts | 4 +- src/config/configuration.ts | 4 + src/config/factories/exolve.factory.ts | 10 ++ src/config/factories/index.ts | 1 + src/config/validation.schema.ts | 5 +- src/infra/sms/constants/index.ts | 1 + src/infra/sms/constants/sms.constants.ts | 1 + src/infra/sms/interfaces/index.ts | 3 + .../sms/interfaces/send-sms.interface.ts | 10 ++ src/infra/sms/interfaces/sms-async-options.ts | 8 ++ .../sms/interfaces/sms-options.interface.ts | 4 + src/infra/sms/sms.module.ts | 40 +++++++ src/infra/sms/sms.service.ts | 108 ++++++++++++++++++ .../notifications/notifications.module.ts | 11 +- yarn.lock | 30 ++++- 16 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/config/factories/exolve.factory.ts create mode 100644 src/infra/sms/constants/index.ts create mode 100644 src/infra/sms/constants/sms.constants.ts create mode 100644 src/infra/sms/interfaces/index.ts create mode 100644 src/infra/sms/interfaces/send-sms.interface.ts create mode 100644 src/infra/sms/interfaces/sms-async-options.ts create mode 100644 src/infra/sms/interfaces/sms-options.interface.ts create mode 100644 src/infra/sms/sms.module.ts create mode 100644 src/infra/sms/sms.service.ts diff --git a/package.json b/package.json index 0fbc165..3a088b5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", @@ -31,6 +32,7 @@ "@teacinema/core": "^1.0.9", "amqp-connection-manager": "^5.0.0", "amqplib": "^0.10.9", + "axios": "^1.13.5", "handlebars": "^4.7.8", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/src/app.module.ts b/src/app.module.ts index 173681c..994ea5c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import configuration from './config/configuration' import { MailModule } from './infra/mail/mail.module' import { RmqModule } from './infra/rmq/rmq.module' import { NotificationsModule } from './modules/notifications/notifications.module' +import { SmsModule } from './infra/sms/sms.module'; @Module({ imports: [ @@ -15,7 +16,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul }), RmqModule, NotificationsModule, - MailModule + MailModule, + SmsModule ] }) export class AppModule {} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 05f22e3..01783f8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -25,6 +25,10 @@ export default () => { password: env.SMTP_PASSWORD, fromAddress: env.SMTP_FROM_ADDRESS, secure: env.SMTP_SECURE + }, + exolve: { + apiKey: env.EXOLVE_API_KEY, + sender: env.EXOLVE_SENDER } } } diff --git a/src/config/factories/exolve.factory.ts b/src/config/factories/exolve.factory.ts new file mode 100644 index 0000000..3551a6e --- /dev/null +++ b/src/config/factories/exolve.factory.ts @@ -0,0 +1,10 @@ +import { ConfigService } from '@nestjs/config' + +import { SmsOptions } from '../../infra/sms/interfaces' + +export function getExolveConfig(configService: ConfigService): SmsOptions { + return { + apiKey: configService.get('exolve.apiKey') ?? '', + sender: configService.get('exolve.sender') ?? '' + } +} diff --git a/src/config/factories/index.ts b/src/config/factories/index.ts index 9ea99e8..e2c1830 100644 --- a/src/config/factories/index.ts +++ b/src/config/factories/index.ts @@ -1 +1,2 @@ export * from './mailer.factory' +export * from './exolve.factory' diff --git a/src/config/validation.schema.ts b/src/config/validation.schema.ts index f034b02..7f1a41f 100644 --- a/src/config/validation.schema.ts +++ b/src/config/validation.schema.ts @@ -21,5 +21,8 @@ export default z.object({ SMTP_SECURE: z .string() .default('false') - .transform(value => value === 'true') + .transform(value => value === 'true'), + // MTS Exolve + EXOLVE_API_KEY: z.string().nonempty(), + EXOLVE_SENDER: z.string().nonempty() }) diff --git a/src/infra/sms/constants/index.ts b/src/infra/sms/constants/index.ts new file mode 100644 index 0000000..d57fa96 --- /dev/null +++ b/src/infra/sms/constants/index.ts @@ -0,0 +1 @@ +export * from './sms.constants' diff --git a/src/infra/sms/constants/sms.constants.ts b/src/infra/sms/constants/sms.constants.ts new file mode 100644 index 0000000..28aa87c --- /dev/null +++ b/src/infra/sms/constants/sms.constants.ts @@ -0,0 +1 @@ +export const SMS_OPTIONS = Symbol('SMS_OPTIONS'); diff --git a/src/infra/sms/interfaces/index.ts b/src/infra/sms/interfaces/index.ts new file mode 100644 index 0000000..2180cc6 --- /dev/null +++ b/src/infra/sms/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './sms-options.interface' +export * from './sms-async-options' +export * from './send-sms.interface' diff --git a/src/infra/sms/interfaces/send-sms.interface.ts b/src/infra/sms/interfaces/send-sms.interface.ts new file mode 100644 index 0000000..248c751 --- /dev/null +++ b/src/infra/sms/interfaces/send-sms.interface.ts @@ -0,0 +1,10 @@ +export interface SendSmsRequest { + sender?: string + destination: string + text: string +} + +export interface SendSmsResponse { + message_id: string + template_resource_id: string +} diff --git a/src/infra/sms/interfaces/sms-async-options.ts b/src/infra/sms/interfaces/sms-async-options.ts new file mode 100644 index 0000000..7d59c8d --- /dev/null +++ b/src/infra/sms/interfaces/sms-async-options.ts @@ -0,0 +1,8 @@ +import { FactoryProvider, ModuleMetadata } from '@nestjs/common' + +import { SmsOptions } from './sms-options.interface' + +export interface SmsAsyncOptions extends Pick { + useFactory: (...args: any[]) => Promise | SmsOptions + inject?: FactoryProvider['inject'] +} diff --git a/src/infra/sms/interfaces/sms-options.interface.ts b/src/infra/sms/interfaces/sms-options.interface.ts new file mode 100644 index 0000000..25613bc --- /dev/null +++ b/src/infra/sms/interfaces/sms-options.interface.ts @@ -0,0 +1,4 @@ +export interface SmsOptions { + apiKey: string; + sender: string; +} diff --git a/src/infra/sms/sms.module.ts b/src/infra/sms/sms.module.ts new file mode 100644 index 0000000..f3ec1c0 --- /dev/null +++ b/src/infra/sms/sms.module.ts @@ -0,0 +1,40 @@ +import { HttpModule } from '@nestjs/axios' +import { DynamicModule, Module } from '@nestjs/common' + +import { SMS_OPTIONS } from './constants' +import { SmsAsyncOptions, SmsOptions } from './interfaces' +import { SmsService } from './sms.service' + +@Module({}) +export class SmsModule { + public static register(options: SmsOptions): DynamicModule { + return { + module: SmsModule, + imports: [HttpModule], + providers: [ + SmsService, + { + provide: SMS_OPTIONS, + useValue: options + } + ], + exports: [SmsService] + } + } + + public static registerAsync(options: SmsAsyncOptions): DynamicModule { + return { + module: SmsModule, + imports: [HttpModule, ...(options.imports ?? [])], + providers: [ + SmsService, + { + provide: SMS_OPTIONS, + useFactory: options.useFactory, + inject: options.inject ?? [] + } + ], + exports: [SmsService] + } + } +} diff --git a/src/infra/sms/sms.service.ts b/src/infra/sms/sms.service.ts new file mode 100644 index 0000000..9599d45 --- /dev/null +++ b/src/infra/sms/sms.service.ts @@ -0,0 +1,108 @@ +import { HttpService } from '@nestjs/axios' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { + catchError, + delay, + firstValueFrom, + retryWhen, + scan, + throwError, + timeout +} from 'rxjs' + +import { SMS_OPTIONS } from './constants' +import type { SendSmsRequest, SendSmsResponse, SmsOptions } from './interfaces' + +@Injectable() +export class SmsService { + private readonly BASE_URL: string + private readonly logger = new Logger(SmsService.name) + + constructor( + private readonly httpService: HttpService, + @Inject(SMS_OPTIONS) private readonly options: SmsOptions + ) { + this.BASE_URL = 'https://api.exolve.ru' + } + + public async sendOtp(phone: string, code: string) { + return this.send({ + destination: phone, + text: `Ваш код подтверждения: ${code}` + }) + } + + public async sendPhoneChanged(phone: string, code: string) { + return this.send({ + destination: phone, + text: `Ваш код подтвердления для изменения номера телефона: ${code}` + }) + } + + public send(data: SendSmsRequest): Promise { + const payload = { + number: data.sender ?? this.options.sender, + destination: data.destination.replace('+', ''), + text: data.text + } + + return this.request( + 'POST', + '/messaging/v1/SendSMS', + payload + ) + } + + private async request( + method: 'GET' | 'POST', + path: string, + body: any + ): Promise { + const url = `${this.BASE_URL}${path}` + + try { + const request = this.httpService + .request({ + url, + method, + data: body, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.options.apiKey}` + } + }) + .pipe( + timeout(7000), + retryWhen(errors => + errors.pipe( + scan((retryCount, error) => { + if (retryCount >= 2) { + throw error + } + + this.logger.warn( + `Retry request (${method} ${path}): ${retryCount + 1}/3` + ) + + return retryCount + 1 + }, 0), + delay(500) + ) + ), + catchError(error => { + const detail = error.response?.data ?? error.message ?? error + this.logger.error( + `Exolve API error (${method} ${path})\n${JSON.stringify(detail)}` + ) + return throwError(() => error) + }) + ) + + const response = await firstValueFrom(request) + return response.data + } catch (e) { + this.logger.error(`Request failed (${method} ${path})\n${e.message}`) + throw e + } + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index 61fd0b7..b2bd3b9 100644 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -1,13 +1,22 @@ import { Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { getExolveConfig } from '../../config/factories' import { MailModule } from '../../infra/mail/mail.module' import { RmqService } from '../../infra/rmq/rmq.service' +import { SmsModule } from '../../infra/sms/sms.module' import { NotificationsController } from './notifications.controller' import { NotificationsService } from './notifications.service' @Module({ - imports: [MailModule], + imports: [ + MailModule, + SmsModule.registerAsync({ + useFactory: getExolveConfig, + inject: [ConfigService] + }) + ], controllers: [NotificationsController], providers: [NotificationsService, RmqService] }) diff --git a/yarn.lock b/yarn.lock index 2b34ba9..276033c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1025,6 +1025,11 @@ preview-email "^3.0.19" pug "^3.0.2" +"@nestjs/axios@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@nestjs/axios/-/axios-4.0.1.tgz#7ff73f47727b67dc04410ac6e9b3329401ebbb65" + integrity sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A== + "@nestjs/cli@^11.0.0": version "11.0.16" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-11.0.16.tgz#bd92b679638b5b93d0db6b9252d089412dc4e9f4" @@ -1185,9 +1190,9 @@ "@sinonjs/commons" "^3.0.1" "@teacinema/contracts@^1.1.0": - version "1.1.0" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.0/contracts-1.1.0.tgz#0d2f03f283dc9ee048eeb770152127f2037b7133" - integrity sha512-x90R4R96AgrUDzOjUT7ePtc7phG3Gx61sI8/CZ1hETF3Ok/TWTHblxX/CIZVcOormBrxEh1FfU39P8/nfno91g== + version "1.1.1" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.1/contracts-1.1.1.tgz#69fc54e0e096cad5ecc5d8bb654d982b6c70996b" + integrity sha512-yK3Th430sWumcnm2/jyEx8dtaA59hmCvCAnul+q/JbW+C2s/AyhBEBoxToDVxx/KKTDixDePJ3O3EnNnIHGgRw== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0" @@ -2040,6 +2045,15 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +axios@^1.13.5: + version "1.13.5" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" + integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^1.1.0" + babel-jest@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" @@ -3325,6 +3339,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== +follow-redirects@^1.15.11: + version "1.15.11" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" @@ -5507,6 +5526,11 @@ proxy-addr@^2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + pug-attrs@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-3.0.0.tgz#b10451e0348165e31fad1cc23ebddd9dc7347c41"