Compare commits

..

2 Commits

Author SHA1 Message Date
3a10dd136b feat: add email notifications 2026-02-25 05:46:55 +03:00
4797e2feda feat: env validation 2026-02-23 11:30:38 +03:00
15 changed files with 1843 additions and 33 deletions

View File

@ -20,17 +20,21 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@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",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/microservices": "^11.1.14", "@nestjs/microservices": "^11.1.14",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@teacinema/contracts": "^1.1.0",
"@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",
"handlebars": "^4.7.8",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

View File

@ -1,16 +1,21 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config' import { ConfigModule } from '@nestjs/config'
import configuration from './config/configuration'
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'
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true isGlobal: true,
load: [configuration],
expandVariables: true
}), }),
RmqModule, RmqModule,
NotificationsModule NotificationsModule,
MailModule
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@ -0,0 +1,30 @@
import validationSchema from './validation.schema'
export default () => {
const parsed = validationSchema.safeParse(process.env)
if (!parsed.success) {
console.error('Invalid enviroment variables', parsed.error.format())
process.exit(1)
}
const env = parsed.data
return {
app: {
nodeEnv: env.NODE_ENV
},
rmq: {
url: env.RMQ_URL,
queue: env.RMQ_QUEUE
},
smtp: {
host: env.SMTP_HOST,
port: env.SMTP_PORT,
username: env.SMTP_USERNAME,
password: env.SMTP_PASSWORD,
fromAddress: env.SMTP_FROM_ADDRESS,
secure: env.SMTP_SECURE
}
}
}

View File

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

View File

@ -0,0 +1,19 @@
import { MailerOptions } from '@nestjs-modules/mailer'
import { ConfigService } from '@nestjs/config'
export function getMailerConfig(configService: ConfigService): MailerOptions {
return {
transport: {
host: configService.get('smtp.host'),
port: configService.get('smtp.port'),
secure: configService.get('smtp.secure'),
auth: {
user: configService.get('smtp.username'),
pass: configService.get('smtp.password')
}
},
defaults: {
from: `Teacinema ${configService.get('smtp.fromAddress')}`
}
}
}

View File

@ -0,0 +1,25 @@
import { z } from 'zod'
export enum Enviroment {
Development = 'development',
Production = 'production',
Test = 'test'
}
export default z.object({
// Node
NODE_ENV: z.enum(Enviroment).default(Enviroment.Development),
// RabbitMQ
RMQ_URL: z.string().nonempty(),
RMQ_QUEUE: z.string().nonempty(),
// SMTP
SMTP_HOST: z.string().nonempty(),
SMTP_PORT: z.coerce.number().nonnegative(),
SMTP_USERNAME: z.string().nonempty(),
SMTP_PASSWORD: z.string().nonempty(),
SMTP_FROM_ADDRESS: z.string().email().nonempty(),
SMTP_SECURE: z
.string()
.default('false')
.transform(value => value === 'true')
})

View File

@ -0,0 +1,20 @@
import { MailerModule } from '@nestjs-modules/mailer'
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { getMailerConfig } from 'src/config/factories'
import { MailService } from './mail.service'
import { TemplateService } from './template.service'
@Module({
imports: [
MailerModule.forRootAsync({
useFactory: (configService: ConfigService) =>
getMailerConfig(configService),
inject: [ConfigService]
})
],
providers: [MailService, TemplateService],
exports: [MailService]
})
export class MailModule {}

View File

@ -0,0 +1,22 @@
import { MailerService } from '@nestjs-modules/mailer'
import { Injectable } from '@nestjs/common'
import { TemplateService } from './template.service'
@Injectable()
export class MailService {
constructor(
private readonly transporter: MailerService,
private readonly templateService: TemplateService
) {}
public async sendOtp(email: string, code: string) {
const html = this.templateService.render('otp', { code })
await this.transporter.sendMail({
to: email,
subject: 'Ваш код подтверждения',
html
})
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common'
import * as Handlebars from 'handlebars'
import fs from 'node:fs'
import path from 'node:path'
@Injectable()
export class TemplateService {
private cache = new Map<string, Handlebars.TemplateDelegate>()
public render(templateName: string, context?: Record<string, any>) {
if (!this.cache.has(templateName)) {
const templatePath = path.join(
process.cwd(),
'src/infra/mail/templates',
`${templateName}.hbs`
)
const file = fs.readFileSync(templatePath, 'utf-8')
this.cache.set(templateName, Handlebars.compile(file))
}
return this.cache.get(templateName)!(context)
}
}

View File

@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ваш код подтверждения</title>
</head>
<body style="margin: 0; padding: 0;font-family: Arial, sans-serif;">
<table style="width: 100%;" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 40px">
<h1 style="font-size: 22px;font-weight: bold; margin: 0 0 25px">
Код подтверждения
</h1>
<p style="font-size: 15px; margin: 0 0 15px">Ваш код: </p>
<div style="font-size: 36px;font-weight: bold; margin: 0 0 15px; letter-spacing: 6px">{{code}}</div>
<p style="font-size: 13px; color: #555; margin: 30px 0 0">
Если это были не вы - просто проигнорируйте письм
</p>
<p style="font-size: 13px; margin: 8px 0 0">
<a href="https://teacinema.ru" target="_blank" style="text-decoration: none; color: #000; font-weight: bold">
teacinema.ru
</a>
</p>
</td>
</tr>
</table>
</body>
</html>

View File

@ -12,8 +12,8 @@ async function bootstrap() {
app.connectMicroservice<MicroserviceOptions>({ app.connectMicroservice<MicroserviceOptions>({
transport: Transport.RMQ, transport: Transport.RMQ,
options: { options: {
urls: [config.getOrThrow<string>('RMQ_URL')], urls: [config.get('rmq.url')],
queue: config.getOrThrow<string>('RMQ_QUEUE'), queue: config.get('rmq.queue'),
queueOptions: { queueOptions: {
durable: false durable: false
}, },

View File

@ -1,5 +1,6 @@
import { Controller } from '@nestjs/common' import { Controller } from '@nestjs/common'
import { Ctx, EventPattern, Payload, RmqContext } from '@nestjs/microservices' import { Ctx, EventPattern, Payload, RmqContext } from '@nestjs/microservices'
import type { OtpRequestedEvent } from '@teacinema/contracts'
import { RmqService } from '../../infra/rmq/rmq.service' import { RmqService } from '../../infra/rmq/rmq.service'
@ -13,9 +14,12 @@ export class NotificationsController {
) {} ) {}
@EventPattern('auth.otp.requested') @EventPattern('auth.otp.requested')
public otpRequested(@Payload() data: any, @Ctx() ctx: RmqContext) { public async otpRequested(
@Payload() data: OtpRequestedEvent,
@Ctx() ctx: RmqContext
) {
try { try {
console.log('OTP received', data) await this.notificationsService.sendOtp(data)
this.rmqService.ack(ctx) this.rmqService.ack(ctx)
} catch (e) { } catch (e) {
console.log('OTP processing error', e.message ?? e) console.log('OTP processing error', e.message ?? e)

View File

@ -1,11 +1,13 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { MailModule } from '../../infra/mail/mail.module'
import { RmqService } from '../../infra/rmq/rmq.service' import { RmqService } from '../../infra/rmq/rmq.service'
import { NotificationsController } from './notifications.controller' import { NotificationsController } from './notifications.controller'
import { NotificationsService } from './notifications.service' import { NotificationsService } from './notifications.service'
@Module({ @Module({
imports: [MailModule],
controllers: [NotificationsController], controllers: [NotificationsController],
providers: [NotificationsService, RmqService] providers: [NotificationsService, RmqService]
}) })

View File

@ -1,4 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common'
import { OtpRequestedEvent } from '@teacinema/contracts'
import { MailService } from '../../infra/mail/mail.service'
@Injectable() @Injectable()
export class NotificationsService {} export class NotificationsService {
constructor(private readonly mailService: MailService) {}
public async sendOtp(data: OtpRequestedEvent) {
const { identifier, type, code } = data
if (type === 'email') {
console.log('SEND EMAIL', data)
await this.mailService.sendOtp(identifier, code)
} else {
console.log('SMS')
}
}
}

1652
yarn.lock

File diff suppressed because it is too large Load Diff