Compare commits

..

5 Commits

Author SHA1 Message Date
bbbfb889ff feat: add otp code sending and verification 2026-01-25 10:04:20 +03:00
62b06982ae feat: add redis 2026-01-24 14:30:19 +03:00
4992f5651e feat: add postgres 2026-01-24 14:26:10 +03:00
9c7b81bf11 feat: add send otp method 2026-01-24 13:20:35 +03:00
f9dc39f29b feat: add prisma 2026-01-24 13:20:14 +03:00
18 changed files with 1052 additions and 25 deletions

2
.gitignore vendored
View File

@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/prisma/generated

View File

@ -30,6 +30,11 @@ export default tseslint.config(
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-redundant-type-constituents": "off",
},
},
);

View File

@ -17,7 +17,9 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
@ -28,7 +30,12 @@
"@nestjs/mapped-types": "*",
"@nestjs/microservices": "^11.1.12",
"@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^7.3.0",
"@teacinema/contracts": "^1.0.0",
"dotenv-expand": "^12.0.3",
"ioredis": "^5.9.2",
"prisma": "^7.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},

12
prisma.config.ts Normal file
View File

@ -0,0 +1,12 @@
import 'dotenv-expand/config'
import { defineConfig } from 'prisma/config'
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations'
},
datasource: {
url: process.env['POSTGRES_URI']
}
})

24
prisma/schema.prisma Normal file
View File

@ -0,0 +1,24 @@
generator client {
provider = "prisma-client"
output = "./generated"
moduleFormat = "cjs"
}
datasource db {
provider = "postgresql"
}
model Account {
id String @id @default(nanoid())
phone String? @unique
email String? @unique
isPhoneVerified Boolean @default(false) @map("is_phone_verified")
isEmailVerified Boolean @default(false) @map("is_email_verified")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("accounts")
}

View File

@ -1,8 +1,18 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { PrismaModule } from './infra/prisma/prisma.module'
import { RedisModule } from './infra/redis/redis.module'
import { AuthModule } from './modules/auth/auth.module'
import { OtpModule } from './modules/otp/otp.module';
@Module({
imports: [AuthModule]
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
RedisModule,
AuthModule,
OtpModule
]
})
export class AppModule {}

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

View File

@ -0,0 +1,59 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@prisma/generated/client'
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name)
public constructor(private readonly configService: ConfigService) {
super({
adapter: new PrismaPg({
user: configService.getOrThrow<string>('POSTGRES_USERNAME'),
password: configService.getOrThrow<string>('POSTGRES_PASSWORD'),
host: configService.getOrThrow<string>('POSTGRES_HOST'),
port: configService.getOrThrow<number>('POSTGRES_PORT'),
database: configService.getOrThrow<string>('POSTGRES_DATABASE_NAME')
})
})
}
public async onModuleInit() {
const start = Date.now()
this.logger.log('Prisma connecting...')
try {
await this.$connect()
const ms = Date.now() - start
this.logger.log(`Prisma connected in ${ms}ms`)
} catch (e) {
this.logger.error('Prisma connection error ', e)
throw e
}
}
public async onModuleDestroy() {
this.logger.log('Prisma disconnecting...')
try {
await this.$disconnect()
this.logger.log('Prisma disconnected')
} catch (e) {
this.logger.error('Prisma disconnection error ', e)
}
}
}

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common'
import { RedisService } from './redis.service'
@Global()
@Module({
providers: [RedisService],
exports: [RedisService]
})
export class RedisModule {}

View File

@ -0,0 +1,64 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Redis from 'ioredis'
@Injectable()
export class RedisService
extends Redis
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(RedisService.name)
public constructor(private readonly configService: ConfigService) {
super({
username: configService.getOrThrow<string>('REDIS_USERNAME'),
host: configService.getOrThrow<string>('REDIS_HOST'),
port: configService.getOrThrow<number>('REDIS_PORT'),
password: configService.getOrThrow<string>('REDIS_PASSWORD'),
maxRetriesPerRequest: 5,
enableOfflineQueue: true
})
}
public onModuleInit() {
const start = Date.now()
this.on('connect', () => {
this.logger.log('Redis connecting...')
})
this.on('ready', () => {
this.logger.log(`Redis connected in ${Date.now() - start}ms`)
})
this.on('error', e => {
this.logger.error('Redis connection error', {
error: e.message || e
})
})
this.on('close', () => {
this.logger.warn('Redis connection closed')
})
this.on('reconnecting', () => {
this.logger.log('Redis reconnecting')
})
}
public async onModuleDestroy() {
this.logger.log('Redis disconnecting...')
try {
await this.quit()
this.logger.log('Redis disconnected')
} catch (e) {
this.logger.error('Redis disconnecting error', e)
}
}
}

View File

@ -2,7 +2,9 @@ import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices'
import type {
SendOtpRequest,
SendOtpResponse
SendOtpResponse,
VerifyOtpRequest,
VerifyOtpResponse
} from '@teacinema/contracts/gen/auth'
import { AuthService } from './auth.service'
@ -12,10 +14,12 @@ export class AuthController {
constructor(private readonly authService: AuthService) {}
@GrpcMethod('AuthService', 'SendOtp')
public sendOtp(data: SendOtpRequest): SendOtpResponse {
console.log('Incoming OTP request', data)
return {
ok: true
}
public sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
return this.authService.sendOtp(data)
}
@GrpcMethod('AuthService', 'VerifyOtp')
public VerifyOtp(data: VerifyOtpRequest): Promise<VerifyOtpResponse> {
return this.authService.verifyOtp(data)
}
}

View File

@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'
import { OtpService } from '@/modules/otp/otp.service'
import { AuthController } from './auth.controller'
import { AuthRepository } from './auth.repository'
import { AuthService } from './auth.service'
@Module({
controllers: [AuthController],
providers: [AuthService]
providers: [AuthService, AuthRepository, OtpService]
})
export class AuthModule {}

View File

@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common'
import { Account } from '@prisma/generated/client'
import {
AccountCreateInput,
AccountUpdateInput
} from '@prisma/generated/models/Account'
import { PrismaService } from '@/infra/prisma/prisma.service'
@Injectable()
export class AuthRepository {
public constructor(private readonly prismaService: PrismaService) {}
public findByPhone(phone: NonNullable<Account['phone']>) {
return this.prismaService.account.findUnique({
where: { phone }
})
}
public findByEmail(email: NonNullable<Account['email']>) {
return this.prismaService.account.findUnique({
where: { email }
})
}
public createAccount(data: AccountCreateInput) {
return this.prismaService.account.create({ data })
}
public updateAccount(id: Account['id'], data: AccountUpdateInput) {
return this.prismaService.account.update({
where: { id },
data
})
}
}

View File

@ -1,4 +1,81 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { Account } from '@prisma/generated/client'
import {
SendOtpRequest,
SendOtpResponse,
VerifyOtpRequest,
VerifyOtpResponse
} from '@teacinema/contracts/gen/auth'
import { OtpService } from '@/modules/otp/otp.service'
import { AuthRepository } from './auth.repository'
@Injectable()
export class AuthService {}
export class AuthService {
public constructor(
private readonly authRepository: AuthRepository,
private readonly otpService: OtpService
) {}
public async sendOtp(data: SendOtpRequest): Promise<SendOtpResponse> {
const { identifier, type } = data
let account: Account | null = null
const isPhoneType = type === 'phone'
const isEmailType = type === 'email'
if (isPhoneType) {
account = await this.authRepository.findByPhone(identifier)
} else {
account = await this.authRepository.findByEmail(identifier)
}
if (!account) {
account = await this.authRepository.createAccount({
email: isEmailType ? identifier : undefined,
phone: isPhoneType ? identifier : undefined
})
}
const code = await this.otpService.send(
identifier,
type as 'email' | 'phone'
)
console.debug('CODE', code)
return { ok: true }
}
public async verifyOtp(data: VerifyOtpRequest): Promise<VerifyOtpResponse> {
const { identifier, code, type } = data
await this.otpService.verify(identifier, code, type as 'email' | 'phone')
const isPhoneType = type === 'phone'
const isEmailType = type === 'email'
const account = isPhoneType
? await this.authRepository.findByPhone(identifier)
: await this.authRepository.findByEmail(identifier)
if (!account) {
throw new RpcException('Account not found')
}
if (isPhoneType && !account.isPhoneVerified) {
await this.authRepository.updateAccount(account.id, {
isPhoneVerified: true
})
}
if (isEmailType && !account.isEmailVerified) {
await this.authRepository.updateAccount(account.id, {
isEmailVerified: true
})
}
return { accessToken: '123456', refreshToken: '123456' }
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { OtpService } from './otp.service'
@Module({
providers: [OtpService]
})
export class OtpModule {}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { createHash } from 'node:crypto'
import { RedisService } from '@/infra/redis/redis.service'
@Injectable()
export class OtpService {
public constructor(private readonly redisService: RedisService) {}
public async send(indentifier: string, type: 'email' | 'phone') {
const { code, hash } = this.generateCode()
await this.redisService.set(
`otp:${type}:${indentifier}`,
hash,
'EX',
60 * 5
)
return code
}
public async verify(
indentifier: string,
code: string,
type: 'email' | 'phone'
) {
const stroredHash = await this.redisService.get(
`otp:${type}:${indentifier}`
)
if (!stroredHash) {
throw new RpcException('Invalid or expired code')
}
const hash = createHash('sha256').update(code).digest('hex')
if (stroredHash !== hash) {
throw new RpcException('Invalid or expired code')
}
await this.redisService.del(`otp:${type}:${indentifier}`)
}
private generateCode() {
// todo посмотреть готовые бибилотеки для генерации friendly otp
const code = Math.floor(100_000 + Math.random() * 900_000)
const hash = createHash('sha256').update(String(code)).digest('hex')
return { code, hash }
}
}

View File

@ -20,6 +20,11 @@
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["src/*"],
"@prisma/generated": ["./prisma/generated"],
"@prisma/generated/*": ["./prisma/generated/*"]
}
}
}

667
yarn.lock

File diff suppressed because it is too large Load Diff