feat: add sms notification

This commit is contained in:
Sergey Krylov 2026-02-27 06:33:41 +03:00
parent 3a10dd136b
commit 7e9e47f755
16 changed files with 236 additions and 6 deletions

View File

@ -21,6 +21,7 @@
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3", "@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
@ -31,6 +32,7 @@
"@teacinema/core": "^1.0.9", "@teacinema/core": "^1.0.9",
"amqp-connection-manager": "^5.0.0", "amqp-connection-manager": "^5.0.0",
"amqplib": "^0.10.9", "amqplib": "^0.10.9",
"axios": "^1.13.5",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@ -5,6 +5,7 @@ import configuration from './config/configuration'
import { MailModule } from './infra/mail/mail.module' import { MailModule } from './infra/mail/mail.module'
import { RmqModule } from './infra/rmq/rmq.module' import { RmqModule } from './infra/rmq/rmq.module'
import { NotificationsModule } from './modules/notifications/notifications.module' import { NotificationsModule } from './modules/notifications/notifications.module'
import { SmsModule } from './infra/sms/sms.module';
@Module({ @Module({
imports: [ imports: [
@ -15,7 +16,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
}), }),
RmqModule, RmqModule,
NotificationsModule, NotificationsModule,
MailModule MailModule,
SmsModule
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@ -25,6 +25,10 @@ export default () => {
password: env.SMTP_PASSWORD, password: env.SMTP_PASSWORD,
fromAddress: env.SMTP_FROM_ADDRESS, fromAddress: env.SMTP_FROM_ADDRESS,
secure: env.SMTP_SECURE secure: env.SMTP_SECURE
},
exolve: {
apiKey: env.EXOLVE_API_KEY,
sender: env.EXOLVE_SENDER
} }
} }
} }

View File

@ -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') ?? ''
}
}

View File

@ -1 +1,2 @@
export * from './mailer.factory' export * from './mailer.factory'
export * from './exolve.factory'

View File

@ -21,5 +21,8 @@ export default z.object({
SMTP_SECURE: z SMTP_SECURE: z
.string() .string()
.default('false') .default('false')
.transform(value => value === 'true') .transform(value => value === 'true'),
// MTS Exolve
EXOLVE_API_KEY: z.string().nonempty(),
EXOLVE_SENDER: z.string().nonempty()
}) })

View File

@ -0,0 +1 @@
export * from './sms.constants'

View File

@ -0,0 +1 @@
export const SMS_OPTIONS = Symbol('SMS_OPTIONS');

View File

@ -0,0 +1,3 @@
export * from './sms-options.interface'
export * from './sms-async-options'
export * from './send-sms.interface'

View File

@ -0,0 +1,10 @@
export interface SendSmsRequest {
sender?: string
destination: string
text: string
}
export interface SendSmsResponse {
message_id: string
template_resource_id: string
}

View File

@ -0,0 +1,8 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common'
import { SmsOptions } from './sms-options.interface'
export interface SmsAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useFactory: (...args: any[]) => Promise<SmsOptions> | SmsOptions
inject?: FactoryProvider['inject']
}

View File

@ -0,0 +1,4 @@
export interface SmsOptions {
apiKey: string;
sender: string;
}

View File

@ -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]
}
}
}

View File

@ -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<SendSmsResponse> {
const payload = {
number: data.sender ?? this.options.sender,
destination: data.destination.replace('+', ''),
text: data.text
}
return this.request<SendSmsResponse>(
'POST',
'/messaging/v1/SendSMS',
payload
)
}
private async request<T>(
method: 'GET' | 'POST',
path: string,
body: any
): Promise<T> {
const url = `${this.BASE_URL}${path}`
try {
const request = this.httpService
.request<T>({
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
}
}
}

View File

@ -1,13 +1,22 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { getExolveConfig } from '../../config/factories'
import { MailModule } from '../../infra/mail/mail.module' import { MailModule } from '../../infra/mail/mail.module'
import { RmqService } from '../../infra/rmq/rmq.service' import { RmqService } from '../../infra/rmq/rmq.service'
import { SmsModule } from '../../infra/sms/sms.module'
import { NotificationsController } from './notifications.controller' import { NotificationsController } from './notifications.controller'
import { NotificationsService } from './notifications.service' import { NotificationsService } from './notifications.service'
@Module({ @Module({
imports: [MailModule], imports: [
MailModule,
SmsModule.registerAsync({
useFactory: getExolveConfig,
inject: [ConfigService]
})
],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService, RmqService] providers: [NotificationsService, RmqService]
}) })

View File

@ -1025,6 +1025,11 @@
preview-email "^3.0.19" preview-email "^3.0.19"
pug "^3.0.2" 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": "@nestjs/cli@^11.0.0":
version "11.0.16" version "11.0.16"
resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-11.0.16.tgz#bd92b679638b5b93d0db6b9252d089412dc4e9f4" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-11.0.16.tgz#bd92b679638b5b93d0db6b9252d089412dc4e9f4"
@ -1185,9 +1190,9 @@
"@sinonjs/commons" "^3.0.1" "@sinonjs/commons" "^3.0.1"
"@teacinema/contracts@^1.1.0": "@teacinema/contracts@^1.1.0":
version "1.1.0" version "1.1.1"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.0/contracts-1.1.0.tgz#0d2f03f283dc9ee048eeb770152127f2037b7133" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.1.1/contracts-1.1.1.tgz#69fc54e0e096cad5ecc5d8bb654d982b6c70996b"
integrity sha512-x90R4R96AgrUDzOjUT7ePtc7phG3Gx61sI8/CZ1hETF3Ok/TWTHblxX/CIZVcOormBrxEh1FfU39P8/nfno91g== integrity sha512-yK3Th430sWumcnm2/jyEx8dtaA59hmCvCAnul+q/JbW+C2s/AyhBEBoxToDVxx/KKTDixDePJ3O3EnNnIHGgRw==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"
@ -2040,6 +2045,15 @@ asynckit@^0.4.0:
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== 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: babel-jest@30.2.0:
version "30.2.0" version "30.2.0"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.2.0.tgz#fd44a1ec9552be35ead881f7381faa7d8f3b95ac" 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" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== 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: foreground-child@^3.1.0:
version "3.3.1" version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" 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" forwarded "0.2.0"
ipaddr.js "1.9.1" 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: pug-attrs@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-3.0.0.tgz#b10451e0348165e31fad1cc23ebddd9dc7347c41" resolved "https://registry.yarnpkg.com/pug-attrs/-/pug-attrs-3.0.0.tgz#b10451e0348165e31fad1cc23ebddd9dc7347c41"