From 7bac0e45ab936c2d5f578248bbb4b7af49aae89a Mon Sep 17 00:00:00 2001 From: soorq Date: Thu, 14 May 2026 21:38:02 +0300 Subject: [PATCH 1/6] chore(tech-debt): refactor health module add to components and review types --- .../src/controller/health.controller.ts | 18 ++++--- .../src/controller/health.controlller.spec.ts | 30 +++++++++-- libs/health/src/dtos/health.dto.ts | 18 +++---- libs/health/src/health.module-definition.ts | 16 ++++++ libs/health/src/health.module.ts | 26 +++------ libs/health/src/health.service.ts | 53 +++++++++++++------ libs/health/src/interfaces/index.ts | 1 + .../health/src/interfaces/module.interface.ts | 9 ++++ src/app.module.ts | 17 +++++- 9 files changed, 131 insertions(+), 57 deletions(-) create mode 100644 libs/health/src/health.module-definition.ts create mode 100644 libs/health/src/interfaces/index.ts create mode 100644 libs/health/src/interfaces/module.interface.ts diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index 0551121..d602a5a 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -4,29 +4,33 @@ import { HealthService } from '../health.service'; import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; import { ApiTags } from '@nestjs/swagger'; import { BaseException } from '@shared/error'; +import { MODULE_OPTIONS_TOKEN } from '../health.module-definition'; +import { HealthModuleOptions } from '../interfaces'; @SkipThrottle() @Controller() @ApiTags('System') export class HealthController { constructor( - private readonly healthService: HealthService, - @Inject('SERVICE_NAME') private readonly serviceName: string, + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: HealthModuleOptions, + private readonly service: HealthService, ) {} @Get('health') @GetHealthSwagger() async checkHealth() { - const pingData = await this.healthService.getHealthData(); + const { serviceName } = this.options; + const pingData = await this.service.getHealthData(); - if (pingData.status !== 'up') { + if (!pingData.status) { throw new BaseException( { code: 'SERVICE_UNHEALTHY', - message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`, + message: `Сервис ${serviceName} временно недоступен или работает некорректно`, details: [ { - target: this.serviceName, + target: serviceName, status: pingData.status, timestamp: new Date().toISOString(), }, @@ -42,6 +46,6 @@ export class HealthController { @Get('ping') @GetPingSwagger() async ping() { - return this.healthService.getHealthData(); + return this.service.getHealthData(); } } diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index 8e061a4..44f51b4 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -5,18 +5,25 @@ import { HttpStatus, Logger } from '@nestjs/common'; describe('HealthController', () => { let controller: HealthController; let healthServiceMock: { getHealthData: ReturnType }; + const SERVICE_NAME = 'MyService'; + const mockOptions = { serviceName: SERVICE_NAME }; + beforeEach(() => { healthServiceMock = { getHealthData: vi.fn(), }; - controller = new HealthController(healthServiceMock as any, SERVICE_NAME); + + controller = new HealthController(mockOptions as any, healthServiceMock as any); vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); - it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' }); + it('should throw SERVICE_UNAVAILABLE when service status is false (down)', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ + status: false, + components: { database: 'down' }, + }); await expect(controller.checkHealth()).rejects.toMatchObject({ status: HttpStatus.SERVICE_UNAVAILABLE, @@ -25,17 +32,30 @@ describe('HealthController', () => { message: expect.stringContaining(SERVICE_NAME), details: expect.arrayContaining([ expect.objectContaining({ - status: 'down', target: SERVICE_NAME, + status: false, }), ]), }, }); }); + it('should return "healthy" when status is true', async () => { + healthServiceMock.getHealthData.mockResolvedValue({ status: true }); + + const result = await controller.checkHealth(); + + expect(result).toBe('healthy'); + }); + describe('ping', () => { it('should return the full health payload', async () => { - const mockPayload = { status: 'up' }; + const mockPayload = { + service: SERVICE_NAME, + status: true, + components: {}, + time: { uptime: '1h 0m 0s' }, + }; healthServiceMock.getHealthData.mockResolvedValue(mockPayload); const result = await controller.ping(); diff --git a/libs/health/src/dtos/health.dto.ts b/libs/health/src/dtos/health.dto.ts index 5ffd93d..f57390a 100644 --- a/libs/health/src/dtos/health.dto.ts +++ b/libs/health/src/dtos/health.dto.ts @@ -3,23 +3,21 @@ import { z } from 'zod/v4'; const HealthResponseSchema = z.object({ service: z.string().describe('Название сервиса'), - status: z.enum(['up', 'down']).describe('Текущий статус'), + status: z.boolean().describe('Общий статус работоспособности (true — ок, false — есть сбои)'), + components: z + .record(z.string(), z.enum(['up', 'down'])) + .describe('Статусы отдельных компонентов (например, database, redis)'), info: z.object({ version: z.string().describe('Версия приложения'), node: z.string().describe('Версия Node.js'), - pid: z.number().describe('ID процесса'), }), time: z.object({ - now: z.string().datetime().describe('Текущее время сервера'), - startedAt: z.string().datetime().describe('Время старта сервера'), - uptime: z.string().describe('Аптайм в формате ч/м/с'), + now: z.string().datetime().describe('Текущее время сервера (ISO)'), + startedAt: z.string().datetime().describe('Время старта сервера (ISO)'), + uptime: z.string().describe('Аптайм в читаемом формате (h m s)'), uptimeSeconds: z.number().describe('Аптайм в секундах'), }), - metrics: z.object({ - rss: z.string().describe('Resident Set Size (общая память)'), - heapUsed: z.string().describe('Использованная память в куче'), - loadAverage: z.string().describe('Средняя нагрузка на CPU'), - }), + loaded: z.string().describe('Средняя нагрузка на CPU за последнюю минуту (Load Average)'), }); export class HealthResponse extends createZodDto(HealthResponseSchema) {} diff --git a/libs/health/src/health.module-definition.ts b/libs/health/src/health.module-definition.ts new file mode 100644 index 0000000..c0c7639 --- /dev/null +++ b/libs/health/src/health.module-definition.ts @@ -0,0 +1,16 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; +import { HealthModuleOptions } from './interfaces'; + +export const { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('register') + .setExtras( + { + global: false, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/health/src/health.module.ts b/libs/health/src/health.module.ts index c391e72..3206758 100644 --- a/libs/health/src/health.module.ts +++ b/libs/health/src/health.module.ts @@ -1,21 +1,11 @@ -import { type DynamicModule, Global, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { HealthController } from './controller/health.controller'; import { HealthService } from './health.service'; +import { ConfigurableModuleClass } from './health.module-definition'; -@Global() -@Module({}) -export class HealthModule { - static register(serviceName: string): DynamicModule { - return { - module: HealthModule, - providers: [ - { - provide: 'SERVICE_NAME', - useValue: serviceName, - }, - HealthService, - ], - controllers: [HealthController], - }; - } -} +@Module({ + controllers: [HealthController], + providers: [HealthService], + exports: [HealthService], +}) +export class HealthModule extends ConfigurableModuleClass {} diff --git a/libs/health/src/health.service.ts b/libs/health/src/health.service.ts index 076a1a6..2a60842 100644 --- a/libs/health/src/health.service.ts +++ b/libs/health/src/health.service.ts @@ -1,28 +1,57 @@ import { Inject, Injectable } from '@nestjs/common'; import * as os from 'os'; +import { MODULE_OPTIONS_TOKEN } from './health.module-definition'; +import type { HealthModuleOptions } from './interfaces'; @Injectable() export class HealthService { private readonly startTime: Date; constructor( - @Inject('SERVICE_NAME') - private readonly serviceName: string, + @Inject(MODULE_OPTIONS_TOKEN) + private readonly options: HealthModuleOptions, ) { this.startTime = new Date(); } async getHealthData() { + const { serviceName, version = 'v1.0.0', indicators = {} } = this.options; + const uptimeSeconds = Math.floor(process.uptime()); - const mem = process.memoryUsage(); + + const results = await Promise.all( + Object.entries(indicators).map(async ([name, check]) => { + try { + const result = await Promise.race([ + check(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 5000), + ), + ]); + return { name, ok: !!result }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return { name, ok: false, error: message }; + } + }), + ); + + const isAllOk = results.every((r) => r.ok); + const components = results.reduce( + (acc, curr) => ({ + ...acc, + [curr.name]: curr.ok ? 'up' : 'down', + }), + {}, + ); return { - service: this.serviceName, - status: 'up', + service: serviceName, + status: isAllOk, + components, info: { - version: '1.0.0', + version, node: process.version, - pid: process.pid, }, time: { now: new Date().toISOString(), @@ -30,18 +59,10 @@ export class HealthService { uptime: this.formatUptime(uptimeSeconds), uptimeSeconds: uptimeSeconds, }, - metrics: { - rss: this.toMb(mem.rss), - heapUsed: this.toMb(mem.heapUsed), - loadAverage: os.loadavg()[0].toFixed(2), - }, + loaded: os.loadavg()[0].toFixed(2), }; } - private toMb(bytes: number) { - return `${Math.round(bytes / 1024 / 1024)}MB`; - } - private formatUptime(seconds: number) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/libs/health/src/interfaces/index.ts b/libs/health/src/interfaces/index.ts new file mode 100644 index 0000000..22f53a1 --- /dev/null +++ b/libs/health/src/interfaces/index.ts @@ -0,0 +1 @@ +export type * from './module.interface'; diff --git a/libs/health/src/interfaces/module.interface.ts b/libs/health/src/interfaces/module.interface.ts new file mode 100644 index 0000000..054caaf --- /dev/null +++ b/libs/health/src/interfaces/module.interface.ts @@ -0,0 +1,9 @@ +export type HealthIndicatorsServices = 'redis' | 'database' | 'storage' | 'http'; +export type HealthIndicatorKey = HealthIndicatorsServices | (string & NonNullable); +export type HealthIndicatorFn = () => boolean | Promise; + +export interface HealthModuleOptions { + serviceName: string; + version?: string; + indicators?: Partial>; +} diff --git a/src/app.module.ts b/src/app.module.ts index 9c03ed5..f16b69c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; import { HttpModule } from '@nestjs/axios'; import { MediaModule } from '@shared/media'; +import { version } from '../package.json'; @Module({ imports: [ @@ -69,7 +70,21 @@ import { MediaModule } from '@shared/media'; }, adapter: FastifyAdapter, }), - HealthModule.register('gateway'), + HealthModule.registerAsync({ + inject: [], + useFactory: () => { + return { + serviceName: 'gateway', + version, + indicators: { + database: async () => true, + redis: async () => true, + storage: async () => true, + http: async () => true, + }, + }; + }, + }), ], providers: [ { From 7ed455efc32f79b1aaf4fc2c9cb0a97394fc5ec7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 15 May 2026 18:02:01 +0300 Subject: [PATCH 2/6] feat(cache): add cache module and redis adapter --- src/app.module.ts | 2 + .../confirm-reset-password.use-case.ts | 14 +- .../use-cases/reset-password.use-case.ts | 14 +- .../use-cases/sign-up-verify.use-case.ts | 12 +- .../application/use-cases/sign-up.use-case.ts | 14 +- .../verify-reset-password.use-case.ts | 15 +- src/auth/auth.module.ts | 22 --- src/shared/adapters/cache/adapters/index.ts | 1 + .../cache/adapters/redis-cache.adapter.ts | 158 ++++++++++++++++++ src/shared/adapters/cache/constants.ts | 1 + src/shared/adapters/cache/module.ts | 40 +++++ src/shared/adapters/cache/ports/index.ts | 1 + .../adapters/cache/ports/static-cache.port.ts | 40 +++++ .../invitions/accept-invitation.use-case.ts | 32 ++-- .../invitions/decline-invitation.use-case.ts | 21 ++- .../invitions/get-invitation.query.ts | 8 +- .../invitions/get-invitations.query.ts | 14 +- .../invitions/get-my-invites.use-case.ts | 16 +- .../invitions/send-invitation.use-case.ts | 24 ++- .../invitions/update-invitation.use-case.ts | 16 +- src/teams/teams.module.ts | 23 --- tsconfig.json | 1 + 22 files changed, 343 insertions(+), 146 deletions(-) create mode 100644 src/shared/adapters/cache/adapters/index.ts create mode 100644 src/shared/adapters/cache/adapters/redis-cache.adapter.ts create mode 100644 src/shared/adapters/cache/constants.ts create mode 100644 src/shared/adapters/cache/module.ts create mode 100644 src/shared/adapters/cache/ports/index.ts create mode 100644 src/shared/adapters/cache/ports/static-cache.port.ts diff --git a/src/app.module.ts b/src/app.module.ts index f16b69c..66beda3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { ProjectsModule } from './projects'; import { HttpModule } from '@nestjs/axios'; import { MediaModule } from '@shared/media'; import { version } from '../package.json'; +import { CacheModule } from '@shared/adapters/cache/module'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { version } from '../package.json'; }, }), }), + CacheModule, MediaModule, HttpModule.register({ global: true }), MailModule, diff --git a/src/auth/application/use-cases/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/confirm-reset-password.use-case.ts index fe044cb..5545b79 100644 --- a/src/auth/application/use-cases/confirm-reset-password.use-case.ts +++ b/src/auth/application/use-cases/confirm-reset-password.use-case.ts @@ -1,22 +1,22 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import * as argon from 'argon2'; -import Redis from 'ioredis'; import { BaseException } from '@shared/error'; import { PasswordResetConfirmDto } from '../dtos'; import { UpdatePasswordUseCase } from '@core/user'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class ConfirmResetPasswordUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, private readonly updatePasswordUserUseCase: UpdatePasswordUseCase, ) {} async execute(dto: PasswordResetConfirmDto) { const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); + const cachedData = await this.cacheService.getOne(redisKey); if (!cachedData) { throw new BaseException( @@ -55,7 +55,7 @@ export class ConfirmResetPasswordUseCase { ); } - await this.redis.del(redisKey); + await this.cacheService.removeOne(redisKey); return { success: true, diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts index 9c1e0c6..b265a05 100644 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ b/src/auth/application/use-cases/reset-password.use-case.ts @@ -1,20 +1,20 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { Queue } from 'bullmq'; -import Redis from 'ioredis'; import { generate, generateSecret } from 'otplib'; import { BaseException } from '@shared/error'; import { AuthMailJobs, AuthQueues } from '../../domain/enums'; import { ResetPasswordEvent } from '../../domain/events'; import { ResetPasswordDto } from '../dtos'; import { FindUserQuery } from '@core/user'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class ResetPasswordUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, private readonly findUserQuery: FindUserQuery, @@ -22,7 +22,7 @@ export class ResetPasswordUseCase { async execute(dto: ResetPasswordDto) { const redisKey = `pass:reset:${dto.email}`; - const isExistsAttempt = await this.redis.get(redisKey); + const isExistsAttempt = await this.cacheService.getOne(redisKey); if (isExistsAttempt) { throw new BaseException( @@ -68,7 +68,7 @@ export class ResetPasswordUseCase { isVerified: false, }; - await this.redis.set(redisKey, JSON.stringify(resetPayload), 'EX', 900); + await this.cacheService.setOne(redisKey, JSON.stringify(resetPayload), 900); const event = new ResetPasswordEvent(dto.email, token); await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { diff --git a/src/auth/application/use-cases/sign-up-verify.use-case.ts b/src/auth/application/use-cases/sign-up-verify.use-case.ts index c8fb836..2d1ad52 100644 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ b/src/auth/application/use-cases/sign-up-verify.use-case.ts @@ -1,6 +1,4 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; import { verify as verifyOTP } from 'otplib'; import { RegisterUserUseCase } from '@core/user'; import { BaseException } from '@shared/error'; @@ -9,12 +7,14 @@ import { TokenService } from '../../infrastructure/security'; import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; import { VerifyDto } from '../dtos'; import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class SignUpVerifyUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, @Inject('ISessionRepository') private readonly sessionRepo: ISessionRepository, private readonly tokenService: TokenService, @@ -23,7 +23,7 @@ export class SignUpVerifyUseCase { async execute(dto: VerifyDto, meta: DeviceMetadata) { const redisKey = `reg:${dto.email}`; - const cachedData = await this.redis.get(redisKey); + const cachedData = await this.cacheService.getOne(redisKey); if (!cachedData) { throw new BaseException( @@ -86,7 +86,7 @@ export class SignUpVerifyUseCase { expiresAt, }); - await this.redis.del(redisKey); + await this.cacheService.removeOne(redisKey); return { success: true, diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts index a893b5e..ccc4d03 100644 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ b/src/auth/application/use-cases/sign-up.use-case.ts @@ -1,21 +1,21 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import * as argon from 'argon2'; import { Queue } from 'bullmq'; -import Redis from 'ioredis'; import { generate, generateSecret } from 'otplib'; import { FindUserQuery } from '@core/user'; import { BaseException } from '@shared/error'; import { AuthQueues, AuthMailJobs } from '../../domain/enums'; import { RegisterCodeEvent } from '../../domain/events'; import { SignUpDto } from '../dtos'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class SignUpUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, @InjectQueue(AuthQueues.AUTH_MAIL) private readonly mailQueue: Queue, private readonly findUserQuery: FindUserQuery, @@ -23,7 +23,7 @@ export class SignUpUseCase { async execute(dto: SignUpDto) { const redisKey = `reg:${dto.email}`; - const cachedData = await this.redis.get(redisKey); + const cachedData = await this.cacheService.getOne(redisKey); if (cachedData) { throw new BaseException( @@ -66,7 +66,7 @@ export class SignUpUseCase { otp: { token, secret }, }; - await this.redis.set(`reg:${dto.email}`, JSON.stringify(data), 'EX', 900); + await this.cacheService.setOne(`reg:${dto.email}`, JSON.stringify(data), 900); const event = new RegisterCodeEvent(dto.email, dto.firstName, token); await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { diff --git a/src/auth/application/use-cases/verify-reset-password.use-case.ts b/src/auth/application/use-cases/verify-reset-password.use-case.ts index 842ae33..a17a7e6 100644 --- a/src/auth/application/use-cases/verify-reset-password.use-case.ts +++ b/src/auth/application/use-cases/verify-reset-password.use-case.ts @@ -1,20 +1,20 @@ -import { InjectRedis } from '@nestjs-modules/ioredis'; -import { HttpStatus, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { verify as verifyOTP } from 'otplib'; import { BaseException } from '@shared/error'; import { VerifyResetCodeDto } from '../dtos'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class VerifyResetPasswordUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, ) {} async execute(dto: VerifyResetCodeDto) { const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.redis.get(redisKey); + const cachedData = await this.cacheService.getOne(redisKey); if (!cachedData) { throw new BaseException( @@ -48,10 +48,9 @@ export class VerifyResetPasswordUseCase { ); } - await this.redis.set( + await this.cacheService.setOne( redisKey, JSON.stringify({ ...resetSession, isVerified: true }), - 'EX', 600, ); diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 3aec5b8..3bf25b6 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,6 +1,5 @@ import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; -import { RedisModule } from '@nestjs-modules/ioredis'; import { BullModule } from '@nestjs/bullmq'; import { Module, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -45,27 +44,6 @@ const REPOSITORY = { }, }), }), - RedisModule.forRootAsync({ - inject: [ConfigService], - useFactory: async (cfg: ConfigService) => { - const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); - const port = cfg.get('REDIS_PORT'); - const password = cfg.get('REDIS_PASSWORD'); - const url = `redis://${host}${port ? `:${port}` : ''}`; - - return { - type: 'single', - url, - options: { - password, - retryStrategy(times) { - return Math.min(times * 50, 2000); - }, - commandTimeout: 3000, - }, - }; - }, - }), BullModule.registerQueue({ name: AuthQueues.AUTH_MAIL, }), diff --git a/src/shared/adapters/cache/adapters/index.ts b/src/shared/adapters/cache/adapters/index.ts new file mode 100644 index 0000000..d75652f --- /dev/null +++ b/src/shared/adapters/cache/adapters/index.ts @@ -0,0 +1 @@ +export * from './redis-cache.adapter'; diff --git a/src/shared/adapters/cache/adapters/redis-cache.adapter.ts b/src/shared/adapters/cache/adapters/redis-cache.adapter.ts new file mode 100644 index 0000000..89f72cf --- /dev/null +++ b/src/shared/adapters/cache/adapters/redis-cache.adapter.ts @@ -0,0 +1,158 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis, { ChainableCommander } from 'ioredis'; +import { ICacheService, ICacheTransaction } from '../ports'; + +@Injectable() +export class RedisCacheAdapter implements ICacheService { + private readonly defaultTtl = 259200; // 3 days in seconds + + constructor(@InjectRedis() private readonly redis: Redis) {} + + async getOne(key: string) { + return this.redis.get(key); + } + + async getMany(keys: string[]): Promise<(string | null)[]> { + if (keys.length === 0) return []; + return this.redis.mget(keys); + } + + async getCollection(key: string): Promise { + return this.redis.smembers(key); + } + + async setOne(key: string, value: string, ttlSeconds: number = this.defaultTtl) { + await this.redis.set(key, value, 'EX', ttlSeconds); + } + + async setMany(items: { key: string; value: string }[], ttlSeconds: number = this.defaultTtl) { + if (!items.length) return; + + const pipeline = this.redis.pipeline(); + + for (const item of items) { + pipeline.set(item.key, item.value, 'EX', ttlSeconds); + } + + await pipeline.exec(); + } + + async addOneToCollection(key: string, value: string, ttlSeconds: number = this.defaultTtl) { + await this.redis.pipeline().sadd(key, value).expire(key, ttlSeconds).exec(); + } + + async addManyToCollection(key: string, values: string[], ttlSeconds: number = this.defaultTtl) { + if (!values.length) return; + + await this.redis + .pipeline() + .sadd(key, ...values) + .expire(key, ttlSeconds) + .exec(); + } + + async removeOne(key: string) { + await this.redis.del(key); + } + + async removeMany(keys: string[]) { + if (!keys.length) return; + await this.redis.del(keys); + } + + async removeOneFromCollection(key: string, value: string) { + await this.redis.srem(key, value); + } + + async removeManyFromCollection(key: string, values: string[]) { + if (!values.length) return; + await this.redis.srem(key, ...values); + } + + async getTtl(key: string): Promise { + const ttl = await this.redis.ttl(key); + return ttl > 0 ? ttl : 0; + } + + async getOneWithTtl(key: string) { + const [[, value], [, ttl]] = (await this.redis.pipeline().get(key).ttl(key).exec()) as [ + [Error | null, string | null], + [Error | null, number], + ]; + + return { + value, + ttlSeconds: ttl && ttl > 0 ? ttl : 0, + }; + } + + transaction(): ICacheTransaction { + return new RedisTransaction(this.redis.multi()); + } + + async isAlive() { + try { + return (await this.redis.ping()) === 'PONG'; + } catch { + return false; + } + } +} + +class RedisTransaction implements ICacheTransaction { + private readonly defaultTtl = 259200; // 3 days in seconds + + constructor(private readonly multi: ChainableCommander) {} + + setOne(key: string, value: string, ttlSeconds: number = this.defaultTtl): this { + this.multi.set(key, value, 'EX', ttlSeconds); + return this; + } + + setMany(items: { key: string; value: string }[], ttlSeconds: number = this.defaultTtl): this { + for (const item of items) { + this.multi.set(item.key, item.value, 'EX', ttlSeconds); + } + return this; + } + + addOneToCollection(key: string, value: string, ttlSeconds: number = this.defaultTtl): this { + this.multi.sadd(key, value); + this.multi.expire(key, ttlSeconds); + return this; + } + + addManyToCollection(key: string, values: string[], ttlSeconds: number = this.defaultTtl): this { + if (!values.length) return this; + this.multi.sadd(key, ...values); + this.multi.expire(key, ttlSeconds); + return this; + } + + removeOne(key: string): this { + this.multi.del(key); + return this; + } + + removeMany(keys: string[]): this { + if (!keys.length) return this; + this.multi.del(keys); + return this; + } + + removeOneFromCollection(collectionKey: string, value: string): this { + this.multi.srem(collectionKey, value); + return this; + } + + removeManyFromCollection(collectionKey: string, values: string[]): this { + if (!values.length) return this; + this.multi.srem(collectionKey, ...values); + return this; + } + + async execute(): Promise { + await this.multi.exec(); + } +} diff --git a/src/shared/adapters/cache/constants.ts b/src/shared/adapters/cache/constants.ts new file mode 100644 index 0000000..b6f0485 --- /dev/null +++ b/src/shared/adapters/cache/constants.ts @@ -0,0 +1 @@ +export const CACHE_SERVICE = 'ICacheService'; diff --git a/src/shared/adapters/cache/module.ts b/src/shared/adapters/cache/module.ts new file mode 100644 index 0000000..de8c581 --- /dev/null +++ b/src/shared/adapters/cache/module.ts @@ -0,0 +1,40 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigService } from '@nestjs/config'; +import { RedisCacheAdapter } from './adapters'; +import { CACHE_SERVICE } from './constants'; + +@Global() +@Module({ + imports: [ + RedisModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (cfg: ConfigService) => { + const host = cfg.getOrThrow('REDIS_HOST'); + const port = cfg.get('REDIS_PORT'); + const password = cfg.get('REDIS_PASSWORD'); + const url = `redis://${host}${port ? `:${port}` : ''}`; + + return { + type: 'single', + url, + options: { + password, + retryStrategy(times) { + return Math.min(times * 50, 2000); + }, + commandTimeout: 3000, + }, + }; + }, + }), + ], + providers: [ + { + provide: CACHE_SERVICE, + useClass: RedisCacheAdapter, + }, + ], + exports: [CACHE_SERVICE], +}) +export class CacheModule {} diff --git a/src/shared/adapters/cache/ports/index.ts b/src/shared/adapters/cache/ports/index.ts new file mode 100644 index 0000000..454366b --- /dev/null +++ b/src/shared/adapters/cache/ports/index.ts @@ -0,0 +1 @@ +export { ICacheService, ICacheTransaction } from './static-cache.port'; diff --git a/src/shared/adapters/cache/ports/static-cache.port.ts b/src/shared/adapters/cache/ports/static-cache.port.ts new file mode 100644 index 0000000..e982fbb --- /dev/null +++ b/src/shared/adapters/cache/ports/static-cache.port.ts @@ -0,0 +1,40 @@ +export interface ICacheService { + getOne(key: string): Promise; + getMany(keys: string[]): Promise<(string | null)[]>; + getCollection(collectionKey: string): Promise; + + setOne(key: string, value: string, ttlSeconds?: number): Promise; + setMany(items: { key: string; value: string }[], ttlSeconds?: number): Promise; + + addOneToCollection(key: string, value: string, ttlSeconds?: number): Promise; + addManyToCollection(key: string, values: string[], ttlSeconds?: number): Promise; + + removeOne(key: string): Promise; + removeMany(keys: string[]): Promise; + + removeOneFromCollection(key: string, value: string): Promise; + removeManyFromCollection(key: string, values: string[]): Promise; + + getTtl(key: string): Promise; + getOneWithTtl(key: string): Promise<{ value: string | null; ttlSeconds: number }>; + + transaction(): ICacheTransaction; + + isAlive(): Promise; +} + +export interface ICacheTransaction { + setOne(key: string, value: string, ttlSeconds?: number): this; + setMany(items: { key: string; value: string }[], ttlSeconds?: number): this; + + addOneToCollection(key: string, value: string, ttlSeconds?: number): this; + addManyToCollection(key: string, values: string[], ttlSeconds?: number): this; + + removeOne(key: string): this; + removeMany(keys: string[]): this; + + removeOneFromCollection(key: string, value: string): this; + removeManyFromCollection(key: string, values: string[]): this; + + execute(): Promise; +} diff --git a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts index 16864a2..b45a4b6 100644 --- a/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/accept-invitation.use-case.ts @@ -1,9 +1,9 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; -import Redis from 'ioredis'; import type { TeamInvite } from '../../dtos/invitation.dto'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; @Injectable() export class AcceptInvitationUseCase { @@ -13,11 +13,11 @@ export class AcceptInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} async execute(code: string, userId: string, email: string) { - const inviteRaw = await this.redis.get(this.INVITES_KEY(code)); + const inviteRaw = await this.cacheService.getOne(this.INVITES_KEY(code)); if (!inviteRaw) { throw new BaseException( { @@ -64,12 +64,12 @@ export class AcceptInvitationUseCase { joinedAt: new Date(), }); - await this.redis - .multi() - .del(this.INVITES_KEY(code)) - .srem(this.TEAM_INVITES_KEY(invite.teamId), code) - .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) - .exec(); + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(invite.teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email.toLowerCase()), code) + .execute(); return { success: true, message: 'Вы успешно присоединились к команде' }; } @@ -84,11 +84,11 @@ export class AcceptInvitationUseCase { } private async cleanupInvite(code: string, teamId: string, email: string) { - await this.redis - .multi() - .del(this.INVITES_KEY(code)) - .srem(this.TEAM_INVITES_KEY(teamId), code) - .srem(this.USER_INVITES_KEY(email.toLowerCase()), code) - .exec(); + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email.toLowerCase()), code) + .execute(); } } diff --git a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts index 5d4a4bc..e13c696 100644 --- a/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/decline-invitation.use-case.ts @@ -1,9 +1,9 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; -import Redis from 'ioredis'; import type { TeamInvite } from '../../dtos/invitation.dto'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class DeclineInvitationUseCase { @@ -13,7 +13,7 @@ export class DeclineInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} async execute(slug: string, code: string, userId: string, userEmail: string) { @@ -67,7 +67,7 @@ export class DeclineInvitationUseCase { } private async getInviteOrThrow(code: string) { - const rawInvite = await this.redis.get(this.INVITES_KEY(code)); + const rawInvite = await this.cacheService.getOne(this.INVITES_KEY(code)); if (!rawInvite) { throw new BaseException( { code: 'INVITE_NOT_FOUND', message: 'Приглашение не найдено' }, @@ -87,12 +87,11 @@ export class DeclineInvitationUseCase { } private async cleanupInvite(code: string, teamId: string, email: string) { - await this.redis - .multi() - .del(this.INVITES_KEY(code)) - .srem(this.TEAM_INVITES_KEY(teamId), code) - .srem(this.USER_INVITES_KEY(email), code) - .exec(); + await this.cacheService + .transaction() + .removeOne(this.INVITES_KEY(code)) + .removeOneFromCollection(this.TEAM_INVITES_KEY(teamId), code) + .removeOneFromCollection(this.USER_INVITES_KEY(email), code) + .execute(); } - ы; } diff --git a/src/teams/application/use-cases/invitions/get-invitation.query.ts b/src/teams/application/use-cases/invitions/get-invitation.query.ts index 90f1b22..2336765 100644 --- a/src/teams/application/use-cases/invitions/get-invitation.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitation.query.ts @@ -1,9 +1,9 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; -import Redis from 'ioredis'; import type { TeamInvite } from '../../dtos/invitation.dto'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class GetInvitationQuery { @@ -11,7 +11,7 @@ export class GetInvitationQuery { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} async execute(slug: string, code: string, userId: string, userEmail: string) { @@ -35,7 +35,7 @@ export class GetInvitationQuery { } private async getInviteOrThrow(code: string) { - const raw = await this.redis.get(this.INVITES_KEY(code)); + const raw = await this.cacheService.getOne(this.INVITES_KEY(code)); if (!raw) throw new BaseException( { code: 'INVITE_EXPIRED', message: 'Срок действия приглашения истек' }, diff --git a/src/teams/application/use-cases/invitions/get-invitations.query.ts b/src/teams/application/use-cases/invitions/get-invitations.query.ts index 11f7d6a..52d4f09 100644 --- a/src/teams/application/use-cases/invitions/get-invitations.query.ts +++ b/src/teams/application/use-cases/invitions/get-invitations.query.ts @@ -1,8 +1,8 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; -import Redis from 'ioredis'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class GetInvitationsQuery { @@ -11,7 +11,7 @@ export class GetInvitationsQuery { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, ) {} async execute(slug: string, userId: string) { @@ -19,10 +19,10 @@ export class GetInvitationsQuery { await this.ensureAdminPermissions(team.id, userId); const teamKey = this.TEAM_INVITES_KEY(team.id); - const codes = await this.redis.smembers(teamKey); + const codes = await this.cacheService.getCollection(teamKey); if (!codes.length) return []; - const results = await this.redis.mget(...codes.map(this.INVITES_KEY)); + const results = await this.cacheService.getMany(codes.map(this.INVITES_KEY)); const { active, expired } = results.reduce( (acc, raw, i) => { @@ -37,7 +37,9 @@ export class GetInvitationsQuery { ); if (expired.length > 0) { - this.redis.srem(teamKey, ...expired).catch((e) => console.error('Cleanup error:', e)); + this.cacheService + .removeManyFromCollection(teamKey, expired) + .catch((e) => console.error('Cleanup error:', e)); } return active; diff --git a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts index 679e5e1..9c1c55a 100644 --- a/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/teams/application/use-cases/invitions/get-my-invites.use-case.ts @@ -1,23 +1,23 @@ import { TeamMemberMapper } from '@core/teams/application/mappers'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import { Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import { Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class GetMyInvitesUseCase { constructor( - @InjectRedis() - private readonly redis: Redis, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, ) {} async execute(email: string) { const userKey = `user:invites:${email.toLowerCase()}`; - const codes = await this.redis.smembers(userKey); + const codes = await this.cacheService.getCollection(userKey); if (!codes.length) return []; const inviteKeys = codes.map((c) => `inv:code:${c}`); - const results = await this.redis.mget(inviteKeys); + const results = await this.cacheService.getMany(inviteKeys); const { activeInvites, expiredCodes } = results.reduce( (acc, raw, i) => { @@ -32,7 +32,7 @@ export class GetMyInvitesUseCase { ); if (expiredCodes.length > 0) { - this.redis.srem(userKey, ...expiredCodes).catch((err) => { + this.cacheService.removeManyFromCollection(userKey, expiredCodes).catch((err) => { console.error('Failed to cleanup expired invites:', err); }); } diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index 63787fc..f970066 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -1,11 +1,9 @@ import { TeamMailJobs, TeamQueues } from '@core/teams/domain/enums'; import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; -import Redis from 'ioredis'; import { InviteMemberDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { generateSecret } from 'otplib'; @@ -13,6 +11,8 @@ import type { TeamInvite } from '../../dtos/invitation.dto'; import { TeamInvitationEvent } from '@core/teams/domain/events'; import { TeamMemberPolicy } from '@core/teams/domain/policy'; import type { TeamRole } from '@shared/entities'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class SendInvitationUseCase { @@ -23,7 +23,7 @@ export class SendInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, @InjectQueue(TeamQueues.TEAM_MAIL) private readonly mailQueue: Queue, private readonly cfg: ConfigService, private readonly policy: TeamMemberPolicy, @@ -86,10 +86,10 @@ export class SendInvitationUseCase { } private async ensureNoPendingInvite(teamId: string, email: string) { - const activeCodes = await this.redis.smembers(this.USER_INVITES_KEY(email)); + const activeCodes = await this.cacheService.getCollection(this.USER_INVITES_KEY(email)); if (activeCodes.length === 0) return; - const invitesData = await this.redis.mget(activeCodes.map(this.INVITES_KEY)); + const invitesData = await this.cacheService.getMany(activeCodes.map(this.INVITES_KEY)); const hasDuplicate = invitesData .filter((d): d is string => !!d) .map((d) => JSON.parse(d) as TeamInvite) @@ -119,14 +119,12 @@ export class SendInvitationUseCase { } private async saveInviteToCache(code: string, data: TeamInvite) { - await this.redis - .multi() - .set(this.INVITES_KEY(code), JSON.stringify(data), 'EX', this.INVITE_TTL) - .sadd(this.TEAM_INVITES_KEY(data.teamId), code) - .expire(this.TEAM_INVITES_KEY(data.teamId), this.INVITE_TTL) - .sadd(this.USER_INVITES_KEY(data.email), code) - .expire(this.USER_INVITES_KEY(data.email), this.INVITE_TTL) - .exec(); + await this.cacheService + .transaction() + .setOne(this.INVITES_KEY(code), JSON.stringify(data), this.INVITE_TTL) + .addOneToCollection(this.TEAM_INVITES_KEY(data.teamId), code) + .addOneToCollection(this.USER_INVITES_KEY(data.email), code) + .execute(); } private async sendEmailNotification(code: string, teamName: string, email: string) { diff --git a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts index 3849e91..84fde3c 100644 --- a/src/teams/application/use-cases/invitions/update-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/update-invitation.use-case.ts @@ -1,12 +1,12 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; -import { InjectRedis } from '@nestjs-modules/ioredis'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; import { UpdateInvitationDto } from '../../dtos'; import { BaseException } from '@shared/error'; import { TeamInvite } from '../../dtos/invitation.dto'; import { TeamMemberPolicy } from '@core/teams/domain/policy'; import { TeamRole } from '@shared/entities'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; @Injectable() export class UpdateInvitationUseCase { @@ -14,7 +14,7 @@ export class UpdateInvitationUseCase { constructor( @Inject('ITeamsRepository') private readonly teamsRepo: ITeamsRepository, - @InjectRedis() private readonly redis: Redis, + @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, private readonly policy: TeamMemberPolicy, ) {} @@ -23,13 +23,13 @@ export class UpdateInvitationUseCase { const member = await this.getMemberOrThrow(team.id, userId); const key = this.INVITES_KEY(code); - const { invite, ttl } = await this.getInviteContextOrThrow(key); + const { invite, ttlSeconds } = await this.getInviteContextOrThrow(key); this.validateInviteOwnership(invite, team.id); this.validatePolicy(member.role as TeamRole, invite.role as TeamRole, dto.role as TeamRole); invite.role = dto.role as TeamRole; - await this.redis.set(key, JSON.stringify(invite), 'EX', ttl); + await this.cacheService.setOne(key, JSON.stringify(invite), ttlSeconds); return { success: true, @@ -60,9 +60,9 @@ export class UpdateInvitationUseCase { } private async getInviteContextOrThrow(key: string) { - const [rawInvite, ttl] = await Promise.all([this.redis.get(key), this.redis.ttl(key)]); + const { value, ttlSeconds } = await this.cacheService.getOneWithTtl(key); - if (!rawInvite || ttl <= 0) { + if (!value || ttlSeconds <= 0) { throw new BaseException( { code: 'INVITE_NOT_FOUND_OR_EXPIRED', @@ -72,7 +72,7 @@ export class UpdateInvitationUseCase { ); } - return { invite: JSON.parse(rawInvite) as TeamInvite, ttl }; + return { invite: JSON.parse(value) as TeamInvite, ttlSeconds }; } private validateInviteOwnership(invite: TeamInvite, teamId: string) { diff --git a/src/teams/teams.module.ts b/src/teams/teams.module.ts index 2c92456..094595d 100644 --- a/src/teams/teams.module.ts +++ b/src/teams/teams.module.ts @@ -6,8 +6,6 @@ import { TeamsController, MeController, } from './application/controller'; -import { RedisModule } from '@nestjs-modules/ioredis'; -import { ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; import { BullBoardModule } from '@bull-board/nestjs'; import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; @@ -23,27 +21,6 @@ const REPOSITORY = { provide: 'ITeamsRepository', useClass: TeamsRepository }; @Module({ imports: [ - RedisModule.forRootAsync({ - inject: [ConfigService], - useFactory: async (cfg: ConfigService) => { - const host = cfg.getOrThrow('REDIS_HOST', { infer: true }); - const port = cfg.get('REDIS_PORT'); - const password = cfg.get('REDIS_PASSWORD'); - const url = `redis://${host}${port ? `:${port}` : ''}`; - - return { - type: 'single', - url, - options: { - password, - retryStrategy(times) { - return Math.min(times * 50, 2000); - }, - commandTimeout: 3000, - }, - }; - }, - }), BullModule.registerQueue({ name: TeamQueues.TEAM_MAIL, }), diff --git a/tsconfig.json b/tsconfig.json index c260ad5..4884f1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2021", + "resolveJsonModule": true, "esModuleInterop": true, "sourceMap": true, "outDir": "./dist", From 2db1ba8e86885497a06896f2bf3724de67486b0b Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 15 May 2026 18:22:54 +0300 Subject: [PATCH 3/6] refactor(health): enhance health check service with database and cache indicators --- .../src/database-healthcheck.service.ts | 20 ++++++++++++++++ libs/database/src/database.module.ts | 4 +++- .../src/controller/health.controller.ts | 15 ++++-------- .../src/controller/health.controlller.spec.ts | 6 ++--- libs/health/src/health.service.ts | 23 ++++++++----------- src/app.module.ts | 15 +++++++----- src/shared/media/media.module.ts | 5 ++-- 7 files changed, 52 insertions(+), 36 deletions(-) create mode 100644 libs/database/src/database-healthcheck.service.ts diff --git a/libs/database/src/database-healthcheck.service.ts b/libs/database/src/database-healthcheck.service.ts new file mode 100644 index 0000000..875d2ed --- /dev/null +++ b/libs/database/src/database-healthcheck.service.ts @@ -0,0 +1,20 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SQL_CLIENT } from '@libs/database/constants'; +import { Sql } from 'postgres'; + +@Injectable() +export class DatabaseHealthcheckService { + constructor( + @Inject(SQL_CLIENT) + private readonly sql: Sql, + ) {} + + async isAlive() { + try { + await this.sql`SELECT 1`; + return true; + } catch { + return false; + } + } +} diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index b5fcf2f..a21f90a 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -9,10 +9,12 @@ import { MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, } from './database.module-definition'; +import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck.service'; @Module({ providers: [ MigrationService, + DatabaseHealthcheckService, { provide: SQL_CLIENT, inject: [ConfigService, MODULE_OPTIONS_TOKEN], @@ -61,7 +63,7 @@ import { }, }, ], - exports: [DATABASE_SERVICE], + exports: [DATABASE_SERVICE, DatabaseHealthcheckService], }) export class DatabaseModule extends ConfigurableModuleClass implements OnApplicationShutdown { private readonly logger = new Logger(DatabaseModule.name); diff --git a/libs/health/src/controller/health.controller.ts b/libs/health/src/controller/health.controller.ts index d602a5a..0530359 100644 --- a/libs/health/src/controller/health.controller.ts +++ b/libs/health/src/controller/health.controller.ts @@ -1,36 +1,29 @@ -import { Controller, Get, HttpStatus, Inject } from '@nestjs/common'; +import { Controller, Get, HttpStatus } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; import { HealthService } from '../health.service'; import { GetHealthSwagger, GetPingSwagger } from './health.swagger'; import { ApiTags } from '@nestjs/swagger'; import { BaseException } from '@shared/error'; -import { MODULE_OPTIONS_TOKEN } from '../health.module-definition'; -import { HealthModuleOptions } from '../interfaces'; @SkipThrottle() @Controller() @ApiTags('System') export class HealthController { - constructor( - @Inject(MODULE_OPTIONS_TOKEN) - private readonly options: HealthModuleOptions, - private readonly service: HealthService, - ) {} + constructor(private readonly service: HealthService) {} @Get('health') @GetHealthSwagger() async checkHealth() { - const { serviceName } = this.options; const pingData = await this.service.getHealthData(); if (!pingData.status) { throw new BaseException( { code: 'SERVICE_UNHEALTHY', - message: `Сервис ${serviceName} временно недоступен или работает некорректно`, + message: `Сервис ${pingData.service} временно недоступен или работает некорректно`, details: [ { - target: serviceName, + target: pingData.service, status: pingData.status, timestamp: new Date().toISOString(), }, diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index 44f51b4..5865763 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -7,20 +7,20 @@ describe('HealthController', () => { let healthServiceMock: { getHealthData: ReturnType }; const SERVICE_NAME = 'MyService'; - const mockOptions = { serviceName: SERVICE_NAME }; beforeEach(() => { healthServiceMock = { getHealthData: vi.fn(), }; - controller = new HealthController(mockOptions as any, healthServiceMock as any); + controller = new HealthController(healthServiceMock as any); vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); it('should throw SERVICE_UNAVAILABLE when service status is false (down)', async () => { healthServiceMock.getHealthData.mockResolvedValue({ + service: SERVICE_NAME, status: false, components: { database: 'down' }, }); @@ -41,7 +41,7 @@ describe('HealthController', () => { }); it('should return "healthy" when status is true', async () => { - healthServiceMock.getHealthData.mockResolvedValue({ status: true }); + healthServiceMock.getHealthData.mockResolvedValue({ service: SERVICE_NAME, status: true }); const result = await controller.checkHealth(); diff --git a/libs/health/src/health.service.ts b/libs/health/src/health.service.ts index 2a60842..c5de0a3 100644 --- a/libs/health/src/health.service.ts +++ b/libs/health/src/health.service.ts @@ -21,29 +21,26 @@ export class HealthService { const results = await Promise.all( Object.entries(indicators).map(async ([name, check]) => { + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Timeout')), 5000); + }); + try { - const result = await Promise.race([ - check(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Timeout')), 5000), - ), - ]); + const result = await Promise.race([check(), timeoutPromise]); return { name, ok: !!result }; } catch (e) { const message = e instanceof Error ? e.message : String(e); return { name, ok: false, error: message }; + } finally { + clearTimeout(timeoutId); } }), ); const isAllOk = results.every((r) => r.ok); - const components = results.reduce( - (acc, curr) => ({ - ...acc, - [curr.name]: curr.ok ? 'up' : 'down', - }), - {}, - ); + const components = Object.fromEntries(results.map((r) => [r.name, r.ok ? 'up' : 'down'])); return { service: serviceName, diff --git a/src/app.module.ts b/src/app.module.ts index 66beda3..a787bb1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -20,6 +20,10 @@ import { HttpModule } from '@nestjs/axios'; import { MediaModule } from '@shared/media'; import { version } from '../package.json'; import { CacheModule } from '@shared/adapters/cache/module'; +import { S3Service } from '@libs/s3'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck.service'; @Module({ imports: [ @@ -73,16 +77,15 @@ import { CacheModule } from '@shared/adapters/cache/module'; adapter: FastifyAdapter, }), HealthModule.registerAsync({ - inject: [], - useFactory: () => { + inject: [DatabaseHealthcheckService, S3Service, CACHE_SERVICE], + useFactory: (db: DatabaseHealthcheckService, s3: S3Service, cache: ICacheService) => { return { serviceName: 'gateway', version, indicators: { - database: async () => true, - redis: async () => true, - storage: async () => true, - http: async () => true, + database: async () => db.isAlive(), + cache: async () => cache.isAlive(), + storage: async () => s3.isAlive(), }, }; }, diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index d1d9747..395ff1d 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -14,6 +14,7 @@ import { MediaProcessor } from './workers/media.worker'; imports: [ S3Module.registerAsync({ inject: [ConfigService], + global: true, useFactory: (cfg: ConfigService) => ({ bucket: cfg.getOrThrow('S3_BUCKET_NAME'), connection: { @@ -28,7 +29,7 @@ import { MediaProcessor } from './workers/media.worker'; connectTimeout: 2000, requestTimeout: 5000, maxAttempts: 3, - } + }, }), }), ImagorModule.forRootAsync({ @@ -58,4 +59,4 @@ import { MediaProcessor } from './workers/media.worker'; controllers: [MediaController], providers: [MediaProcessor, MediaService], }) -export class MediaModule { } +export class MediaModule {} From 6c341ea0ad769393bff3bcf4961bf505d735e1d1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 15 May 2026 18:28:03 +0300 Subject: [PATCH 4/6] test(health): add unit tests for health service indicators and status checks --- libs/health/src/health.service.spec.ts | 92 ++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 libs/health/src/health.service.spec.ts diff --git a/libs/health/src/health.service.spec.ts b/libs/health/src/health.service.spec.ts new file mode 100644 index 0000000..802b89f --- /dev/null +++ b/libs/health/src/health.service.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + return { + ...actual, + loadavg: () => [1.23, 0.5, 0.1], + }; +}); +import { HealthService } from './health.service'; +import type { HealthModuleOptions } from './interfaces'; + +describe('HealthService', () => { + const BASE_TIME = new Date('2026-05-15T10:00:00.000Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(BASE_TIME); + vi.spyOn(process, 'uptime').mockReturnValue(3661); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('returns healthy payload when all indicators are ok', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + version: 'v2.0.0', + indicators: { + database: () => true, + redis: async () => true, + }, + }; + + const service = new HealthService(options); + const data = await service.getHealthData(); + + expect(data).toMatchObject({ + service: 'MyService', + status: true, + components: { database: 'up', redis: 'up' }, + info: { version: 'v2.0.0', node: process.version }, + time: { + now: BASE_TIME.toISOString(), + startedAt: BASE_TIME.toISOString(), + uptime: '1h 1m 1s', + uptimeSeconds: 3661, + }, + loaded: '1.23', + }); + }); + + it('marks status as false when any indicator fails or throws', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + indicators: { + cache: () => true, + database: () => false, + storage: () => { + throw new Error('boom'); + }, + }, + }; + + const service = new HealthService(options); + const data = await service.getHealthData(); + + expect(data.status).toBe(false); + expect(data.components).toEqual({ database: 'down', storage: 'down', cache: 'up' }); + }); + + it('marks indicator as down on timeout', async () => { + const options: HealthModuleOptions = { + serviceName: 'MyService', + indicators: { + http: () => new Promise(() => undefined), + }, + }; + + const service = new HealthService(options); + const resultPromise = service.getHealthData(); + + await vi.advanceTimersByTimeAsync(5000); + + const data = await resultPromise; + + expect(data.status).toBe(false); + expect(data.components).toEqual({ http: 'down' }); + }); +}); From 628318bb170af7e70e6e8064b6903420895326da Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 15 May 2026 21:42:01 +0300 Subject: [PATCH 5/6] feat: option forcePathStyle --- src/shared/media/media.module.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index 395ff1d..79eea26 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -26,6 +26,7 @@ import { MediaProcessor } from './workers/media.worker'; }, }, config: { + forcePathStyle: true, connectTimeout: 2000, requestTimeout: 5000, maxAttempts: 3, From 71c14f44a3c9035c178fd13e0cd8941374ada803 Mon Sep 17 00:00:00 2001 From: soorq Date: Fri, 15 May 2026 21:58:26 +0300 Subject: [PATCH 6/6] refactor: resolve error with version --- ...healthcheck.service.ts => database-health.service.ts} | 2 +- libs/database/src/database.module.ts | 6 +++--- libs/database/src/index.ts | 1 + src/app.module.ts | 9 +++++---- 4 files changed, 10 insertions(+), 8 deletions(-) rename libs/database/src/{database-healthcheck.service.ts => database-health.service.ts} (90%) diff --git a/libs/database/src/database-healthcheck.service.ts b/libs/database/src/database-health.service.ts similarity index 90% rename from libs/database/src/database-healthcheck.service.ts rename to libs/database/src/database-health.service.ts index 875d2ed..c869806 100644 --- a/libs/database/src/database-healthcheck.service.ts +++ b/libs/database/src/database-health.service.ts @@ -3,7 +3,7 @@ import { SQL_CLIENT } from '@libs/database/constants'; import { Sql } from 'postgres'; @Injectable() -export class DatabaseHealthcheckService { +export class DatabaseHealthService { constructor( @Inject(SQL_CLIENT) private readonly sql: Sql, diff --git a/libs/database/src/database.module.ts b/libs/database/src/database.module.ts index a21f90a..1b82c43 100644 --- a/libs/database/src/database.module.ts +++ b/libs/database/src/database.module.ts @@ -9,12 +9,12 @@ import { MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, } from './database.module-definition'; -import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck.service'; +import { DatabaseHealthService } from '@libs/database/database-health.service'; @Module({ providers: [ MigrationService, - DatabaseHealthcheckService, + DatabaseHealthService, { provide: SQL_CLIENT, inject: [ConfigService, MODULE_OPTIONS_TOKEN], @@ -63,7 +63,7 @@ import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck. }, }, ], - exports: [DATABASE_SERVICE, DatabaseHealthcheckService], + exports: [DATABASE_SERVICE, DatabaseHealthService], }) export class DatabaseModule extends ConfigurableModuleClass implements OnApplicationShutdown { private readonly logger = new Logger(DatabaseModule.name); diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts index e258d47..da99ae2 100644 --- a/libs/database/src/index.ts +++ b/libs/database/src/index.ts @@ -1,3 +1,4 @@ export * from './database.module'; export { DATABASE_SERVICE } from './constants'; export type { DatabaseService } from './interfaces'; +export { DatabaseHealthService } from './database-health.service'; diff --git a/src/app.module.ts b/src/app.module.ts index a787bb1..e0324fc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,12 +18,11 @@ import { TeamsModule } from './teams'; import { ProjectsModule } from './projects'; import { HttpModule } from '@nestjs/axios'; import { MediaModule } from '@shared/media'; -import { version } from '../package.json'; import { CacheModule } from '@shared/adapters/cache/module'; import { S3Service } from '@libs/s3'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; -import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck.service'; +import { DatabaseHealthService } from '@libs/database'; @Module({ imports: [ @@ -77,8 +76,10 @@ import { DatabaseHealthcheckService } from '@libs/database/database-healthcheck. adapter: FastifyAdapter, }), HealthModule.registerAsync({ - inject: [DatabaseHealthcheckService, S3Service, CACHE_SERVICE], - useFactory: (db: DatabaseHealthcheckService, s3: S3Service, cache: ICacheService) => { + inject: [DatabaseHealthService, S3Service, CACHE_SERVICE], + useFactory: (db: DatabaseHealthService, s3: S3Service, cache: ICacheService) => { + const version = process.env.npm_package_version; + return { serviceName: 'gateway', version,