feat: add sms notification
This commit is contained in:
parent
3a10dd136b
commit
7e9e47f755
@ -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",
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/config/factories/exolve.factory.ts
Normal file
10
src/config/factories/exolve.factory.ts
Normal 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') ?? ''
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export * from './mailer.factory'
|
||||
export * from './exolve.factory'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
1
src/infra/sms/constants/index.ts
Normal file
1
src/infra/sms/constants/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './sms.constants'
|
||||
1
src/infra/sms/constants/sms.constants.ts
Normal file
1
src/infra/sms/constants/sms.constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const SMS_OPTIONS = Symbol('SMS_OPTIONS');
|
||||
3
src/infra/sms/interfaces/index.ts
Normal file
3
src/infra/sms/interfaces/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './sms-options.interface'
|
||||
export * from './sms-async-options'
|
||||
export * from './send-sms.interface'
|
||||
10
src/infra/sms/interfaces/send-sms.interface.ts
Normal file
10
src/infra/sms/interfaces/send-sms.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export interface SendSmsRequest {
|
||||
sender?: string
|
||||
destination: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SendSmsResponse {
|
||||
message_id: string
|
||||
template_resource_id: string
|
||||
}
|
||||
8
src/infra/sms/interfaces/sms-async-options.ts
Normal file
8
src/infra/sms/interfaces/sms-async-options.ts
Normal 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']
|
||||
}
|
||||
4
src/infra/sms/interfaces/sms-options.interface.ts
Normal file
4
src/infra/sms/interfaces/sms-options.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface SmsOptions {
|
||||
apiKey: string;
|
||||
sender: string;
|
||||
}
|
||||
40
src/infra/sms/sms.module.ts
Normal file
40
src/infra/sms/sms.module.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/infra/sms/sms.service.ts
Normal file
108
src/infra/sms/sms.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
})
|
||||
|
||||
30
yarn.lock
30
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user