diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7b94cf6..26297f5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -3,7 +3,17 @@ name: 'Pull Request Labeler' on: # Важно: target позволяет работать в PR из форков pull_request_target: - types: [opened, synchronize] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: 'PR Number' + required: true + type: number + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number }} + cancel-in-progress: true jobs: label: @@ -12,6 +22,12 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: + - name: Checkout head branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - name: Ensure Labels Exist env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/libs/bootstrap/src/setups/logger.ts b/libs/bootstrap/src/setups/logger.ts index f13211a..f689330 100644 --- a/libs/bootstrap/src/setups/logger.ts +++ b/libs/bootstrap/src/setups/logger.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { WinstonModule, utilities } from 'nest-winston'; -import { Observable, throwError } from 'rxjs'; +import { throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { format, transports } from 'winston'; @@ -39,7 +39,7 @@ export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger('HTTP'); private readonly sensitiveFields = ['password', 'token', 'access', 'refresh', 'code', 'secret']; - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler) { const request = context.switchToHttp().getRequest(); const startTime = Date.now(); diff --git a/libs/database/src/interfaces/module.interface.ts b/libs/database/src/interfaces/module.interface.ts index fd9499a..7c89571 100644 --- a/libs/database/src/interfaces/module.interface.ts +++ b/libs/database/src/interfaces/module.interface.ts @@ -1,5 +1,5 @@ import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; -import type { Options } from 'postgres'; +import type { Options, PostgresType } from 'postgres'; export interface DatabaseModuleOptions { /** @@ -24,7 +24,9 @@ export interface DatabaseModuleOptions { * @see https://github.com/porsager/postgres#options * @example { max: 20, idle_timeout: 30, connect_timeout: 5 } */ - readonly pool?: Options; + readonly pool?: Options<{ + [key: string]: PostgresType; + }>; /** * Включение или выключение логирования SQL-запросов в консоль через NestJS Logger. @@ -47,6 +49,8 @@ export interface DatabaseModuleOptions { readonly migrationsPath?: string; } +export type DatabaseSchema = Record; + /** * Тип для внедрения Drizzle ORM в репозитории. * Использует драйвер postgres-js под капотом. @@ -59,4 +63,5 @@ export interface DatabaseModuleOptions { * * @template TSchema - Тип вашей схемы данных (например, `typeof schema`). */ -export type DatabaseService> = PostgresJsDatabase; +export type DatabaseService = + PostgresJsDatabase; diff --git a/libs/database/src/migration.service.ts b/libs/database/src/migration.service.ts index ca261ad..31fe50e 100644 --- a/libs/database/src/migration.service.ts +++ b/libs/database/src/migration.service.ts @@ -14,7 +14,7 @@ export class MigrationService implements OnModuleInit { constructor( @Inject(DATABASE_SERVICE) - private readonly db: DatabaseService, + private readonly db: DatabaseService, @Inject(MODULE_OPTIONS_TOKEN) private readonly options: typeof OPTIONS_TYPE, ) {} diff --git a/libs/health/src/controller/health.controlller.spec.ts b/libs/health/src/controller/health.controlller.spec.ts index a0618d8..86ce004 100644 --- a/libs/health/src/controller/health.controlller.spec.ts +++ b/libs/health/src/controller/health.controlller.spec.ts @@ -5,7 +5,7 @@ import { HealthController } from './health.controller'; describe('HealthController', () => { let controller: HealthController; - let healthServiceMock: { readonly getHealthData: ReturnType }; + let healthServiceMock: { getHealthData: ReturnType }; const SERVICE_NAME = 'MyService'; diff --git a/libs/imagor/src/utils/imagor-path-builder.ts b/libs/imagor/src/utils/imagor-path-builder.ts index ce0df82..bafc7bc 100644 --- a/libs/imagor/src/utils/imagor-path-builder.ts +++ b/libs/imagor/src/utils/imagor-path-builder.ts @@ -62,93 +62,102 @@ export class ImagorPathBuilder { return parts.join('/'); } - // TODO will fix that shit - // eslint-disable-next-line sonarjs/cognitive-complexity private serializeAllFilters(f: Filters): string { - const s: string[] = []; + const segments: string[] = []; + this.addBasicFilters(f, segments); + this.addAdjustmentFilters(f, segments); + this.addEffectFilters(f, segments); + this.addTransformationFilters(f, segments); + this.addWatermarkFilter(f, segments); + + return segments.length ? `filters:${segments.join(':')}` : ''; + } + + private addBasicFilters(f: Filters, segments: string[]): void { if (f.quality) { - s.push(`quality(${f.quality})`); + segments.push(`quality(${f.quality})`); } if (f.format) { - s.push(`format(${f.format})`); + segments.push(`format(${f.format})`); } if (f.autojpg) { - s.push('autojpg()'); + segments.push('autojpg()'); } if (f.strip_exif) { - s.push('strip_exif()'); + segments.push('strip_exif()'); } if (f.strip_icc) { - s.push('strip_icc()'); + segments.push('strip_icc()'); } + if (f.no_upscale) { + segments.push('no_upscale()'); + } + if (f.max_bytes) { + segments.push(`max_bytes(${f.max_bytes})`); + } + } + private addAdjustmentFilters(f: Filters, segments: string[]): void { if (f.brightness !== undefined) { - s.push(`brightness(${f.brightness})`); + segments.push(`brightness(${f.brightness})`); } if (f.contrast !== undefined) { - s.push(`contrast(${f.contrast})`); + segments.push(`contrast(${f.contrast})`); } if (f.grayscale) { - s.push('grayscale()'); + segments.push('grayscale()'); } if (f.proportion !== undefined) { - s.push(`proportion(${f.proportion})`); + segments.push(`proportion(${f.proportion})`); } if (f.rgb) { - s.push(`rgb(${f.rgb.r},${f.rgb.g},${f.rgb.b})`); + segments.push(`rgb(${f.rgb.r},${f.rgb.g},${f.rgb.b})`); } + } + private addEffectFilters(f: Filters, segments: string[]): void { if (f.blur) { - const b = f.blur; - s.push(typeof b === 'number' ? `blur(${b})` : `blur(${b.radius},${b.sigma || 0})`); + segments.push( + typeof f.blur === 'number' + ? `blur(${f.blur})` + : `blur(${f.blur.radius},${f.blur.sigma ?? 0})`, + ); } if (f.sharpen) { - s.push(`sharpen(${f.sharpen.amount},${f.sharpen.radius},${f.sharpen.threshold})`); - } - if (f.noise) { - s.push(`noise(${f.noise})`); + const { amount, radius, threshold } = f.sharpen; + segments.push(`sharpen(${amount},${radius},${threshold})`); } - if (f.rotate) { - s.push(`rotate(${f.rotate})`); + if (f.noise !== undefined) { + segments.push(`noise(${f.noise})`); } + } + private addTransformationFilters(f: Filters, segments: string[]): void { + if (f.rotate !== undefined) { + segments.push(`rotate(${f.rotate})`); + } if (f.fill) { - s.push(`fill(${f.fill})`); + segments.push(`fill(${f.fill})`); } if (f.background_color) { - s.push(`background_color(${f.background_color})`); + segments.push(`background_color(${f.background_color})`); } - - if (f.watermark) { - const w = f.watermark; - const params = [ - w.image, - w.x ?? 0, - w.y ?? 0, - w.alpha ?? 0, - w.w_ratio ?? 0, - w.h_ratio ?? 0, - ]; - s.push(`watermark(${params.join(',')})`); - } - if (f.focal) { - s.push(`focal(${f.focal.x}x${f.focal.y})`); + segments.push(`focal(${f.focal.x}x${f.focal.y})`); } if (f.round_corner) { - s.push( - `round_corner(${f.round_corner.radius}${f.round_corner.color ? `,${f.round_corner.color}` : ''})`, - ); + const { radius, color } = f.round_corner; + segments.push(`round_corner(${radius}${color ? `,${color}` : ''})`); } + } - if (f.max_bytes) { - s.push(`max_bytes(${f.max_bytes})`); - } - if (f.no_upscale) { - s.push('no_upscale()'); + private addWatermarkFilter(f: Filters, segments: string[]): void { + if (!f.watermark) { + return; } - return s.length ? `filters:${s.join(':')}` : ''; + const { image, x = 0, y = 0, alpha = 0, w_ratio = 0, h_ratio = 0 } = f.watermark; + segments.push(`watermark(${image},${x},${y},${alpha},${w_ratio},${h_ratio})`); } } diff --git a/src/app.module.ts b/src/app.module.ts index 43c307e..3288e74 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,4 @@ +import { MediaModule } from '@core/media'; import { ConfigModule } from '@libs/config'; import { DatabaseModule, DatabaseHealthService } from '@libs/database'; import { HealthModule } from '@libs/health'; @@ -14,12 +15,11 @@ import { ICacheService } from '@shared/adapters/cache/ports'; import { MailModule } from '@shared/adapters/mail'; import { GlobalExceptionFilter } from '@shared/error'; import { ZodValidationInterceptor } from '@shared/interceptors'; -import { MediaModule } from '@shared/media'; import { ZodValidationPipe } from 'nestjs-zod'; import { AreaModule } from './area'; import { AuthModule } from './auth/auth.module'; -import { ProjectsModule } from './projects'; +import { ProjectModule } from './project'; import * as schema from './shared/entities'; import { TeamsModule } from './teams'; import { UserModule } from './user'; @@ -34,7 +34,6 @@ import { UserModule } from './user'; schema, schemaName: cfg.getOrThrow('DB_SCHEMA'), logging: true, - // runMigrations: false, }), }), BullModule.forRootAsync({ @@ -54,7 +53,7 @@ import { UserModule } from './user'; AuthModule, UserModule, TeamsModule, - ProjectsModule, + ProjectModule, AreaModule, MetricsModule, HealthModule.registerAsync({ diff --git a/src/area/application/area.facade.ts b/src/area/application/area.facade.ts index b33a246..2886ed6 100644 --- a/src/area/application/area.facade.ts +++ b/src/area/application/area.facade.ts @@ -6,6 +6,7 @@ import { ReordersStatesDto, CreateAreaDto, UpdateAreaDto, + QueryParamsDto, } from './dtos'; import { CreateAreaUseCase, @@ -78,7 +79,7 @@ export class AreaFacade { return this.getStateDetailQ.execute(slug, stateId, userId); } - public async getStates(slug: string, query: unknown, userId: string) { + public async getStates(slug: string, query: QueryParamsDto, userId: string) { return this.getStatesQ.execute(slug, userId, query); } diff --git a/src/area/application/controllers/area/swagger.ts b/src/area/application/controllers/area/swagger.ts index 8e3fcc2..0cf2579 100644 --- a/src/area/application/controllers/area/swagger.ts +++ b/src/area/application/controllers/area/swagger.ts @@ -1,6 +1,5 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiUnauthorized, ApiNotFound, @@ -9,6 +8,7 @@ import { ApiConflict, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { CreateAreaDto, UpdateAreaDto, AreaResponse, AreasResponse } from '../../dtos'; diff --git a/src/area/application/controllers/state/controller.ts b/src/area/application/controllers/state/controller.ts index 40d1865..c0fa034 100644 --- a/src/area/application/controllers/state/controller.ts +++ b/src/area/application/controllers/state/controller.ts @@ -2,7 +2,7 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; import { AreaFacade } from '../../area.facade'; -import { CreateStateDto, ReordersStatesDto, UpdateStateDto } from '../../dtos'; +import { CreateStateDto, QueryParamsDto, ReordersStatesDto, UpdateStateDto } from '../../dtos'; import { CreateStateSwagger, @@ -24,31 +24,8 @@ export class StateController { async getAll( @Param('slug') slug: string, @GetUserId() userId: string, - // TODO: ADD SCHEMA, AT DTO, AND VALIDATE WITH CONTRACT - @Query('hidden') hidden?: boolean, - @Query('counts') counts?: boolean, - @Query('my') my?: boolean, - @Query('category') category?: string, - @Query('overdue') overdue?: boolean, - @Query('orderBy') orderBy?: 'order' | 'title' | 'tasksCount' | 'createdAt', - @Query('order') order?: 'asc' | 'desc', - @Query('page') page?: number, - @Query('offset') offset?: number, - @Query('limit') limit?: number, + @Query() query: QueryParamsDto, ) { - const query = { - hidden, - counts, - my, - category, - overdue, - order, - limit, - offset, - page, - orderBy, - }; - return this.facade.getStates(slug, query, userId); } diff --git a/src/area/application/controllers/state/swagger.ts b/src/area/application/controllers/state/swagger.ts index c3872ac..c4ed279 100644 --- a/src/area/application/controllers/state/swagger.ts +++ b/src/area/application/controllers/state/swagger.ts @@ -1,7 +1,6 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiBody } from '@nestjs/swagger'; import { ApiListQuery } from '@shared/decorators'; -import { ActionResponse } from '@shared/dtos'; import { ApiUnauthorized, ApiNotFound, @@ -10,6 +9,7 @@ import { ApiConflict, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { CreateStateDto, diff --git a/src/area/application/dtos/index.ts b/src/area/application/dtos/index.ts index e929082..adb3890 100644 --- a/src/area/application/dtos/index.ts +++ b/src/area/application/dtos/index.ts @@ -1,2 +1,2 @@ -export * from './states.dto'; +export * from './state.dto'; export * from './area.dto'; diff --git a/src/area/application/dtos/states.dto.ts b/src/area/application/dtos/state.dto.ts similarity index 85% rename from src/area/application/dtos/states.dto.ts rename to src/area/application/dtos/state.dto.ts index c6c937a..58d7f4a 100644 --- a/src/area/application/dtos/states.dto.ts +++ b/src/area/application/dtos/state.dto.ts @@ -1,5 +1,5 @@ import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; -import { ActionResponseSchema } from '@shared/dtos'; +import { createSortingSchema, PaginationBaseSchema, ActionResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -137,3 +137,25 @@ export class CreateStateDto extends createZodDto(CreateStateSchema) {} export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} export class ReordersStatesDto extends createZodDto(ReorderStatesSchema) {} + +export const QueryParamsSchema = z + .object({ + hidden: z.boolean().optional().default(false).describe('Скрытые записи'), + counts: z.boolean().optional().default(false).describe('Показывать счетчики'), + my: z.boolean().optional().default(false).describe('Только мои записи'), + category: z.string().optional().describe('Фильтр по категории'), + overdue: z.boolean().optional().default(false).describe('Только просроченные'), + }) + .extend(PaginationBaseSchema.shape) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) + .transform((data) => { + if (data.page > 1 && data.offset === 0) { + return { + ...data, + offset: (data.page - 1) * (data.limit || 20), + }; + } + return data; + }); + +export class QueryParamsDto extends createZodDto(QueryParamsSchema) {} diff --git a/src/area/application/use-cases/areas/create.use-case.ts b/src/area/application/use-cases/areas/create.use-case.ts index 43b54d7..3f0c866 100644 --- a/src/area/application/use-cases/areas/create.use-case.ts +++ b/src/area/application/use-cases/areas/create.use-case.ts @@ -1,7 +1,7 @@ import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; import { MAX_AREAS_PER_PROJECT } from '@core/area/infrastructure/constants'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import slugify from 'slugify'; diff --git a/src/area/application/use-cases/areas/delete.use-case.ts b/src/area/application/use-cases/areas/delete.use-case.ts index f53747c..3549033 100644 --- a/src/area/application/use-cases/areas/delete.use-case.ts +++ b/src/area/application/use-cases/areas/delete.use-case.ts @@ -1,6 +1,6 @@ import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/area/application/use-cases/areas/get-all.query.ts b/src/area/application/use-cases/areas/get-all.query.ts index 5006dec..e77076d 100644 --- a/src/area/application/use-cases/areas/get-all.query.ts +++ b/src/area/application/use-cases/areas/get-all.query.ts @@ -1,5 +1,5 @@ import { IAreaRepository } from '@core/area/domain/repository'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { Inject, Injectable } from '@nestjs/common'; @Injectable() diff --git a/src/area/application/use-cases/areas/get-one.query.ts b/src/area/application/use-cases/areas/get-one.query.ts index cc883e2..4b23a1d 100644 --- a/src/area/application/use-cases/areas/get-one.query.ts +++ b/src/area/application/use-cases/areas/get-one.query.ts @@ -1,6 +1,6 @@ import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/area/application/use-cases/areas/update.use-case.ts b/src/area/application/use-cases/areas/update.use-case.ts index 1497a1b..246221f 100644 --- a/src/area/application/use-cases/areas/update.use-case.ts +++ b/src/area/application/use-cases/areas/update.use-case.ts @@ -1,12 +1,14 @@ import { AreaErrorCodes, AreaErrorMessages } from '@core/area/domain/errors'; import { IAreaRepository } from '@core/area/domain/repository'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import slugify from 'slugify'; import { UpdateAreaDto } from '../../dtos'; +import type { NewArea } from '../../../domain/entities'; + @Injectable() export class UpdateAreaUseCase { constructor( @@ -34,10 +36,8 @@ export class UpdateAreaUseCase { ); } - // TODO: TMP|fix that at next patch resolve - const updateData: any = { + const updateData: Partial = { updatedAt: new Date().toISOString(), - updatedBy: userId, ...(dto.title && dto.title !== area.title && { title: dto.title.trim() }), ...(dto.description && dto.description !== area.description && { diff --git a/src/area/application/use-cases/states/get-all.query.ts b/src/area/application/use-cases/states/get-all.query.ts index ce63217..8d5b79d 100644 --- a/src/area/application/use-cases/states/get-all.query.ts +++ b/src/area/application/use-cases/states/get-all.query.ts @@ -1,6 +1,7 @@ import { IStateRepository } from '@core/area/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; +import { QueryParamsDto } from '../../dtos'; import { GetAreaQuery } from '../areas'; @Injectable() @@ -11,7 +12,7 @@ export class GetStatesQuery { private readonly getAreaQ: GetAreaQuery, ) {} - async execute(slug: string, userId: string, query: unknown) { + async execute(slug: string, userId: string, query: QueryParamsDto) { const area = await this.getAreaQ.execute({ key: slug }, userId); const states = await this.stateRepo.find(area.id, query); diff --git a/src/area/area.module.ts b/src/area/area.module.ts index 147ddb3..9dfbc90 100644 --- a/src/area/area.module.ts +++ b/src/area/area.module.ts @@ -1,4 +1,4 @@ -import { ProjectsModule } from '@core/projects'; +import { ProjectModule } from '@core/project'; import { forwardRef, Module } from '@nestjs/common'; import { AreaFacade } from './application/area.facade'; @@ -7,7 +7,7 @@ import { AreasUseCases, StatesUseCases } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ - imports: [forwardRef(() => ProjectsModule)], + imports: [forwardRef(() => ProjectModule)], controllers: [...CONTROLLERS], providers: [...REPOSITORIES, ...StatesUseCases, ...AreasUseCases, AreaFacade], exports: [], diff --git a/src/auth/application/controller/auth/controller.ts b/src/auth/application/controllers/auth/controller.ts similarity index 100% rename from src/auth/application/controller/auth/controller.ts rename to src/auth/application/controllers/auth/controller.ts diff --git a/src/auth/application/controller/auth/swagger.ts b/src/auth/application/controllers/auth/swagger.ts similarity index 99% rename from src/auth/application/controller/auth/swagger.ts rename to src/auth/application/controllers/auth/swagger.ts index 1ed906a..826ddf4 100644 --- a/src/auth/application/controller/auth/swagger.ts +++ b/src/auth/application/controllers/auth/swagger.ts @@ -1,6 +1,5 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiBadRequest, ApiConflict, @@ -11,6 +10,7 @@ import { ApiValidationError, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { SignInDto, diff --git a/src/auth/application/controller/index.ts b/src/auth/application/controllers/index.ts similarity index 100% rename from src/auth/application/controller/index.ts rename to src/auth/application/controllers/index.ts diff --git a/src/auth/application/controller/oauth/controller.ts b/src/auth/application/controllers/oauth/controller.ts similarity index 87% rename from src/auth/application/controller/oauth/controller.ts rename to src/auth/application/controllers/oauth/controller.ts index e33c4c8..0870645 100644 --- a/src/auth/application/controller/oauth/controller.ts +++ b/src/auth/application/controllers/oauth/controller.ts @@ -13,7 +13,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ApiBaseController, GetUserId, SkipContract } from '@shared/decorators'; -import { isBaseException } from '@shared/error'; +import { type IErrorOptions, isBaseException } from '@shared/error'; import { BearerAuthGuard, OAuthGuard } from '@shared/guards'; import { AuthFacade } from '../../auth.facade'; @@ -55,7 +55,7 @@ export class OAuthController { @UseGuards(OAuthGuard) @SkipContract() async oauthCallback( - @Query() query: { readonly code?: string; readonly state?: string }, + @Query() query: { code?: string; state?: string }, @Param('provider') provider: 'google' | 'yandex' | 'github' | 'vkontakte', @Res({ passthrough: true }) res: FastifyReply, @Req() req: FastifyRequest, @@ -85,18 +85,23 @@ export class OAuthController { res.redirect(`${baseUrl}/user/profile?${result.query.toString()}`, 302); } } catch (err) { - const isBaseError = isBaseException(err); + let message = 'Произошла ошибка при авторизации'; + let code = 'OAUTH_ERROR'; - const code = isBaseError - ? typeof err.getResponse().valueOf() !== 'object' && String(err) - : String(err); + if (isBaseException(err)) { + const response = err.getResponse() as IErrorOptions; + message = response.message || message; + code = response.code || code; + } - const message = isBaseError ? err.message : String(err); + if (err instanceof Error) { + message = err.message || message; + } const errorQuery = new URLSearchParams({ success: 'false', - message: message || 'Произошла ошибка при авторизации', - code: code || 'OAUTH_ERROR', + message, + code, }); res.redirect(`${baseUrl}/oauth?${errorQuery.toString()}`, 302); diff --git a/src/auth/application/controller/oauth/swagger.ts b/src/auth/application/controllers/oauth/swagger.ts similarity index 99% rename from src/auth/application/controller/oauth/swagger.ts rename to src/auth/application/controllers/oauth/swagger.ts index 1aa32bc..5c04d53 100644 --- a/src/auth/application/controller/oauth/swagger.ts +++ b/src/auth/application/controllers/oauth/swagger.ts @@ -1,7 +1,6 @@ import { OAuthProvider } from '@core/auth/infrastructure/constants'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiBadRequest, ApiConflict, @@ -10,6 +9,7 @@ import { ApiValidationError, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { ConnectedProviders, diff --git a/src/auth/application/controller/recovery/controller.ts b/src/auth/application/controllers/recovery/controller.ts similarity index 100% rename from src/auth/application/controller/recovery/controller.ts rename to src/auth/application/controllers/recovery/controller.ts diff --git a/src/auth/application/controller/recovery/swagger.ts b/src/auth/application/controllers/recovery/swagger.ts similarity index 99% rename from src/auth/application/controller/recovery/swagger.ts rename to src/auth/application/controllers/recovery/swagger.ts index 01a2515..926bf9a 100644 --- a/src/auth/application/controller/recovery/swagger.ts +++ b/src/auth/application/controllers/recovery/swagger.ts @@ -1,6 +1,5 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiBadRequest, ApiErrorResponse, @@ -10,6 +9,7 @@ import { ApiValidationError, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { ChangePasswordDto, diff --git a/src/auth/application/dtos/auth.dto.ts b/src/auth/application/dtos/auth.dto.ts index 7dcf2ec..44fa933 100644 --- a/src/auth/application/dtos/auth.dto.ts +++ b/src/auth/application/dtos/auth.dto.ts @@ -1,4 +1,4 @@ -import { ActionResponseSchema } from '@shared/dtos'; +import { ActionResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; diff --git a/src/auth/application/strategies/reset-password-resend.strategy.ts b/src/auth/application/strategies/reset-password-resend.strategy.ts index 6c93b3e..115b7a9 100644 --- a/src/auth/application/strategies/reset-password-resend.strategy.ts +++ b/src/auth/application/strategies/reset-password-resend.strategy.ts @@ -6,6 +6,8 @@ import { } from '@core/auth/infrastructure/constants'; import { generate, generateSecret } from 'otplib'; +import { AuthErrorCodes, AuthErrorMessages } from '../../domain/errors'; + import { ResendCodeStrategy } from './resend-code.strategy'; import type { ResetPasswordCacheData } from '@core/auth/application/interfaces'; @@ -14,9 +16,8 @@ import type { Queue } from 'bullmq'; export class ResetPasswordResendStrategy extends ResendCodeStrategy { readonly context = 'reset-password' as const; readonly successMessage = 'Повторный код для восстановления пароля отправлен на вашу почту'; - readonly cacheNotFoundCode = 'RESET_SESSION_EXPIRED'; - readonly cacheNotFoundMessage = - 'Время подтверждения истекло или запрос не найден. Запросите код снова.'; + readonly cacheNotFoundCode = AuthErrorCodes.RESET_SESSION_EXPIRED; + readonly cacheNotFoundMessage = AuthErrorMessages[AuthErrorCodes.RESET_SESSION_EXPIRED]; getCacheKey(email: string): string { return RESET_PASSWORD_CACHE_KEY(email); diff --git a/src/auth/application/strategies/sign-up-resend.strategy.ts b/src/auth/application/strategies/sign-up-resend.strategy.ts index e66f8c0..a519ada 100644 --- a/src/auth/application/strategies/sign-up-resend.strategy.ts +++ b/src/auth/application/strategies/sign-up-resend.strategy.ts @@ -3,6 +3,8 @@ import { RegisterCodeEvent } from '@core/auth/domain/events'; import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; import { generate, generateSecret } from 'otplib'; +import { AuthErrorCodes, AuthErrorMessages } from '../../domain/errors'; + import { ResendCodeStrategy } from './resend-code.strategy'; import type { SignUpCacheData } from '@core/auth/application/interfaces'; @@ -11,8 +13,8 @@ import type { Queue } from 'bullmq'; export class SignUpResendStrategy extends ResendCodeStrategy { readonly context = 'sign-up' as const; readonly successMessage = 'Повторный код подтверждения отправлен на вашу почту'; - readonly cacheNotFoundCode = 'REGISTRATION_EXPIRED'; - readonly cacheNotFoundMessage = 'Срок регистрации истек или email не найден. Попробуйте снова.'; + readonly cacheNotFoundCode = AuthErrorCodes.REGISTRATION_EXPIRED; + readonly cacheNotFoundMessage = AuthErrorMessages[AuthErrorCodes.REGISTRATION_EXPIRED]; getCacheKey(email: string): string { return SIGNUP_CACHE_KEY(email); diff --git a/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts new file mode 100644 index 0000000..4cba596 --- /dev/null +++ b/src/auth/application/use-cases/auth/confirm-reset-password.use-case.ts @@ -0,0 +1,83 @@ +import { UpdatePasswordUseCase } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { PasswordResetConfirmDto } from '../../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly updatePasswordUserUC: UpdatePasswordUseCase, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const hashed = await argon.hash(dto.password); + const result = await this.updatePasswordUserUC.execute(dto.email, hashed); + + await this.cacheService.removeOne(redisKey); + + return { + success: result, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts b/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts new file mode 100644 index 0000000..fedcdca --- /dev/null +++ b/src/auth/application/use-cases/auth/refresh-tokens.use-case.ts @@ -0,0 +1,115 @@ +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; + +import type { DeviceMetadata } from '../../../infrastructure/utils'; + +@Injectable() +export class RefreshTokensUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(token: string | undefined, metadata: DeviceMetadata) { + if (!token) { + throw new BaseException( + { + code: AuthErrorCodes.UNAUTHORIZED, + message: AuthErrorMessages[AuthErrorCodes.UNAUTHORIZED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: AuthErrorCodes.TOKEN_INVALID, + message: AuthErrorMessages[AuthErrorCodes.TOKEN_INVALID], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const session = await this.sessionRepo.findById(payload.jti); + + if (!session) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.SESSION_NOT_FOUND], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + if (session.isRevoked) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_REVOKED, + message: AuthErrorMessages[AuthErrorCodes.SESSION_REVOKED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const entity = await this.findUserQ.execute({ id: session.userId }); + + if (!entity?.user) { + await this.sessionRepo.revoke(session.id); + throw new BaseException( + { + code: 'USER_NOT_FOUND', + message: 'Аккаунт пользователя не найден', + }, + HttpStatus.UNAUTHORIZED, + ); + } + + try { + await this.sessionRepo.revoke(session.id); + + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + entity.user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: entity.user.id, + ...metadata, + expiresAt: expiresAt.toISOString(), + }); + + return { + success: true, + tokens: { access, refresh }, + expiresAt, + message: 'Токены успешно обновлены', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.REFRESH_FAILED, + message: AuthErrorMessages[AuthErrorCodes.REFRESH_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/resend-code.use-case.ts b/src/auth/application/use-cases/auth/resend-code.use-case.ts similarity index 79% rename from src/auth/application/use-cases/resend-code.use-case.ts rename to src/auth/application/use-cases/auth/resend-code.use-case.ts index f5546ff..f11d51e 100644 --- a/src/auth/application/use-cases/resend-code.use-case.ts +++ b/src/auth/application/use-cases/auth/resend-code.use-case.ts @@ -8,17 +8,17 @@ import { SECONDS_BETWEEN_ATTEMPTS, } from '@core/auth/infrastructure/constants'; import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; import { Queue } from 'bullmq'; -import { RESEND_CODE_STRATEGIES, ResendCodeStrategy } from '../strategies'; +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { RESEND_CODE_STRATEGIES, ResendCodeStrategy } from '../../strategies'; @Injectable() export class ResendCodeUseCase { - private readonly logger = new Logger('TEST'); constructor( @Inject(CACHE_SERVICE) private readonly cacheService: ICacheService, @@ -48,16 +48,8 @@ export class ResendCodeUseCase { if (cooldownTtl > 0) { throw new BaseException( { - code: 'RESEND_RATE_LIMIT', + code: AuthErrorCodes.RESEND_RATE_LIMIT, message: `Повторная отправка доступна через ${this.formatWaitTime(cooldownTtl)}`, - details: [ - { - target: 'email', - value: dto.email, - ttlSeconds: cooldownTtl, - nextResendAt: this.buildResendTiming(cooldownTtl).nextResendAt, - }, - ], }, HttpStatus.TOO_MANY_REQUESTS, ); @@ -67,14 +59,12 @@ export class ResendCodeUseCase { const attemptsStr = await this.cacheService.getOne(attemptsKey); let attemptsLeft = attemptsStr ? parseInt(attemptsStr, 10) : MAX_ATTEMPTS; - this.logger.error(attemptsLeft); + if (attemptsLeft <= 0) { throw new BaseException( { - code: 'MAX_ATTEMPTS_REACHED', - message: - 'Превышено максимальное количество попыток отправки кода. Начните процесс заново позже.', - details: [{ target: 'email', value: dto.email }], + code: AuthErrorCodes.MAX_ATTEMPTS_REACHED, + message: AuthErrorMessages[AuthErrorCodes.MAX_ATTEMPTS_REACHED], }, HttpStatus.FORBIDDEN, ); @@ -115,7 +105,10 @@ export class ResendCodeUseCase { if (!strategy) { throw new BaseException( - { code: 'STRATEGY_NOT_FOUND', message: `No strategy for ${context}` }, + { + code: AuthErrorCodes.STRATEGY_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.STRATEGY_NOT_FOUND], + }, HttpStatus.BAD_REQUEST, ); } diff --git a/src/auth/application/use-cases/auth/sign-in.use-case.ts b/src/auth/application/use-cases/auth/sign-in.use-case.ts new file mode 100644 index 0000000..f4d55a0 --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-in.use-case.ts @@ -0,0 +1,95 @@ +import { FindUserQuery } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; +import { DeviceMetadata } from '../../../infrastructure/utils/get-device-meta'; +import { SignInDto } from '../../dtos'; + +@Injectable() +export class SignInUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: SignInDto, meta: DeviceMetadata) { + const entities = await this.findUserQ.execute({ email: dto.email }); + + if (!entities.security) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const { security, user } = entities; + + if (!security.passwordHash) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const isPasswordValid = await argon.verify(security.passwordHash, dto.password); + + if (!isPasswordValid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CREDENTIALS, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CREDENTIALS], + }, + HttpStatus.UNAUTHORIZED, + ); + } + try { + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: user.id, + expiresAt: expiresAt.toISOString(), + ...meta, + }); + + return { + success: true, + tokens: { + access, + refresh, + }, + expiresAt, + message: 'Вы успешно вошли в систему', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNIN_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNIN_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-out.use-case.ts b/src/auth/application/use-cases/auth/sign-out.use-case.ts new file mode 100644 index 0000000..55df53f --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-out.use-case.ts @@ -0,0 +1,66 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; + +@Injectable() +export class SignOutUseCase { + constructor( + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + ) {} + + async execute(token?: string) { + if (!token) { + throw new BaseException( + { + code: AuthErrorCodes.UNAUTHORIZED, + message: AuthErrorMessages[AuthErrorCodes.UNAUTHORIZED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + const payload = await this.tokenService.validateToken(token, 'refresh'); + + if (!payload?.jti) { + throw new BaseException( + { + code: AuthErrorCodes.SESSION_EXPIRED, + message: AuthErrorMessages[AuthErrorCodes.SESSION_EXPIRED], + }, + HttpStatus.UNAUTHORIZED, + ); + } + + try { + const session = await this.sessionRepo.findById(payload.jti); + + if (!session) { + return { + success: true, + message: 'Сессия уже завершена', + }; + } + + const result = await this.sessionRepo.revoke(session.id); + + return { success: result, message: 'Успешно вышли из системы!' }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNOUT_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNOUT_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts b/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts new file mode 100644 index 0000000..bd6860d --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-up-verify.use-case.ts @@ -0,0 +1,139 @@ +import { SignUpCacheData } from '@core/auth/application/interfaces'; +import { AuthQueues } from '@core/auth/domain/enums'; +import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; +import { CreateUserWorkspaceEvent } from '@core/auth/domain/events/create-user-workspace.event'; +import { SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { RegisterUserUseCase } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { createId } from '@paralleldrive/cuid2'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; +import { verify as verifyOTP } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ISessionRepository } from '../../../domain/repository'; +import { TokenService } from '../../../infrastructure/security'; +import { DeviceMetadata } from '../../../infrastructure/utils/get-device-meta'; +import { VerifyDto } from '../../dtos'; + +@Injectable() +export class SignUpVerifyUseCase { + constructor( + @InjectQueue(AuthQueues.AUTH_USER) + private readonly queue: Queue, + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @Inject('ISessionRepository') + private readonly sessionRepo: ISessionRepository, + private readonly tokenService: TokenService, + private readonly registerUserUC: RegisterUserUseCase, + ) {} + + async execute(dto: VerifyDto, meta: DeviceMetadata) { + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.REGISTRATION_EXPIRED, + message: AuthErrorMessages[AuthErrorCodes.REGISTRATION_EXPIRED], + }, + HttpStatus.GONE, + ); + } + + const userData: SignUpCacheData = JSON.parse(cachedData); + + if (!userData || !userData.otp || !userData.password) { + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные регистрации' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (userData.otp.token !== dto.code) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'Неверный код подтверждения' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + const verifyResult = await verifyOTP({ + token: dto.code, + secret: userData.otp.secret, + algorithm: 'sha256', + digits: 6, + period: 900, + strategy: 'totp', + afterTimeStep: 1, + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'Неверный код подтверждения' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + try { + const user = await this.registerUserUC.execute({ + ...userData.user, + emailVerified: true, + emailVerifiedAt: new Date().toISOString(), + password: userData.password, + }); + + const sessionId = createId(); + const { access, refresh, expiresAt } = await this.tokenService.generateTokens( + user, + sessionId, + ); + + await this.sessionRepo.create({ + id: sessionId, + userId: user.id, + ...meta, + expiresAt: expiresAt.toISOString(), + }); + + await this.cacheService.removeOne(SIGNUP_CACHE_KEY(dto.email)); + + const event = new CreateUserWorkspaceEvent(user.id, user.firstName); + await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); + + return { + success: true, + tokens: { access, refresh }, + expiresAt, + message: 'Аккаунт успешно подтвержден', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNUP_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNUP_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/auth/sign-up.use-case.ts b/src/auth/application/use-cases/auth/sign-up.use-case.ts new file mode 100644 index 0000000..9abd000 --- /dev/null +++ b/src/auth/application/use-cases/auth/sign-up.use-case.ts @@ -0,0 +1,95 @@ +import { SignUpCacheData } from '@core/auth/application/interfaces'; +import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; +import { FindUserQuery } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; + +import { AuthQueues, AuthMailJobs } from '../../../domain/enums'; +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { RegisterCodeEvent } from '../../../domain/events'; +import { SignUpDto } from '../../dtos'; + +@Injectable() +export class SignUpUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: SignUpDto) { + const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); + + if (cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_ALREADY_SENT, + message: AuthErrorMessages[AuthErrorCodes.CODE_ALREADY_SENT], + details: [{ target: 'email', message: 'Verification code already sent' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + try { + await this.findUserQ.execute({ email: dto.email }, { throwIfExists: true }); + + const hashPass = await argon.hash(dto.password); + + const secret = generateSecret(); + const token = await generate({ + secret, + algorithm: 'sha256', + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + const data: SignUpCacheData = { + user: dto, + password: hashPass, + otp: { token, secret }, + }; + + await this.cacheService.setOne( + SIGNUP_CACHE_KEY(dto.email), + JSON.stringify(data), + EMAIL_CODE_TTL_SECONDS, + ); + + const event = new RegisterCodeEvent(dto.email, dto.firstName, token); + await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код подтверждения отправлен на вашу почту', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: AuthErrorCodes.SIGNUP_FAILED, + message: AuthErrorMessages[AuthErrorCodes.SIGNUP_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} 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 deleted file mode 100644 index 1c7dd7b..0000000 --- a/src/auth/application/use-cases/confirm-reset-password.use-case.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { UpdatePasswordUseCase } from '@core/user'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; -import { BaseException } from '@shared/error'; -import * as argon from 'argon2'; - -import { PasswordResetConfirmDto } from '../dtos'; - -@Injectable() -export class ConfirmResetPasswordUseCase { - constructor( - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - private readonly updatePasswordUserUC: UpdatePasswordUseCase, - ) {} - - async execute(dto: PasswordResetConfirmDto) { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.cacheService.getOne(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_NOT_FOUND', - message: - 'Сессия восстановления не найдена или истекла. Начните процесс заново.', - }, - HttpStatus.BAD_REQUEST, - ); - } - - const resetSession = JSON.parse(cachedData); - - if (!resetSession.isVerified) { - throw new BaseException( - { - code: 'CODE_NOT_VERIFIED', - message: 'Код подтверждения еще не был верифицирован.', - details: [{ target: 'isVerified', value: false }], - }, - HttpStatus.FORBIDDEN, - ); - } - - const hashed = await argon.hash(dto.password); - const isUpdated = await this.updatePasswordUserUC.execute(dto.email, hashed); - - if (!isUpdated) { - throw new BaseException( - { - code: 'PASSWORD_UPDATE_FAILED', - message: 'Не удалось обновить пароль. Попробуйте позже.', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - await this.cacheService.removeOne(redisKey); - - return { - success: true, - message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', - }; - } -} diff --git a/src/auth/application/use-cases/index.ts b/src/auth/application/use-cases/index.ts index 74ad374..f1fca2e 100644 --- a/src/auth/application/use-cases/index.ts +++ b/src/auth/application/use-cases/index.ts @@ -1,4 +1,9 @@ -import { ConfirmResetPasswordUseCase } from './confirm-reset-password.use-case'; +import { RefreshTokensUseCase } from './auth/refresh-tokens.use-case'; +import { ResendCodeUseCase } from './auth/resend-code.use-case'; +import { SignInUseCase } from './auth/sign-in.use-case'; +import { SignOutUseCase } from './auth/sign-out.use-case'; +import { SignUpVerifyUseCase } from './auth/sign-up-verify.use-case'; +import { SignUpUseCase } from './auth/sign-up.use-case'; import { AuthenticateOAuthUseCase } from './oauth/authenticate-oauth.use-case'; import { ConnectOAuthProviderUseCase } from './oauth/connect-oauth-provider.use-case'; import { ConnectProviderUseCase } from './oauth/connect-provider.use-case'; @@ -9,14 +14,9 @@ import { GetEnabledProvidersQuery } from './oauth/get-enabled-providers.query'; import { OAuthOrchestratorUseCase } from './oauth/oauth-orchestrator.use-case'; import { ProcessOAuthLoginUseCase } from './oauth/process-oauth-login.use-case'; import { ProcessOAuthRegistrationUseCase } from './oauth/process-oauth-registration.use-case'; -import { RefreshTokensUseCase } from './refresh-tokens.use-case'; -import { ResendCodeUseCase } from './resend-code.use-case'; -import { ResetPasswordUseCase } from './reset-password.use-case'; -import { SignInUseCase } from './sign-in.use-case'; -import { SignOutUseCase } from './sign-out.use-case'; -import { SignUpVerifyUseCase } from './sign-up-verify.use-case'; -import { SignUpUseCase } from './sign-up.use-case'; -import { VerifyResetPasswordUseCase } from './verify-reset-password.use-case'; +import { ConfirmResetPasswordUseCase } from './password/confirm-reset-password.use-case'; +import { ResetPasswordUseCase } from './password/reset-password.use-case'; +import { VerifyResetPasswordUseCase } from './password/verify-reset-password.use-case'; export const AuthUseCases = [ ConfirmResetPasswordUseCase, @@ -41,15 +41,15 @@ export const AuthUseCases = [ ExchangeUseCase, ]; -export * from './confirm-reset-password.use-case'; -export * from './verify-reset-password.use-case'; +export * from './password/confirm-reset-password.use-case'; +export * from './password/verify-reset-password.use-case'; export * from './oauth/get-connected-providers.query'; export * from './oauth/disconnect-provider.use-case'; export * from './oauth/authenticate-oauth.use-case'; export * from './oauth/connect-provider.use-case'; -export * from './refresh-tokens.use-case'; -export * from './reset-password.use-case'; -export * from './sign-up-verify.use-case'; +export * from './auth/refresh-tokens.use-case'; +export * from './password/reset-password.use-case'; +export * from './auth/sign-up-verify.use-case'; export * from './oauth/get-enabled-providers.query'; export * from './oauth/exchange.use-case'; @@ -57,7 +57,7 @@ export * from './oauth/oauth-orchestrator.use-case'; export * from './oauth/process-oauth-login.use-case'; export * from './oauth/process-oauth-registration.use-case'; export * from './oauth/connect-oauth-provider.use-case'; -export * from './sign-in.use-case'; -export * from './sign-out.use-case'; -export * from './sign-up.use-case'; -export * from './resend-code.use-case'; +export * from './auth/sign-in.use-case'; +export * from './auth/sign-out.use-case'; +export * from './auth/sign-up.use-case'; +export * from './auth/resend-code.use-case'; diff --git a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts index 29f4c7c..20c6245 100644 --- a/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/connect-oauth-provider.use-case.ts @@ -5,6 +5,7 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; import { OAuthResponse } from '../../dtos'; @Injectable() @@ -22,7 +23,7 @@ export class ConnectOAuthProviderUseCase { this.validateProvider(stateData, dto); - const user = await this.getUser(stateData.userId); + const { user } = await this.findUserQ.execute({ id: stateData.userId }); await this.validateProviderNotConnected(user.id, dto.provider, dto.id); @@ -47,8 +48,8 @@ export class ConnectOAuthProviderUseCase { if (!rawData) { throw new BaseException( { - code: 'INVALID_OR_EXPIRED_STATE', - message: 'Сессия подключения недействительна или истекла', + code: OAuthErrorCodes.INVALID_OR_EXPIRED_STATE, + message: OAuthErrorMessages[OAuthErrorCodes.INVALID_OR_EXPIRED_STATE], }, HttpStatus.BAD_REQUEST, ); @@ -60,8 +61,8 @@ export class ConnectOAuthProviderUseCase { if (stateData.action !== 'connect') { throw new BaseException( { - code: 'INVALID_ACTION', - message: 'Этот state не предназначен для подключения провайдера', + code: OAuthErrorCodes.INVALID_ACTION, + message: OAuthErrorMessages[OAuthErrorCodes.INVALID_ACTION], }, HttpStatus.BAD_REQUEST, ); @@ -70,30 +71,14 @@ export class ConnectOAuthProviderUseCase { if (stateData.provider !== dto.provider) { throw new BaseException( { - code: 'PROVIDER_MISMATCH', - message: `Провайдер в запросе (${dto.provider}) не совпадает с ожидаемым (${stateData.provider})`, + code: OAuthErrorCodes.PROVIDER_MISMATCH, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_MISMATCH], }, HttpStatus.BAD_REQUEST, ); } } - private async getUser(userId: string) { - const result = await this.findUserQ.execute({ id: userId }); - - if (!result?.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь для подключения провайдера не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - - return result.user; - } - private async validateProviderNotConnected( userId: string, provider: string, @@ -107,8 +92,8 @@ export class ConnectOAuthProviderUseCase { if (existingIdentity && existingIdentity.userId !== userId) { throw new BaseException( { - code: 'PROVIDER_ALREADY_USED', - message: `Этот ${provider} аккаунт уже привязан к другому пользователю`, + code: OAuthErrorCodes.PROVIDER_ALREADY_USED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_USED], }, HttpStatus.CONFLICT, ); @@ -120,8 +105,8 @@ export class ConnectOAuthProviderUseCase { if (alreadyConnected) { throw new BaseException( { - code: 'PROVIDER_ALREADY_CONNECTED', - message: `Провайдер ${provider} уже привязан к вашему аккаунту`, + code: OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED], }, HttpStatus.CONFLICT, ); diff --git a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts index 9d8115f..c60d3b8 100644 --- a/src/auth/application/use-cases/oauth/connect-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/connect-provider.use-case.ts @@ -6,6 +6,8 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; + @Injectable() export class ConnectProviderUseCase { constructor( @@ -21,7 +23,7 @@ export class ConnectProviderUseCase { private readonly STATE_KEY = (state: string) => `oauth:state:${state}`; async execute(provider: string, userId: string) { - await this.validateUser(userId); + await this.findUserQ.execute({ id: userId }); await this.validateProviderNotConnected(userId, provider); await this.validateNoActiveSession(userId, provider); @@ -56,19 +58,6 @@ export class ConnectProviderUseCase { return { success: true, url: `/v1/auth/oauth/${provider}?state=${stateCode}` }; } - private async validateUser(userId: string) { - const entity = await this.findUserQ.execute({ id: userId }); - if (!entity?.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - } - private async validateProviderNotConnected(userId: string, provider: string) { const identities = await this.identityRepo.findAllByUserId(userId); const isConnected = identities.some((identity) => identity.provider === provider); @@ -76,8 +65,8 @@ export class ConnectProviderUseCase { if (isConnected) { throw new BaseException( { - code: 'PROVIDER_ALREADY_CONNECTED', - message: `Провайдер "${this.getProviderName(provider)}" уже подключен к аккаунту`, + code: OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED], }, HttpStatus.CONFLICT, ); @@ -105,7 +94,7 @@ export class ConnectProviderUseCase { throw new BaseException( { - code: 'ACTIVE_OAUTH_SESSION_EXISTS', + code: OAuthErrorCodes.ACTIVE_OAUTH_SESSION_EXISTS, message, details: [ { @@ -126,9 +115,8 @@ export class ConnectProviderUseCase { const names: Record = { google: 'Google', github: 'GitHub', - facebook: 'Facebook', yandex: 'Яндекс', - vk: 'VK', + vkontakte: 'VK', }; return names[provider] || provider; } diff --git a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts index 4b91453..472b1e6 100644 --- a/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts +++ b/src/auth/application/use-cases/oauth/disconnect-provider.use-case.ts @@ -3,6 +3,8 @@ import { FindUserQuery } from '@core/user'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; + @Injectable() export class DisconnectProviderUseCase { constructor( @@ -14,24 +16,14 @@ export class DisconnectProviderUseCase { async execute(provider: string, userId: string) { const entity = await this.findUserQ.execute({ id: userId }); - if (!entity?.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - const providers = await this.identityRepo.findAllByUserId(entity.user.id); const targetProvider = providers.find((p) => p.provider === provider); if (!targetProvider) { throw new BaseException( { - code: 'PROVIDER_NOT_LINKED', - message: `Провайдер ${provider} не привязан к пользователю`, + code: OAuthErrorCodes.PROVIDER_NOT_LINKED, + message: OAuthErrorMessages[OAuthErrorCodes.PROVIDER_NOT_LINKED], }, HttpStatus.BAD_REQUEST, ); @@ -44,9 +36,8 @@ export class DisconnectProviderUseCase { if (!hasOtherProviders && !hasPassword) { throw new BaseException( { - message: - 'Нельзя удалить последний способ входа. Пожалуйста, установите пароль или добавьте другой провайдер.', - code: 'LAST_AUTH_METHOD_CANNOT_BE_REMOVED', + code: OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED, + message: OAuthErrorMessages[OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED], }, HttpStatus.BAD_REQUEST, ); diff --git a/src/auth/application/use-cases/oauth/exchange.use-case.ts b/src/auth/application/use-cases/oauth/exchange.use-case.ts index 00dd97e..ab762bd 100644 --- a/src/auth/application/use-cases/oauth/exchange.use-case.ts +++ b/src/auth/application/use-cases/oauth/exchange.use-case.ts @@ -4,6 +4,7 @@ import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; import { BaseException } from '@shared/error'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; import { ISessionRepository } from '../../../domain/repository'; import { EXCHANGE_TOKEN_NAME } from '../../../infrastructure/constants'; import { TokenService } from '../../../infrastructure/security'; @@ -28,8 +29,8 @@ export class ExchangeUseCase { if (!rawData) { throw new BaseException( { - message: 'Exchange token is invalid or expired', - code: 'EXCHANGE_TOKEN_INVALID', + code: OAuthErrorCodes.EXCHANGE_TOKEN_INVALID, + message: OAuthErrorMessages[OAuthErrorCodes.EXCHANGE_TOKEN_INVALID], }, HttpStatus.BAD_REQUEST, ); diff --git a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts index 455ae94..5bd6a9e 100644 --- a/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts +++ b/src/auth/application/use-cases/oauth/process-oauth-login.use-case.ts @@ -3,6 +3,7 @@ import { FindUserQuery } from '@core/user'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; import { OAuthResponse } from '../../dtos'; @Injectable() @@ -19,8 +20,8 @@ export class ProcessOAuthLoginUseCase { if (!identity) { throw new BaseException( { - code: 'OAUTH_LOGIN_NOT_FOUND', - message: 'Пользователь с таким OAuth аккаунтом не найден', + code: OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND, + message: OAuthErrorMessages[OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND], }, HttpStatus.NOT_FOUND, ); @@ -28,16 +29,6 @@ export class ProcessOAuthLoginUseCase { const result = await this.findUserQ.execute({ id: identity.userId }); - if (!result?.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь не найден', - }, - HttpStatus.NOT_FOUND, - ); - } - return { user: result.user, isNewUser: false, diff --git a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts index 0131cb3..ead5ada 100644 --- a/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts +++ b/src/auth/application/use-cases/oauth/process-oauth-registration.use-case.ts @@ -7,6 +7,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { Queue } from 'bullmq'; +import { OAuthErrorCodes, OAuthErrorMessages } from '../../../domain/errors'; import { OAuthResponse } from '../../dtos'; @Injectable() @@ -26,9 +27,8 @@ export class ProcessOAuthRegistrationUseCase { if (existingUser) { throw new BaseException( { - code: 'EMAIL_ALREADY_EXISTS', - message: - 'Пользователь с таким email уже существует. Пожалуйста, войдите через пароль.', + code: OAuthErrorCodes.EMAIL_ALREADY_EXISTS, + message: OAuthErrorMessages[OAuthErrorCodes.EMAIL_ALREADY_EXISTS], }, HttpStatus.CONFLICT, ); diff --git a/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts b/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts new file mode 100644 index 0000000..4cba596 --- /dev/null +++ b/src/auth/application/use-cases/password/confirm-reset-password.use-case.ts @@ -0,0 +1,83 @@ +import { UpdatePasswordUseCase } from '@core/user'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import * as argon from 'argon2'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { PasswordResetConfirmDto } from '../../dtos'; + +@Injectable() +export class ConfirmResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + private readonly updatePasswordUserUC: UpdatePasswordUseCase, + ) {} + + async execute(dto: PasswordResetConfirmDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const hashed = await argon.hash(dto.password); + const result = await this.updatePasswordUserUC.execute(dto.email, hashed); + + await this.cacheService.removeOne(redisKey); + + return { + success: result, + message: 'Пароль успешно изменен. Теперь вы можете войти в аккаунт.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/password/reset-password.use-case.ts b/src/auth/application/use-cases/password/reset-password.use-case.ts new file mode 100644 index 0000000..4b57749 --- /dev/null +++ b/src/auth/application/use-cases/password/reset-password.use-case.ts @@ -0,0 +1,97 @@ +import { ResetPasswordCacheData } from '@core/auth/application/interfaces'; +import { + EMAIL_CODE_TTL_SECONDS, + RESET_PASSWORD_CACHE_KEY, +} from '@core/auth/infrastructure/constants'; +import { FindUserQuery } from '@core/user'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; +import { generate, generateSecret } from 'otplib'; + +import { AuthMailJobs, AuthQueues } from '../../../domain/enums'; +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { ResetPasswordEvent } from '../../../domain/events'; +import { ResetPasswordDto } from '../../dtos'; + +@Injectable() +export class ResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + @InjectQueue(AuthQueues.AUTH_MAIL) + private readonly mailQueue: Queue, + private readonly findUserQ: FindUserQuery, + ) {} + + async execute(dto: ResetPasswordDto) { + const isExistsAttempt = await this.cacheService.getOne(RESET_PASSWORD_CACHE_KEY(dto.email)); + + if (isExistsAttempt) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_ATTEMPT_ACTIVE, + message: AuthErrorMessages[AuthErrorCodes.RESET_ATTEMPT_ACTIVE], + details: [{ target: 'email', value: dto.email }], + }, + HttpStatus.CONFLICT, + ); + } + + const entity = await this.findUserQ.execute( + { email: dto.email }, + { throwIfNotFound: true, throwIfExists: false }, + ); + + try { + const secret = generateSecret(); + const token = await generate({ + secret, + digits: 6, + period: EMAIL_CODE_TTL_SECONDS, + strategy: 'totp', + }); + + const resetPayload: ResetPasswordCacheData = { + email: entity.user.email, + otp: { secret, token }, + isVerified: false, + }; + + await this.cacheService.setOne( + RESET_PASSWORD_CACHE_KEY(dto.email), + JSON.stringify(resetPayload), + EMAIL_CODE_TTL_SECONDS, + ); + + const event = new ResetPasswordEvent(dto.email, token); + await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + }); + + return { + success: true, + message: 'Код для восстановления пароля отправлен на вашу почту', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/password/verify-reset-password.use-case.ts b/src/auth/application/use-cases/password/verify-reset-password.use-case.ts new file mode 100644 index 0000000..937e0ed --- /dev/null +++ b/src/auth/application/use-cases/password/verify-reset-password.use-case.ts @@ -0,0 +1,101 @@ +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; +import { ICacheService } from '@shared/adapters/cache/ports'; +import { BaseException } from '@shared/error'; +import { verify as verifyOTP } from 'otplib'; + +import { AuthErrorCodes, AuthErrorMessages } from '../../../domain/errors'; +import { VerifyResetCodeDto } from '../../dtos'; + +@Injectable() +export class VerifyResetPasswordUseCase { + constructor( + @Inject(CACHE_SERVICE) + private readonly cacheService: ICacheService, + ) {} + + async execute(dto: VerifyResetCodeDto) { + const redisKey = `pass:reset:${dto.email}`; + const cachedData = await this.cacheService.getOne(redisKey); + + if (!cachedData) { + throw new BaseException( + { + code: AuthErrorCodes.RESET_SESSION_NOT_FOUND, + message: AuthErrorMessages[AuthErrorCodes.RESET_SESSION_NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const resetSession = JSON.parse(cachedData); + + if (!resetSession) { + await this.cacheService.removeOne(redisKey); + + throw new BaseException( + { + code: AuthErrorCodes.DATA_CORRUPTION, + message: AuthErrorMessages[AuthErrorCodes.DATA_CORRUPTION], + details: [{ target: 'cache', message: 'Поврежденные данные сброса пароля' }], + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + if (!resetSession.isVerified) { + throw new BaseException( + { + code: AuthErrorCodes.CODE_NOT_VERIFIED, + message: AuthErrorMessages[AuthErrorCodes.CODE_NOT_VERIFIED], + details: [{ target: 'code', message: 'Код подтверждения не верифицирован' }], + }, + HttpStatus.FORBIDDEN, + ); + } + + try { + const verifyResult = await verifyOTP({ + token: dto.code, + secret: resetSession.otp.secret, + digits: 6, + period: 900, + strategy: 'totp', + }); + + if (!verifyResult.valid) { + throw new BaseException( + { + code: AuthErrorCodes.INVALID_CODE, + message: AuthErrorMessages[AuthErrorCodes.INVALID_CODE], + details: [{ target: 'code', message: 'The provided OTP is incorrect' }], + }, + HttpStatus.BAD_REQUEST, + ); + } + + await this.cacheService.setOne( + redisKey, + JSON.stringify({ ...resetSession, isVerified: true }), + 600, + ); + + return { + success: true, + message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', + }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } + + throw new BaseException( + { + code: AuthErrorCodes.RESET_PASSWORD_FAILED, + message: AuthErrorMessages[AuthErrorCodes.RESET_PASSWORD_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/auth/application/use-cases/refresh-tokens.use-case.ts b/src/auth/application/use-cases/refresh-tokens.use-case.ts deleted file mode 100644 index 70de6f9..0000000 --- a/src/auth/application/use-cases/refresh-tokens.use-case.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { FindUserQuery } from '@core/user'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; -import { BaseException } from '@shared/error'; - -import { ISessionRepository } from '../../domain/repository'; -import { TokenService } from '../../infrastructure/security'; -import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; - -@Injectable() -export class RefreshTokensUseCase { - constructor( - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - private readonly tokenService: TokenService, - private readonly findUserQ: FindUserQuery, - ) {} - - async execute(token: string | undefined, metadata: DeviceMetadata) { - if (!token) { - throw new BaseException( - { - code: 'SESSION_REQUIRED', - message: 'Session required', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'INVALID_TOKEN', - message: 'Сессия недействительна или истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - if (!session || session?.isRevoked) { - throw new BaseException( - { - code: 'SESSION_REVOKED', - message: 'Ваша сессия была отозвана или завершена', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const entity = await this.findUserQ.execute({ id: session.userId }); - - if (!entity?.user) { - await this.sessionRepo.revoke(session.id); - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Аккаунт пользователя не найден', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - await this.sessionRepo.revoke(session.id); - - const sessionId = createId(); - const { access, refresh, expiresAt } = await this.tokenService.generateTokens( - entity.user, - sessionId, - ); - - await this.sessionRepo.create({ - id: sessionId, - userId: entity.user.id, - ...metadata, - expiresAt: expiresAt.toISOString(), - }); - - return { - tokens: { access, refresh }, - success: true, - expiresAt, - message: 'Токены успешно обновлены', - }; - } -} diff --git a/src/auth/application/use-cases/reset-password.use-case.ts b/src/auth/application/use-cases/reset-password.use-case.ts deleted file mode 100644 index b5a8f55..0000000 --- a/src/auth/application/use-cases/reset-password.use-case.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ResetPasswordCacheData } from '@core/auth/application/interfaces'; -import { - EMAIL_CODE_TTL_SECONDS, - RESET_PASSWORD_CACHE_KEY, -} from '@core/auth/infrastructure/constants'; -import { FindUserQuery } from '@core/user'; -import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; -import { BaseException } from '@shared/error'; -import { Queue } from 'bullmq'; -import { generate, generateSecret } from 'otplib'; - -import { AuthMailJobs, AuthQueues } from '../../domain/enums'; -import { ResetPasswordEvent } from '../../domain/events'; -import { ResetPasswordDto } from '../dtos'; - -@Injectable() -export class ResetPasswordUseCase { - constructor( - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - @InjectQueue(AuthQueues.AUTH_MAIL) - private readonly mailQueue: Queue, - private readonly findUserQ: FindUserQuery, - ) {} - - async execute(dto: ResetPasswordDto) { - const isExistsAttempt = await this.cacheService.getOne(RESET_PASSWORD_CACHE_KEY(dto.email)); - - if (isExistsAttempt) { - throw new BaseException( - { - code: 'PASS_RESET_ATTEMPT_ACTIVE', - message: - 'Запрос на сброс пароля уже активен. Проверьте почту или попробуйте позже.', - details: [ - { - target: 'email', - value: dto.email, - }, - ], - }, - HttpStatus.CONFLICT, - ); - } - - const entity = await this.findUserQ.execute({ email: dto.email }); - - if (!entity?.user) { - throw new BaseException( - { - code: 'USER_NOT_FOUND', - message: 'Пользователь с таким email не найден', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.NOT_FOUND, - ); - } - - const secret = generateSecret(); - const token = await generate({ - secret, - digits: 6, - period: EMAIL_CODE_TTL_SECONDS, - strategy: 'totp', - }); - - const resetPayload: ResetPasswordCacheData = { - email: entity.user.email, - otp: { secret, token }, - isVerified: false, - }; - - await this.cacheService.setOne( - RESET_PASSWORD_CACHE_KEY(dto.email), - JSON.stringify(resetPayload), - EMAIL_CODE_TTL_SECONDS, - ); - - const event = new ResetPasswordEvent(dto.email, token); - await this.mailQueue.add(AuthMailJobs.SEND_RESET_PASSWORD, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код для восстановления пароля отправлен на вашу почту', - }; - } -} diff --git a/src/auth/application/use-cases/sign-in.use-case.ts b/src/auth/application/use-cases/sign-in.use-case.ts deleted file mode 100644 index 51d67cf..0000000 --- a/src/auth/application/use-cases/sign-in.use-case.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { FindUserQuery } from '@core/user'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; -import { BaseException } from '@shared/error'; -import * as argon from 'argon2'; - -import { ISessionRepository } from '../../domain/repository'; -import { TokenService } from '../../infrastructure/security'; -import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; -import { SignInDto } from '../dtos'; - -@Injectable() -export class SignInUseCase { - constructor( - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - private readonly tokenService: TokenService, - private readonly findUserQ: FindUserQuery, - ) {} - - async execute(dto: SignInDto, meta: DeviceMetadata) { - const entities = await this.findUserQ.execute({ email: dto.email }); - - if (!entities?.user || !entities?.security) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const { security, user } = entities; - // TODO: FIX - const isPasswordValid = await argon.verify(security.passwordHash ?? '', dto.password); - - if (!isPasswordValid) { - throw new BaseException( - { - code: 'INVALID_CREDENTIALS', - message: 'Неверный email или пароль', - }, - HttpStatus.UNAUTHORIZED, - ); - } - const sessionId = createId(); - const { access, refresh, expiresAt } = await this.tokenService.generateTokens( - user, - sessionId, - ); - - await this.sessionRepo.create({ - id: sessionId, - userId: user.id, - expiresAt: expiresAt.toISOString(), - ...meta, - }); - - return { - success: true, - tokens: { - access, - refresh, - }, - expiresAt, - message: 'Вы успешно вошли в систему', - }; - } -} diff --git a/src/auth/application/use-cases/sign-out.use-case.ts b/src/auth/application/use-cases/sign-out.use-case.ts deleted file mode 100644 index 301a5fe..0000000 --- a/src/auth/application/use-cases/sign-out.use-case.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -import { ISessionRepository } from '../../domain/repository'; -import { TokenService } from '../../infrastructure/security'; - -@Injectable() -export class SignOutUseCase { - constructor( - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - private readonly tokenService: TokenService, - ) {} - - async execute(token?: string) { - if (!token) { - throw new BaseException( - { - code: 'SESSION_REQUIRED', - message: 'Session required', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const payload = await this.tokenService.validateToken(token, 'refresh'); - - if (!payload?.jti) { - throw new BaseException( - { - code: 'SESSION_EXPIRED', - message: 'Сессия уже истекла', - }, - HttpStatus.UNAUTHORIZED, - ); - } - - const session = await this.sessionRepo.findById(payload.jti); - - if (session) { - const isRevoked = await this.sessionRepo.revoke(session.id); - - if (!isRevoked) { - throw new BaseException( - { - code: 'SIGNOUT_FAILED', - message: 'Не удалось завершить сессию на сервере. Попробуйте позже.', - details: [{ target: 'database', message: 'Session revocation failed' }], - }, - HttpStatus.SERVICE_UNAVAILABLE, - ); - } - } - - return { success: true, message: 'Успешно вышли из системы!' }; - } -} 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 deleted file mode 100644 index 3b43dd9..0000000 --- a/src/auth/application/use-cases/sign-up-verify.use-case.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { SignUpCacheData } from '@core/auth/application/interfaces'; -import { AuthQueues } from '@core/auth/domain/enums'; -import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; -import { CreateUserWorkspaceEvent } from '@core/auth/domain/events/create-user-workspace.event'; -import { SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; -import { RegisterUserUseCase } from '@core/user'; -import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; -import { BaseException } from '@shared/error'; -import { Queue } from 'bullmq'; -import { verify as verifyOTP } from 'otplib'; - -import { ISessionRepository } from '../../domain/repository'; -import { TokenService } from '../../infrastructure/security'; -import { DeviceMetadata } from '../../infrastructure/utils/get-device-meta'; -import { VerifyDto } from '../dtos'; - -@Injectable() -export class SignUpVerifyUseCase { - constructor( - @InjectQueue(AuthQueues.AUTH_USER) - private readonly queue: Queue, - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - @Inject('ISessionRepository') - private readonly sessionRepo: ISessionRepository, - private readonly tokenService: TokenService, - private readonly registerUserUC: RegisterUserUseCase, - ) {} - - async execute(dto: VerifyDto, meta: DeviceMetadata) { - const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); - - if (!cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_EXPIRED', - message: 'Срок регистрации истек или email не найден. Попробуйте снова.', - }, - HttpStatus.GONE, - ); - } - - const userData: SignUpCacheData = JSON.parse(cachedData); - - if (!userData) { - throw new BaseException( - { - code: 'INTERNAL_DATA_CORRUPTION', - message: 'Ошибка целостности данных. Попробуйте начать регистрацию заново.', - }, - HttpStatus.UNPROCESSABLE_ENTITY, - ); - } - - if (userData.otp.token !== dto.code) { - throw new BaseException( - { - code: 'INVALID_OTP', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'OTP code is invalid or expired' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: userData.otp.secret, - algorithm: 'sha256', - digits: 6, - period: 900, - strategy: 'totp', - afterTimeStep: 1, - }); - - if (!verifyResult.valid) { - throw new BaseException( - { - code: 'INVALID_OTP', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'OTP code is invalid or expired' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const user = await this.registerUserUC.execute({ - ...userData.user, - emailVerified: true, - emailVerifiedAt: new Date().toISOString(), - password: userData.password, - }); - - const sessionId = createId(); - const { access, refresh, expiresAt } = await this.tokenService.generateTokens( - user, - sessionId, - ); - - await this.sessionRepo.create({ - id: sessionId, - userId: user.id, - ...meta, - expiresAt: expiresAt.toISOString(), - }); - - await this.cacheService.removeOne(SIGNUP_CACHE_KEY(dto.email)); - - const event = new CreateUserWorkspaceEvent(user.id, user.firstName); - await this.queue.add(AuthUserJobs.CREATE_WORKSPACE, event); - - return { - success: true, - tokens: { access, refresh }, - expiresAt, - message: 'Аккаунт успешно подтвержден', - }; - } -} diff --git a/src/auth/application/use-cases/sign-up.use-case.ts b/src/auth/application/use-cases/sign-up.use-case.ts deleted file mode 100644 index 2e338f5..0000000 --- a/src/auth/application/use-cases/sign-up.use-case.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { SignUpCacheData } from '@core/auth/application/interfaces'; -import { EMAIL_CODE_TTL_SECONDS, SIGNUP_CACHE_KEY } from '@core/auth/infrastructure/constants'; -import { FindUserQuery } from '@core/user'; -import { InjectQueue } from '@nestjs/bullmq'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; -import { BaseException } from '@shared/error'; -import * as argon from 'argon2'; -import { Queue } from 'bullmq'; -import { generate, generateSecret } from 'otplib'; - -import { AuthQueues, AuthMailJobs } from '../../domain/enums'; -import { RegisterCodeEvent } from '../../domain/events'; -import { SignUpDto } from '../dtos'; - -@Injectable() -export class SignUpUseCase { - constructor( - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - @InjectQueue(AuthQueues.AUTH_MAIL) - private readonly mailQueue: Queue, - private readonly findUserQ: FindUserQuery, - ) {} - - async execute(dto: SignUpDto) { - const cachedData = await this.cacheService.getOne(SIGNUP_CACHE_KEY(dto.email)); - - if (cachedData) { - throw new BaseException( - { - code: 'REGISTRATION_IN_PROGRESS', - message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.', - details: [{ target: 'email', message: 'Verification code already sent' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - const isExists = await this.findUserQ.execute({ email: dto.email }); - - if (isExists) { - throw new BaseException( - { - code: 'USER_ALREADY_EXISTS', - message: 'Email уже занят другим аккаунтом', - details: [{ target: 'email', value: dto.email }], - }, - HttpStatus.CONFLICT, - ); - } - - const hashPass = await argon.hash(dto.password); - - const secret = generateSecret(); - const token = await generate({ - secret, - algorithm: 'sha256', - digits: 6, - period: EMAIL_CODE_TTL_SECONDS, - strategy: 'totp', - }); - - const data: SignUpCacheData = { - user: dto, - password: hashPass, - otp: { token, secret }, - }; - - await this.cacheService.setOne( - SIGNUP_CACHE_KEY(dto.email), - JSON.stringify(data), - EMAIL_CODE_TTL_SECONDS, - ); - - const event = new RegisterCodeEvent(dto.email, dto.firstName, token); - await this.mailQueue.add(AuthMailJobs.SEND_REGISTER_CODE, event, { - attempts: 3, - backoff: { - type: 'exponential', - delay: 5000, - }, - }); - - return { - success: true, - message: 'Код подтверждения отправлен на вашу почту', - }; - } -} 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 deleted file mode 100644 index fb35dee..0000000 --- a/src/auth/application/use-cases/verify-reset-password.use-case.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; -import { ICacheService } from '@shared/adapters/cache/ports'; -import { BaseException } from '@shared/error'; -import { verify as verifyOTP } from 'otplib'; - -import { VerifyResetCodeDto } from '../dtos'; - -@Injectable() -export class VerifyResetPasswordUseCase { - constructor( - @Inject(CACHE_SERVICE) - private readonly cacheService: ICacheService, - ) {} - - async execute(dto: VerifyResetCodeDto) { - const redisKey = `pass:reset:${dto.email}`; - const cachedData = await this.cacheService.getOne(redisKey); - - if (!cachedData) { - throw new BaseException( - { - code: 'RESET_SESSION_EXPIRED', - message: - 'Время подтверждения истекло или запрос не найден. Запросите код снова.', - }, - HttpStatus.GONE, - ); - } - - const resetSession = JSON.parse(cachedData); - - const verifyResult = await verifyOTP({ - token: dto.code, - secret: resetSession.otp.secret, - digits: 6, - period: 900, - strategy: 'totp', - }); - - if (!verifyResult.valid) { - throw new BaseException( - { - code: 'INVALID_VERIFICATION_CODE', - message: 'Неверный или истекший код подтверждения', - details: [{ target: 'code', message: 'The provided OTP is incorrect' }], - }, - HttpStatus.BAD_REQUEST, - ); - } - - await this.cacheService.setOne( - redisKey, - JSON.stringify({ ...resetSession, isVerified: true }), - 600, - ); - - return { - success: true, - message: 'Код успешно подтвержден. Теперь вы можете установить новый пароль.', - }; - } -} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 946c9eb..eb31892 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,4 +1,4 @@ -import { ProjectsModule } from '@core/projects'; +import { ProjectModule } from '@core/project'; import { TeamsModule } from '@core/teams'; import { UserModule } from '@core/user'; import { BullModule } from '@nestjs/bullmq'; @@ -8,7 +8,7 @@ import { JwtModule } from '@nestjs/jwt'; import { MailAdapter } from '@shared/adapters/mail'; import { AuthFacade } from './application/auth.facade'; -import { CONTROLLERS } from './application/controller'; +import { CONTROLLERS } from './application/controllers'; import { AuthUseCases } from './application/use-cases'; import { AuthQueues } from './domain/enums'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @@ -30,6 +30,7 @@ const WORKERS = [MailProcessor, UserProcessor]; * формат строки (напр. '15m', '30d') через regex в ConfigSchema, но внутренний тип * 'StringValue' из библиотеки 'ms' слишком строг для обычного string. */ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ expiresIn: cfg.get('JWT_ACCESS_EXPIRES_IN'), algorithm: 'HS256', }, @@ -43,7 +44,7 @@ const WORKERS = [MailProcessor, UserProcessor]; BullModule.registerQueue({ name: AuthQueues.AUTH_MAIL }, { name: AuthQueues.AUTH_USER }), forwardRef(() => UserModule), TeamsModule, - ProjectsModule, + ProjectModule, ], controllers: CONTROLLERS, providers: [ diff --git a/src/auth/domain/errors/auth.error.ts b/src/auth/domain/errors/auth.error.ts new file mode 100644 index 0000000..91cf0c9 --- /dev/null +++ b/src/auth/domain/errors/auth.error.ts @@ -0,0 +1,54 @@ +export const AuthErrorCodes = { + INVALID_CREDENTIALS: 'AUTH.INVALID_CREDENTIALS', + INVALID_CODE: 'AUTH.INVALID_CODE', + CODE_ALREADY_SENT: 'AUTH.CODE_ALREADY_SENT', + SESSION_EXPIRED: 'AUTH.SESSION_EXPIRED', + SIGNOUT_FAILED: 'AUTH.SIGNOUT_FAILED', + STRATEGY_NOT_FOUND: 'AUTH.STRATEGY_NOT_FOUND', + SESSION_NOT_FOUND: 'AUTH.SESSION_NOT_FOUND', + SESSION_REVOKED: 'AUTH.SESSION_REVOKED', + TOKEN_INVALID: 'AUTH.TOKEN_INVALID', + REFRESH_FAILED: 'AUTH.REFRESH_FAILED', + CODE_NOT_VERIFIED: 'AUTH.CODE_NOT_VERIFIED', + SIGNUP_FAILED: 'AUTH.SIGNUP_FAILED', + UNAUTHORIZED: 'AUTH.UNAUTHORIZED', + RESET_SESSION_NOT_FOUND: 'AUTH.RESET_SESSION_NOT_FOUND', + RESET_ATTEMPT_ACTIVE: 'AUTH.RESET_ATTEMPT_ACTIVE', + REGISTRATION_EXPIRED: 'AUTH.REGISTRATION_EXPIRED', + RESET_SESSION_EXPIRED: 'AUTH.RESET_SESSION_EXPIRED', + RESEND_RATE_LIMIT: 'AUTH.RESEND_RATE_LIMIT', + MAX_ATTEMPTS_REACHED: 'AUTH.MAX_ATTEMPTS_REACHED', + SIGNIN_FAILED: 'AUTH.SIGNIN_FAILED', + SESSION_CREATION_FAILED: 'AUTH.SESSION_CREATION_FAILED', + RESET_PASSWORD_FAILED: 'AUTH.RESET_PASSWORD_FAILED', + DATA_CORRUPTION: 'DATA_CORRUPTION', +} as const; + +export type AuthErrorCode = (typeof AuthErrorCodes)[keyof typeof AuthErrorCodes]; + +export const AuthErrorMessages: Record = { + [AuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных. Попробуйте начать заново', + [AuthErrorCodes.INVALID_CREDENTIALS]: 'Неверный email или пароль', + [AuthErrorCodes.INVALID_CODE]: 'Неверный или истекший код подтверждения', + [AuthErrorCodes.CODE_ALREADY_SENT]: 'Код уже был отправлен. Проверьте почту или подождите', + [AuthErrorCodes.SESSION_EXPIRED]: 'Сессия истекла. Выполните вход заново', + [AuthErrorCodes.SIGNOUT_FAILED]: 'Не удалось завершить сессию, попробуйте позже', + [AuthErrorCodes.TOKEN_INVALID]: 'Недействительный токен обновления', + [AuthErrorCodes.REFRESH_FAILED]: 'Не удалось обновить токены. Попробуйте позже', + [AuthErrorCodes.SESSION_NOT_FOUND]: 'Сессия не найдена', + [AuthErrorCodes.SESSION_REVOKED]: 'Сессия была отозвана. Выполните вход заново', + [AuthErrorCodes.STRATEGY_NOT_FOUND]: 'Неизвестный контекст операции', + [AuthErrorCodes.CODE_NOT_VERIFIED]: 'Код подтверждения еще не был верифицирован', + [AuthErrorCodes.UNAUTHORIZED]: 'Необходимо выполнить вход', + [AuthErrorCodes.RESET_SESSION_NOT_FOUND]: 'Запрос на сброс пароля не найден', + [AuthErrorCodes.REGISTRATION_EXPIRED]: 'Регистрация истекла. Зарегистрируйтесь заново', + [AuthErrorCodes.RESET_SESSION_EXPIRED]: 'Время подтверждения истекло. Запросите код снова', + [AuthErrorCodes.RESEND_RATE_LIMIT]: 'Слишком частые попытки. Повторная отправка доступна через', + [AuthErrorCodes.MAX_ATTEMPTS_REACHED]: + 'Превышено максимальное количество попыток. Начните процесс заново', + [AuthErrorCodes.SIGNIN_FAILED]: 'Не удалось выполнить вход. Попробуйте позже', + [AuthErrorCodes.SIGNUP_FAILED]: 'Не удалось зарегистрироваться. Попробуйте позже', + [AuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', + [AuthErrorCodes.RESET_ATTEMPT_ACTIVE]: 'Запрос на сброс пароля уже активен. Проверьте почту', + [AuthErrorCodes.RESET_PASSWORD_FAILED]: 'Не удалось сбросить пароль. Попробуйте позже', +}; diff --git a/src/auth/domain/errors/index.ts b/src/auth/domain/errors/index.ts new file mode 100644 index 0000000..6539b96 --- /dev/null +++ b/src/auth/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from './auth.error'; +export * from './oauth.error'; diff --git a/src/auth/domain/errors/oauth.error.ts b/src/auth/domain/errors/oauth.error.ts new file mode 100644 index 0000000..ad3c55f --- /dev/null +++ b/src/auth/domain/errors/oauth.error.ts @@ -0,0 +1,45 @@ +export const OAuthErrorCodes = { + INVALID_ACTION: 'OAUTH.INVALID_ACTION', + PROVIDER_NOT_LINKED: 'OAUTH.PROVIDER_NOT_LINKED', + PROVIDER_ALREADY_CONNECTED: 'OAUTH.PROVIDER_ALREADY_CONNECTED', + PROVIDER_MISMATCH: 'OAUTH.PROVIDER_MISMATCH', + INVALID_OR_EXPIRED_STATE: 'OAUTH.INVALID_OR_EXPIRED_STATE', + LAST_AUTH_METHOD_CANNOT_BE_REMOVED: 'OAUTH.LAST_AUTH_METHOD_CANNOT_BE_REMOVED', + EXCHANGE_TOKEN_INVALID: 'OAUTH.EXCHANGE_TOKEN_INVALID', + EXCHANGE_DATA_CORRUPTED: 'OAUTH.EXCHANGE_DATA_CORRUPTED', + PROVIDER_ALREADY_USED: 'OAUTH.PROVIDER_ALREADY_USED', + EMAIL_ALREADY_EXISTS: 'OAUTH.EMAIL_ALREADY_EXISTS', + OAUTH_LOGIN_NOT_FOUND: 'OAUTH.LOGIN_NOT_FOUND', + ACTIVE_OAUTH_SESSION_EXISTS: 'OAUTH.ACTIVE_SESSION_EXISTS', + UNAUTHORIZED: 'OAUTH.UNAUTHORIZED', + DATA_CORRUPTION: 'OAUTH.DATA_CORRUPTION', + SESSION_CREATION_FAILED: 'OAUTH.SESSION_CREATION_FAILED', + SESSION_CREATION_INTERNAL_ERROR: 'OAUTH.SESSION_CREATION_INTERNAL_ERROR', + PROVIDER_CONNECT_FAILED: 'OAUTH.PROVIDER_CONNECT_FAILED', + PROVIDER_DISCONNECT_FAILED: 'OAUTH.PROVIDER_DISCONNECT_FAILED', +} as const; + +export type OAuthErrorCode = (typeof OAuthErrorCodes)[keyof typeof OAuthErrorCodes]; + +export const OAuthErrorMessages: Record = { + [OAuthErrorCodes.INVALID_ACTION]: 'Неверное действие для OAuth операции', + [OAuthErrorCodes.PROVIDER_NOT_LINKED]: 'Провайдер не привязан к пользователю', + [OAuthErrorCodes.PROVIDER_ALREADY_CONNECTED]: 'Провайдер уже подключен к аккаунту', + [OAuthErrorCodes.PROVIDER_MISMATCH]: 'Провайдер в запросе не совпадает с ожидаемым', + [OAuthErrorCodes.INVALID_OR_EXPIRED_STATE]: 'Сессия подключения недействительна или истекла', + [OAuthErrorCodes.LAST_AUTH_METHOD_CANNOT_BE_REMOVED]: + 'Нельзя удалить последний способ входа. Установите пароль или добавьте другой провайдер', + [OAuthErrorCodes.EXCHANGE_TOKEN_INVALID]: 'Токен обмена недействителен или истёк', + [OAuthErrorCodes.EXCHANGE_DATA_CORRUPTED]: 'Неверный формат данных авторизации', + [OAuthErrorCodes.PROVIDER_ALREADY_USED]: 'Провайдер уже привязан к другому пользователю', + [OAuthErrorCodes.EMAIL_ALREADY_EXISTS]: + 'Пользователь с таким email уже существует. Войдите через пароль', + [OAuthErrorCodes.OAUTH_LOGIN_NOT_FOUND]: 'Пользователь с таким OAuth аккаунтом не найден', + [OAuthErrorCodes.ACTIVE_OAUTH_SESSION_EXISTS]: 'Активный процесс авторизации уже существует', + [OAuthErrorCodes.UNAUTHORIZED]: 'Необходима авторизация', + [OAuthErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных', + [OAuthErrorCodes.SESSION_CREATION_FAILED]: 'Не удалось создать сессию', + [OAuthErrorCodes.SESSION_CREATION_INTERNAL_ERROR]: 'Внутренняя ошибка при создании сессии', + [OAuthErrorCodes.PROVIDER_CONNECT_FAILED]: 'Не удалось привязать провайдера', + [OAuthErrorCodes.PROVIDER_DISCONNECT_FAILED]: 'Не удалось отвязать провайдера', +}; diff --git a/src/auth/infrastructure/security/token.service.ts b/src/auth/infrastructure/security/token.service.ts index 383caff..159291b 100644 --- a/src/auth/infrastructure/security/token.service.ts +++ b/src/auth/infrastructure/security/token.service.ts @@ -23,7 +23,9 @@ export class TokenService { aud, }; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const accessExp = this.cfg.get('JWT_ACCESS_EXPIRES_IN'); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const refreshExp = this.cfg.get('JWT_REFRESH_EXPIRES_IN'); const [access, refresh] = await Promise.all([ diff --git a/src/auth/infrastructure/strategies/yandex.strategy.ts b/src/auth/infrastructure/strategies/yandex.strategy.ts index 33fad7e..ab886b7 100644 --- a/src/auth/infrastructure/strategies/yandex.strategy.ts +++ b/src/auth/infrastructure/strategies/yandex.strategy.ts @@ -78,13 +78,15 @@ export class YandexStrategy extends PassportStrategy(Strategy, 'yandex-oauth') { ) { const json = profile._json; + const phone = json.default_phone?.number || null; + const user = { id: json.id, email: json.default_email, first_name: json.first_name, last_name: json.last_name, sex: json.sex || null, - phone: json.default_phone.number, + phone, avatar_url: profile.photos?.[0]?.value || null, bio: null, }; diff --git a/src/auth/infrastructure/workers/mail.processor.ts b/src/auth/infrastructure/workers/mail.processor.ts index 9277a8d..ff3f3a5 100644 --- a/src/auth/infrastructure/workers/mail.processor.ts +++ b/src/auth/infrastructure/workers/mail.processor.ts @@ -18,7 +18,7 @@ export class MailProcessor extends WorkerHost { async process(job: Job): Promise; async process(job: Job): Promise; - async process(job: Job): Promise { + async process(job: Job): Promise { await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); try { diff --git a/src/auth/infrastructure/workers/user.processor.ts b/src/auth/infrastructure/workers/user.processor.ts index 7fe2766..fb61c30 100644 --- a/src/auth/infrastructure/workers/user.processor.ts +++ b/src/auth/infrastructure/workers/user.processor.ts @@ -1,7 +1,7 @@ import { AuthQueues } from '@core/auth/domain/enums'; import { AuthUserJobs } from '@core/auth/domain/enums/auth-jobs.enum'; import { CreateUserWorkspaceEvent } from '@core/auth/domain/events'; -import { CreateProjectUseCase } from '@core/projects/application/use-cases'; +import { CreateProjectUseCase } from '@core/project/application/use-cases'; import { CreateTeamUseCase } from '@core/teams/application/use-cases'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; diff --git a/src/shared/media/controller/index.ts b/src/media/application/controllers/index.ts similarity index 89% rename from src/shared/media/controller/index.ts rename to src/media/application/controllers/index.ts index 2d6d82f..d80f398 100644 --- a/src/shared/media/controller/index.ts +++ b/src/media/application/controllers/index.ts @@ -1,7 +1,7 @@ import { Post } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { ExtractMediaReq } from '../decorators'; +import { ExtractMediaReq } from '../../infrastructure/decorators'; import { UploadMediaDto } from '../dtos'; import { MediaService } from '../media.service'; diff --git a/src/shared/media/controller/swagger.ts b/src/media/application/controllers/swagger.ts similarity index 96% rename from src/shared/media/controller/swagger.ts rename to src/media/application/controllers/swagger.ts index 0d416d9..20a9698 100644 --- a/src/shared/media/controller/swagger.ts +++ b/src/media/application/controllers/swagger.ts @@ -1,8 +1,8 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { UploadMediaDto } from '../dtos'; diff --git a/src/shared/media/dtos/index.ts b/src/media/application/dtos/index.ts similarity index 100% rename from src/shared/media/dtos/index.ts rename to src/media/application/dtos/index.ts diff --git a/src/shared/media/dtos/upload-file.dto.ts b/src/media/application/dtos/upload-file.dto.ts similarity index 100% rename from src/shared/media/dtos/upload-file.dto.ts rename to src/media/application/dtos/upload-file.dto.ts diff --git a/src/shared/media/media.service.ts b/src/media/application/media.service.ts similarity index 91% rename from src/shared/media/media.service.ts rename to src/media/application/media.service.ts index b2eb22c..b95352b 100644 --- a/src/shared/media/media.service.ts +++ b/src/media/application/media.service.ts @@ -6,11 +6,12 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import { FlowProducer } from 'bullmq'; +import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from '../domain/enums'; +import { MEDIA_STRATEGIES, MediaStrategyKey } from '../infrastructure/strategies'; + import { UploadMediaDto } from './dtos'; -import { MEDIA_FLOW, MEDIA_JOBS, MEDIA_QUEUES } from './media.constant'; -import { MEDIA_STRATEGIES, MediaStrategyKey } from './strategies'; -import type { MediaDispatchStrategy } from './strategies/media.strategy'; +import type { MediaDispatchStrategy } from '../infrastructure/strategies/media.strategy'; @Injectable() export class MediaService { @@ -82,7 +83,7 @@ export class MediaService { return { attempts, backoff: { type: backoffType, delay: backoffType === 'fixed' ? 2000 : 1000 }, - // removeOnComplete: true, + removeOnComplete: true, removeOnFail: false, }; } @@ -110,7 +111,6 @@ export class MediaService { { code: 'MEDIA_SAVE_FAILED', message: 'Ошибка при сохранении медиа-данных', - details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], }, HttpStatus.BAD_REQUEST, ); diff --git a/src/media/domain/enums/flow.enum.ts b/src/media/domain/enums/flow.enum.ts new file mode 100644 index 0000000..2cd782f --- /dev/null +++ b/src/media/domain/enums/flow.enum.ts @@ -0,0 +1 @@ +export const MEDIA_FLOW = 'MEDIA_FLOW'; diff --git a/src/media/domain/enums/index.ts b/src/media/domain/enums/index.ts new file mode 100644 index 0000000..a73b34c --- /dev/null +++ b/src/media/domain/enums/index.ts @@ -0,0 +1,3 @@ +export * from './flow.enum'; +export * from './jobs.enum'; +export * from './queues'; diff --git a/src/media/domain/enums/jobs.enum.ts b/src/media/domain/enums/jobs.enum.ts new file mode 100644 index 0000000..2b83a2a --- /dev/null +++ b/src/media/domain/enums/jobs.enum.ts @@ -0,0 +1,5 @@ +export const enum MEDIA_JOBS { + RESIZE_IMAGES = 'RESIZE_IMAGES', + UPDATE_USER_AVATAR = 'UPDATE_USER_AVATAR', + UPDATE_TEAM_MEDIA = 'UPDATE_TEAM_MEDIA', +} diff --git a/src/media/domain/enums/queues.ts b/src/media/domain/enums/queues.ts new file mode 100644 index 0000000..0ddac69 --- /dev/null +++ b/src/media/domain/enums/queues.ts @@ -0,0 +1,4 @@ +export const enum MEDIA_QUEUES { + RESIZE = 'RESIZE', + SAVE_ENTITY = 'SAVE_ENTITY', +} diff --git a/src/media/index.ts b/src/media/index.ts new file mode 100644 index 0000000..f5f63d2 --- /dev/null +++ b/src/media/index.ts @@ -0,0 +1,4 @@ +export { MediaModule } from './media.module'; +export * from './infrastructure/constants'; +export * from './infrastructure/interfaces'; +export * from './domain/enums'; diff --git a/src/shared/media/media.constant.ts b/src/media/infrastructure/constants/index.ts similarity index 61% rename from src/shared/media/media.constant.ts rename to src/media/infrastructure/constants/index.ts index 04efa24..ad5cafb 100644 --- a/src/shared/media/media.constant.ts +++ b/src/media/infrastructure/constants/index.ts @@ -1,15 +1,3 @@ -export const MEDIA_QUEUES = { - RESIZE: 'RESIZE', - SAVE_ENTITY: 'SAVE_ENTITY', -}; - -export const MEDIA_JOBS = { - RESIZE_IMAGES: 'RESIZE_IMAGES', - UPDATE_USER_AVATAR: 'UPDATE_USER_AVATAR', - UPDATE_TEAM_MEDIA: 'UPDATE_TEAM_MEDIA', -}; -export const MEDIA_FLOW = 'MEDIA_FLOW'; - export const MEDIA_SPECS = { avatar: [ { name: 'sm', width: 64, height: 64, quality: 80 }, @@ -22,3 +10,5 @@ export const MEDIA_SPECS = { { name: 'lg', width: 1920, height: 1080, fit: 'fit-in' }, ], } as const; + +export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; diff --git a/src/shared/media/decorators/extract-media-req.decorator.ts b/src/media/infrastructure/decorators/extract-media-req.decorator.ts similarity index 98% rename from src/shared/media/decorators/extract-media-req.decorator.ts rename to src/media/infrastructure/decorators/extract-media-req.decorator.ts index 48ca927..7103ca9 100644 --- a/src/shared/media/decorators/extract-media-req.decorator.ts +++ b/src/media/infrastructure/decorators/extract-media-req.decorator.ts @@ -2,7 +2,7 @@ import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs import { BaseException } from '@shared/error'; import { formatBytes } from '@shared/utils/format-bytes.util'; -import { IMAGE_MIME_TYPES } from '../../constants'; +import { IMAGE_MIME_TYPES } from '../constants'; import type { FastifyRequest } from 'fastify'; diff --git a/src/shared/media/decorators/index.ts b/src/media/infrastructure/decorators/index.ts similarity index 100% rename from src/shared/media/decorators/index.ts rename to src/media/infrastructure/decorators/index.ts diff --git a/src/media/infrastructure/interfaces/index.ts b/src/media/infrastructure/interfaces/index.ts new file mode 100644 index 0000000..524ee80 --- /dev/null +++ b/src/media/infrastructure/interfaces/index.ts @@ -0,0 +1 @@ +export * from './media.interface'; diff --git a/src/shared/media/interfaces/media.interface.ts b/src/media/infrastructure/interfaces/media.interface.ts similarity index 100% rename from src/shared/media/interfaces/media.interface.ts rename to src/media/infrastructure/interfaces/media.interface.ts diff --git a/src/shared/media/strategies/index.ts b/src/media/infrastructure/strategies/index.ts similarity index 100% rename from src/shared/media/strategies/index.ts rename to src/media/infrastructure/strategies/index.ts diff --git a/src/media/infrastructure/strategies/media.strategy.ts b/src/media/infrastructure/strategies/media.strategy.ts new file mode 100644 index 0000000..88690e4 --- /dev/null +++ b/src/media/infrastructure/strategies/media.strategy.ts @@ -0,0 +1,7 @@ +// eslint-disable-next-line no-restricted-syntax +import type { UploadMediaDto } from '../../application/dtos'; + +export abstract class MediaDispatchStrategy { + abstract readonly jobName: string; + abstract createPayload(dto: UploadMediaDto, userId: string, path: string): unknown; +} diff --git a/src/shared/media/strategies/team-media.strategy.ts b/src/media/infrastructure/strategies/team-media.strategy.ts similarity index 72% rename from src/shared/media/strategies/team-media.strategy.ts rename to src/media/infrastructure/strategies/team-media.strategy.ts index d10cf3e..ee271c8 100644 --- a/src/shared/media/strategies/team-media.strategy.ts +++ b/src/media/infrastructure/strategies/team-media.strategy.ts @@ -1,8 +1,8 @@ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import { UploadMediaDto } from '../dtos'; -import { MEDIA_JOBS } from '../media.constant'; +import { MEDIA_JOBS } from '../../domain/enums/jobs.enum'; -import type { UpdateMediaTeam } from '../interfaces/media.interface'; +// eslint-disable-next-line no-restricted-syntax +import type { UploadMediaDto } from '../../application/dtos'; +import type { UpdateMediaTeam } from '../interfaces'; import type { MediaDispatchStrategy } from './media.strategy'; export class TeamMediaStrategy implements MediaDispatchStrategy { diff --git a/src/shared/media/strategies/user-avatar.strategy.ts b/src/media/infrastructure/strategies/user-avatar.strategy.ts similarity index 71% rename from src/shared/media/strategies/user-avatar.strategy.ts rename to src/media/infrastructure/strategies/user-avatar.strategy.ts index 94447aa..ad3c229 100644 --- a/src/shared/media/strategies/user-avatar.strategy.ts +++ b/src/media/infrastructure/strategies/user-avatar.strategy.ts @@ -1,8 +1,8 @@ -import { MEDIA_JOBS } from '../media.constant'; +import { MEDIA_JOBS } from '../../domain/enums/jobs.enum'; // eslint-disable-next-line no-restricted-syntax -import type { UploadMediaDto } from '../dtos'; -import type { UpdateMediaUser } from '../interfaces/media.interface'; +import type { UploadMediaDto } from '../../application/dtos'; +import type { UpdateMediaUser } from '../interfaces'; import type { MediaDispatchStrategy } from './media.strategy'; export class UserAvatarStrategy implements MediaDispatchStrategy { diff --git a/src/shared/media/workers/index.ts b/src/media/infrastructure/workers/index.ts similarity index 100% rename from src/shared/media/workers/index.ts rename to src/media/infrastructure/workers/index.ts diff --git a/src/shared/media/workers/media.worker.ts b/src/media/infrastructure/workers/media.worker.ts similarity index 95% rename from src/shared/media/workers/media.worker.ts rename to src/media/infrastructure/workers/media.worker.ts index 02b8c21..6ab8e7a 100644 --- a/src/shared/media/workers/media.worker.ts +++ b/src/media/infrastructure/workers/media.worker.ts @@ -5,7 +5,8 @@ import { S3Service } from '@libs/s3'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Job } from 'bullmq'; -import { MEDIA_JOBS, MEDIA_QUEUES, MEDIA_SPECS } from '../media.constant'; +import { MEDIA_JOBS, MEDIA_QUEUES } from '../../domain/enums'; +import { MEDIA_SPECS } from '../constants'; @Processor(MEDIA_QUEUES.RESIZE) export class MediaProcessor extends WorkerHost { diff --git a/src/shared/media/media.module.ts b/src/media/media.module.ts similarity index 87% rename from src/shared/media/media.module.ts rename to src/media/media.module.ts index f72c79b..be31e73 100644 --- a/src/shared/media/media.module.ts +++ b/src/media/media.module.ts @@ -4,10 +4,10 @@ import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { MediaController } from './controller'; -import { MEDIA_FLOW, MEDIA_QUEUES } from './media.constant'; -import { MediaService } from './media.service'; -import { MediaProcessor } from './workers/media.worker'; +import { MediaController } from './application/controllers'; +import { MediaService } from './application/media.service'; +import { MEDIA_FLOW, MEDIA_QUEUES } from './domain/enums'; +import { MediaProcessor } from './infrastructure/workers'; @Module({ imports: [ diff --git a/src/projects/application/controller/index.ts b/src/project/application/controllers/index.ts similarity index 100% rename from src/projects/application/controller/index.ts rename to src/project/application/controllers/index.ts diff --git a/src/projects/application/controller/members/controller.ts b/src/project/application/controllers/members/controller.ts similarity index 100% rename from src/projects/application/controller/members/controller.ts rename to src/project/application/controllers/members/controller.ts diff --git a/src/projects/application/controller/members/swagger.ts b/src/project/application/controllers/members/swagger.ts similarity index 99% rename from src/projects/application/controller/members/swagger.ts rename to src/project/application/controllers/members/swagger.ts index b00efa0..490a44a 100644 --- a/src/projects/application/controller/members/swagger.ts +++ b/src/project/application/controllers/members/swagger.ts @@ -1,8 +1,8 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiForbidden, ApiNotFound, ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { AddProjectMemberDto, ListMembersResponse, UpdateProjectMemberDto } from '../../dtos'; diff --git a/src/projects/application/controller/projects/controller.ts b/src/project/application/controllers/projects/controller.ts similarity index 100% rename from src/projects/application/controller/projects/controller.ts rename to src/project/application/controllers/projects/controller.ts diff --git a/src/projects/application/controller/projects/swagger.ts b/src/project/application/controllers/projects/swagger.ts similarity index 99% rename from src/projects/application/controller/projects/swagger.ts rename to src/project/application/controllers/projects/swagger.ts index bc56a0d..76d3f66 100644 --- a/src/projects/application/controller/projects/swagger.ts +++ b/src/project/application/controllers/projects/swagger.ts @@ -1,10 +1,9 @@ import { CheckSlugResponse, CreateShareTokenResponse, -} from '@core/projects/application/dtos/project.dto'; +} from '@core/project/application/dtos/project.dto'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiValidationError, ApiUnauthorized, @@ -13,6 +12,7 @@ import { ApiConflict, } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { CreateProjectDto, diff --git a/src/projects/application/dtos/index.ts b/src/project/application/dtos/index.ts similarity index 100% rename from src/projects/application/dtos/index.ts rename to src/project/application/dtos/index.ts diff --git a/src/projects/application/dtos/member.dto.ts b/src/project/application/dtos/member.dto.ts similarity index 100% rename from src/projects/application/dtos/member.dto.ts rename to src/project/application/dtos/member.dto.ts diff --git a/src/projects/application/dtos/project.dto.ts b/src/project/application/dtos/project.dto.ts similarity index 98% rename from src/projects/application/dtos/project.dto.ts rename to src/project/application/dtos/project.dto.ts index 4167926..79d2d2c 100644 --- a/src/projects/application/dtos/project.dto.ts +++ b/src/project/application/dtos/project.dto.ts @@ -1,6 +1,5 @@ -import { PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/projects/domain/entities'; -import { ActionResponseSchema } from '@shared/dtos'; -import { createPaginationSchema } from '@shared/schemas'; +import { PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/project/domain/entities'; +import { createPaginationSchema, ActionResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; diff --git a/src/projects/application/dtos/settings.dto.ts b/src/project/application/dtos/settings.dto.ts similarity index 100% rename from src/projects/application/dtos/settings.dto.ts rename to src/project/application/dtos/settings.dto.ts diff --git a/src/projects/application/mappers/index.ts b/src/project/application/mappers/index.ts similarity index 100% rename from src/projects/application/mappers/index.ts rename to src/project/application/mappers/index.ts diff --git a/src/projects/application/mappers/member.mapper.ts b/src/project/application/mappers/member.mapper.ts similarity index 93% rename from src/projects/application/mappers/member.mapper.ts rename to src/project/application/mappers/member.mapper.ts index 06751fd..c1a8411 100644 --- a/src/projects/application/mappers/member.mapper.ts +++ b/src/project/application/mappers/member.mapper.ts @@ -1,4 +1,4 @@ -import type { MemberWithUser } from '@core/projects/domain/entities'; +import type { MemberWithUser } from '@core/project/domain/entities'; export class MemberMapper { public static toMemberResponse(member: MemberWithUser) { diff --git a/src/projects/application/mappers/project.mapper.ts b/src/project/application/mappers/project.mapper.ts similarity index 96% rename from src/projects/application/mappers/project.mapper.ts rename to src/project/application/mappers/project.mapper.ts index 7580a42..df3466f 100644 --- a/src/projects/application/mappers/project.mapper.ts +++ b/src/project/application/mappers/project.mapper.ts @@ -1,4 +1,4 @@ -import type { Project } from '@core/projects/domain/entities'; +import type { Project } from '@core/project/domain/entities'; import type { RawMemberRow } from '@core/teams/domain/repository'; export class ProjectMapper { diff --git a/src/projects/application/project.facade.ts b/src/project/application/project.facade.ts similarity index 100% rename from src/projects/application/project.facade.ts rename to src/project/application/project.facade.ts diff --git a/src/projects/application/use-cases/index.ts b/src/project/application/use-cases/index.ts similarity index 100% rename from src/projects/application/use-cases/index.ts rename to src/project/application/use-cases/index.ts diff --git a/src/projects/application/use-cases/member/add.use-case.ts b/src/project/application/use-cases/member/add.use-case.ts similarity index 90% rename from src/projects/application/use-cases/member/add.use-case.ts rename to src/project/application/use-cases/member/add.use-case.ts index e8788d1..f528e86 100644 --- a/src/projects/application/use-cases/member/add.use-case.ts +++ b/src/project/application/use-cases/member/add.use-case.ts @@ -1,7 +1,7 @@ -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IMemberRepository } from '@core/projects/domain/repository'; -import { MAX_MEMBERS_PER_PROJECT } from '@core/projects/infrastructure/constants'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; +import { MAX_MEMBERS_PER_PROJECT } from '@core/project/infrastructure/constants'; import { FindTeamMemberQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; @@ -62,7 +62,6 @@ export class AddProjectMemberUseCase { } const currentCount = await this.memberRepo.countByProject(project.id); - // TODO: project.settings?.maxMembers ?? MAX_MEMBERS_PER_PROJECT if (currentCount >= MAX_MEMBERS_PER_PROJECT) { throw new BaseException( { diff --git a/src/projects/application/use-cases/member/delete.use-case.ts b/src/project/application/use-cases/member/delete.use-case.ts similarity index 95% rename from src/projects/application/use-cases/member/delete.use-case.ts rename to src/project/application/use-cases/member/delete.use-case.ts index e19996d..22a29fb 100644 --- a/src/projects/application/use-cases/member/delete.use-case.ts +++ b/src/project/application/use-cases/member/delete.use-case.ts @@ -1,6 +1,6 @@ -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IMemberRepository } from '@core/projects/domain/repository'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/projects/application/use-cases/member/find-all.query.ts b/src/project/application/use-cases/member/find-all.query.ts similarity index 89% rename from src/projects/application/use-cases/member/find-all.query.ts rename to src/project/application/use-cases/member/find-all.query.ts index e60ec9d..1b38743 100644 --- a/src/projects/application/use-cases/member/find-all.query.ts +++ b/src/project/application/use-cases/member/find-all.query.ts @@ -1,5 +1,5 @@ -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IMemberRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; import { FindByIdsQuery } from '@core/user/application/use-cases'; import { Inject, Injectable } from '@nestjs/common'; diff --git a/src/projects/application/use-cases/member/get-available.query.ts b/src/project/application/use-cases/member/get-available.query.ts similarity index 100% rename from src/projects/application/use-cases/member/get-available.query.ts rename to src/project/application/use-cases/member/get-available.query.ts diff --git a/src/projects/application/use-cases/member/index.ts b/src/project/application/use-cases/member/index.ts similarity index 100% rename from src/projects/application/use-cases/member/index.ts rename to src/project/application/use-cases/member/index.ts diff --git a/src/projects/application/use-cases/member/update.use-case.ts b/src/project/application/use-cases/member/update.use-case.ts similarity index 94% rename from src/projects/application/use-cases/member/update.use-case.ts rename to src/project/application/use-cases/member/update.use-case.ts index eb7815c..b2d0067 100644 --- a/src/projects/application/use-cases/member/update.use-case.ts +++ b/src/project/application/use-cases/member/update.use-case.ts @@ -1,6 +1,6 @@ -import { MemberErrorCodes, MemberErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IMemberRepository } from '@core/projects/domain/repository'; +import { MemberErrorCodes, MemberErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IMemberRepository } from '@core/project/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/projects/application/use-cases/project/check-slug.use-case.ts b/src/project/application/use-cases/project/check-slug.use-case.ts similarity index 87% rename from src/projects/application/use-cases/project/check-slug.use-case.ts rename to src/project/application/use-cases/project/check-slug.use-case.ts index adaa5c2..c5a119b 100644 --- a/src/projects/application/use-cases/project/check-slug.use-case.ts +++ b/src/project/application/use-cases/project/check-slug.use-case.ts @@ -1,4 +1,4 @@ -import { IProjectRepository } from '@core/projects/domain/repository'; +import { IProjectRepository } from '@core/project/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; @Injectable() diff --git a/src/projects/application/use-cases/project/create.use-case.ts b/src/project/application/use-cases/project/create.use-case.ts similarity index 85% rename from src/projects/application/use-cases/project/create.use-case.ts rename to src/project/application/use-cases/project/create.use-case.ts index dbf687b..10028c8 100644 --- a/src/projects/application/use-cases/project/create.use-case.ts +++ b/src/project/application/use-cases/project/create.use-case.ts @@ -1,8 +1,8 @@ -import { PROJECT_STATUSES } from '@core/projects/domain/entities'; -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; -import { MAX_PROJECTS_PER_TEAM } from '@core/projects/infrastructure/constants'; +import { PROJECT_STATUSES } from '@core/project/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; +import { MAX_PROJECTS_PER_TEAM } from '@core/project/infrastructure/constants'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import slugify from 'slugify'; @@ -17,12 +17,9 @@ export class CreateProjectUseCase { ) {} public async execute(userId: string, teamId: string, dto: CreateProjectDto) { - const { settings, ...project } = dto; + const { settings: _s, ...project } = dto; const { team } = await this.policy.ensureTeamAccess(teamId, userId, 'admin'); - // TODO: TMP VAR - console.debug(settings); - const currentSlug = slugify(project?.slug ? project.slug : project.name, { lower: true, strict: true, diff --git a/src/projects/application/use-cases/project/delete.use-case.ts b/src/project/application/use-cases/project/delete.use-case.ts similarity index 90% rename from src/projects/application/use-cases/project/delete.use-case.ts rename to src/project/application/use-cases/project/delete.use-case.ts index 104724b..6c90ea9 100644 --- a/src/projects/application/use-cases/project/delete.use-case.ts +++ b/src/project/application/use-cases/project/delete.use-case.ts @@ -1,6 +1,6 @@ -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/projects/application/use-cases/project/find-by-team.query.ts b/src/project/application/use-cases/project/find-by-team.query.ts similarity index 88% rename from src/projects/application/use-cases/project/find-by-team.query.ts rename to src/project/application/use-cases/project/find-by-team.query.ts index b90b5db..2b0e04c 100644 --- a/src/projects/application/use-cases/project/find-by-team.query.ts +++ b/src/project/application/use-cases/project/find-by-team.query.ts @@ -1,5 +1,5 @@ -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; import { ProjectMapper } from '../../mappers'; diff --git a/src/projects/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts similarity index 96% rename from src/projects/application/use-cases/project/find-one.query.ts rename to src/project/application/use-cases/project/find-one.query.ts index c92ee10..3a4d67b 100644 --- a/src/projects/application/use-cases/project/find-one.query.ts +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -1,13 +1,13 @@ import { createHash } from 'node:crypto'; -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { IProjectRepository } from '@core/project/domain/repository'; import { FindTeamMemberQuery, FindTeamQuery } from '@core/teams'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { isTeamRole, ROLE_PRIORITY } from '@shared/constants'; import { BaseException } from '@shared/error'; -import type { Project } from '@core/projects/domain/entities'; +import type { Project } from '@core/project/domain/entities'; @Injectable() export class FindProjectQuery { diff --git a/src/projects/application/use-cases/project/generate-share-token.use-case.ts b/src/project/application/use-cases/project/generate-share-token.use-case.ts similarity index 94% rename from src/projects/application/use-cases/project/generate-share-token.use-case.ts rename to src/project/application/use-cases/project/generate-share-token.use-case.ts index 566b2d1..dc2a381 100644 --- a/src/projects/application/use-cases/project/generate-share-token.use-case.ts +++ b/src/project/application/use-cases/project/generate-share-token.use-case.ts @@ -1,13 +1,13 @@ import { createHash, randomBytes } from 'node:crypto'; -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; import { SHARE_LINK_LENGTH, SHARE_LINK_PREFIX, SHARE_LINK_TTL_MONTHS, -} from '@core/projects/infrastructure/constants'; +} from '@core/project/infrastructure/constants'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/projects/application/use-cases/project/get-detail.query.ts b/src/project/application/use-cases/project/get-detail.query.ts similarity index 100% rename from src/projects/application/use-cases/project/get-detail.query.ts rename to src/project/application/use-cases/project/get-detail.query.ts diff --git a/src/projects/application/use-cases/project/index.ts b/src/project/application/use-cases/project/index.ts similarity index 100% rename from src/projects/application/use-cases/project/index.ts rename to src/project/application/use-cases/project/index.ts diff --git a/src/projects/application/use-cases/project/set-status.use-case.ts b/src/project/application/use-cases/project/set-status.use-case.ts similarity index 94% rename from src/projects/application/use-cases/project/set-status.use-case.ts rename to src/project/application/use-cases/project/set-status.use-case.ts index 074adc6..48a97d6 100644 --- a/src/projects/application/use-cases/project/set-status.use-case.ts +++ b/src/project/application/use-cases/project/set-status.use-case.ts @@ -1,7 +1,7 @@ -import { PROJECT_STATUSES, type ProjectStatus } from '@core/projects/domain/entities'; -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { PROJECT_STATUSES, type ProjectStatus } from '@core/project/domain/entities'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; diff --git a/src/projects/application/use-cases/project/update.use-case.ts b/src/project/application/use-cases/project/update.use-case.ts similarity index 95% rename from src/projects/application/use-cases/project/update.use-case.ts rename to src/project/application/use-cases/project/update.use-case.ts index bda0920..3723075 100644 --- a/src/projects/application/use-cases/project/update.use-case.ts +++ b/src/project/application/use-cases/project/update.use-case.ts @@ -1,6 +1,6 @@ -import { ProjectErrorCodes, ProjectErrorMessages } from '@core/projects/domain/errors'; -import { ProjectAccessPolicy } from '@core/projects/domain/policy'; -import { IProjectRepository } from '@core/projects/domain/repository'; +import { ProjectErrorCodes, ProjectErrorMessages } from '@core/project/domain/errors'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { IProjectRepository } from '@core/project/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; import slugify from 'slugify'; diff --git a/src/projects/domain/entities/enum.ts b/src/project/domain/entities/enum.ts similarity index 100% rename from src/projects/domain/entities/enum.ts rename to src/project/domain/entities/enum.ts diff --git a/src/projects/domain/entities/index.ts b/src/project/domain/entities/index.ts similarity index 100% rename from src/projects/domain/entities/index.ts rename to src/project/domain/entities/index.ts diff --git a/src/projects/domain/entities/member.domain.ts b/src/project/domain/entities/member.domain.ts similarity index 100% rename from src/projects/domain/entities/member.domain.ts rename to src/project/domain/entities/member.domain.ts diff --git a/src/projects/domain/entities/project.domain.ts b/src/project/domain/entities/project.domain.ts similarity index 100% rename from src/projects/domain/entities/project.domain.ts rename to src/project/domain/entities/project.domain.ts diff --git a/src/projects/domain/errors/index.ts b/src/project/domain/errors/index.ts similarity index 100% rename from src/projects/domain/errors/index.ts rename to src/project/domain/errors/index.ts diff --git a/src/projects/domain/errors/member.errors.ts b/src/project/domain/errors/member.errors.ts similarity index 100% rename from src/projects/domain/errors/member.errors.ts rename to src/project/domain/errors/member.errors.ts diff --git a/src/projects/domain/errors/project.errors.ts b/src/project/domain/errors/project.errors.ts similarity index 100% rename from src/projects/domain/errors/project.errors.ts rename to src/project/domain/errors/project.errors.ts diff --git a/src/projects/domain/policy/index.ts b/src/project/domain/policy/index.ts similarity index 100% rename from src/projects/domain/policy/index.ts rename to src/project/domain/policy/index.ts diff --git a/src/projects/domain/policy/project-access.policy.ts b/src/project/domain/policy/project-access.policy.ts similarity index 100% rename from src/projects/domain/policy/project-access.policy.ts rename to src/project/domain/policy/project-access.policy.ts diff --git a/src/projects/domain/repository/index.ts b/src/project/domain/repository/index.ts similarity index 100% rename from src/projects/domain/repository/index.ts rename to src/project/domain/repository/index.ts diff --git a/src/projects/domain/repository/member.repository.interface.ts b/src/project/domain/repository/member.repository.interface.ts similarity index 100% rename from src/projects/domain/repository/member.repository.interface.ts rename to src/project/domain/repository/member.repository.interface.ts diff --git a/src/projects/domain/repository/project.repository.interface.ts b/src/project/domain/repository/project.repository.interface.ts similarity index 100% rename from src/projects/domain/repository/project.repository.interface.ts rename to src/project/domain/repository/project.repository.interface.ts diff --git a/src/projects/index.ts b/src/project/index.ts similarity index 56% rename from src/projects/index.ts rename to src/project/index.ts index c402167..592dbc6 100644 --- a/src/projects/index.ts +++ b/src/project/index.ts @@ -1,2 +1,2 @@ -export { ProjectsModule } from './projects.module'; +export { ProjectModule } from './project.module'; export { FindProjectQuery } from './application/use-cases/project'; diff --git a/src/projects/infrastructure/constants/index.ts b/src/project/infrastructure/constants/index.ts similarity index 100% rename from src/projects/infrastructure/constants/index.ts rename to src/project/infrastructure/constants/index.ts diff --git a/src/projects/infrastructure/persistence/models/enum.ts b/src/project/infrastructure/persistence/models/enum.ts similarity index 91% rename from src/projects/infrastructure/persistence/models/enum.ts rename to src/project/infrastructure/persistence/models/enum.ts index c03295f..88167de 100644 --- a/src/projects/infrastructure/persistence/models/enum.ts +++ b/src/project/infrastructure/persistence/models/enum.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/projects/domain/entities'; +import { LAYOUTS, PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/project/domain/entities'; import { baseSchema } from '@shared/entities'; export const projectStatusEnum = baseSchema.enum('project_status', PROJECT_STATUSES); diff --git a/src/projects/infrastructure/persistence/models/index.ts b/src/project/infrastructure/persistence/models/index.ts similarity index 100% rename from src/projects/infrastructure/persistence/models/index.ts rename to src/project/infrastructure/persistence/models/index.ts diff --git a/src/projects/infrastructure/persistence/models/member.model.ts b/src/project/infrastructure/persistence/models/member.model.ts similarity index 100% rename from src/projects/infrastructure/persistence/models/member.model.ts rename to src/project/infrastructure/persistence/models/member.model.ts diff --git a/src/projects/infrastructure/persistence/models/project.model.ts b/src/project/infrastructure/persistence/models/project.model.ts similarity index 100% rename from src/projects/infrastructure/persistence/models/project.model.ts rename to src/project/infrastructure/persistence/models/project.model.ts diff --git a/src/projects/infrastructure/persistence/repositories/index.ts b/src/project/infrastructure/persistence/repositories/index.ts similarity index 100% rename from src/projects/infrastructure/persistence/repositories/index.ts rename to src/project/infrastructure/persistence/repositories/index.ts diff --git a/src/projects/infrastructure/persistence/repositories/member.repository.ts b/src/project/infrastructure/persistence/repositories/member.repository.ts similarity index 96% rename from src/projects/infrastructure/persistence/repositories/member.repository.ts rename to src/project/infrastructure/persistence/repositories/member.repository.ts index 4ee6bc4..bb5740c 100644 --- a/src/projects/infrastructure/persistence/repositories/member.repository.ts +++ b/src/project/infrastructure/persistence/repositories/member.repository.ts @@ -1,11 +1,11 @@ -import { IMemberRepository } from '@core/projects/domain/repository'; +import { IMemberRepository } from '@core/project/domain/repository'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject, Injectable } from '@nestjs/common'; import { and, eq, sql } from 'drizzle-orm'; import * as schema from '../models'; -import type { MemberRole } from '@core/projects/domain/entities'; +import type { MemberRole } from '@core/project/domain/entities'; @Injectable() export class MemberRepository implements IMemberRepository { diff --git a/src/projects/infrastructure/persistence/repositories/project.repository.ts b/src/project/infrastructure/persistence/repositories/project.repository.ts similarity index 98% rename from src/projects/infrastructure/persistence/repositories/project.repository.ts rename to src/project/infrastructure/persistence/repositories/project.repository.ts index 92395b7..6d84c14 100644 --- a/src/projects/infrastructure/persistence/repositories/project.repository.ts +++ b/src/project/infrastructure/persistence/repositories/project.repository.ts @@ -5,7 +5,7 @@ import { and, count, eq, gt, isNull, or } from 'drizzle-orm'; import { IProjectRepository } from '../../../domain/repository'; import * as schema from '../models'; -import type { NewProject, NewProjectShare } from '@core/projects/domain/entities'; +import type { NewProject, NewProjectShare } from '@core/project/domain/entities'; @Injectable() export class ProjectRepository implements IProjectRepository { diff --git a/src/projects/projects.module.ts b/src/project/project.module.ts similarity index 88% rename from src/projects/projects.module.ts rename to src/project/project.module.ts index 3ad18d3..6fbd286 100644 --- a/src/projects/projects.module.ts +++ b/src/project/project.module.ts @@ -2,7 +2,7 @@ import { TeamsModule } from '@core/teams'; import { UserModule } from '@core/user'; import { forwardRef, Module } from '@nestjs/common'; -import { CONTROLLERS } from './application/controller'; +import { CONTROLLERS } from './application/controllers'; import { ProjectFacade } from './application/project.facade'; import { CreateProjectUseCase, FindProjectQuery, USE_CASES } from './application/use-cases'; import { POLICIES, ProjectAccessPolicy } from './domain/policy'; @@ -14,4 +14,4 @@ import { REPOSITORIES } from './infrastructure/persistence/repositories'; providers: [...REPOSITORIES, ...POLICIES, ...USE_CASES, ProjectFacade], exports: [FindProjectQuery, ProjectAccessPolicy, CreateProjectUseCase], }) -export class ProjectsModule {} +export class ProjectModule {} diff --git a/src/shared/constants/file.constants.ts b/src/shared/constants/file.constants.ts deleted file mode 100644 index be950f2..0000000 --- a/src/shared/constants/file.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg']; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 8a4ea9d..27c8844 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -1,2 +1 @@ -export * from './file.constants'; export * from './roles.constant'; diff --git a/src/shared/dtos/index.ts b/src/shared/dtos/index.ts deleted file mode 100644 index 5a8e94b..0000000 --- a/src/shared/dtos/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './pagination.dto'; -export * from './response.dto'; diff --git a/src/shared/dtos/pagination.dto.ts b/src/shared/dtos/pagination.dto.ts deleted file mode 100644 index 7636a1b..0000000 --- a/src/shared/dtos/pagination.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createZodDto } from 'nestjs-zod'; -import { z } from 'zod/v4'; - -export const PaginationSchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(20), -}); - -export class PaginationDto extends createZodDto(PaginationSchema) {} diff --git a/src/shared/entities/index.ts b/src/shared/entities/index.ts index f6b52ef..b357898 100644 --- a/src/shared/entities/index.ts +++ b/src/shared/entities/index.ts @@ -2,5 +2,5 @@ export { baseSchema } from './schema'; export * from '../../user/infrastructure/persistence/models'; export * from '../../auth/infrastructure/persistence/models'; export * from '../../teams/infrastructure/persistence/models'; -export * from '../../projects/infrastructure/persistence/models'; +export * from '../../project/infrastructure/persistence/models'; export * from '../../area/infrastructure/persistence/models'; diff --git a/src/shared/error/exception.ts b/src/shared/error/exception.ts index 588b638..c484f61 100644 --- a/src/shared/error/exception.ts +++ b/src/shared/error/exception.ts @@ -2,13 +2,14 @@ import { HttpException, type HttpStatus } from '@nestjs/common'; interface IDetailsOptions { readonly target?: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ readonly [key: string]: any; } export interface IErrorOptions { - readonly code: string; - readonly message: string; - readonly details?: readonly IDetailsOptions[]; + code: string; + message: string; + details?: IDetailsOptions[]; } export class BaseException extends HttpException { diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index 0ae8b90..0450fea 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -157,7 +157,7 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); }; - private handleUnknownError(exception: any, host: ArgumentsHost) { + private handleUnknownError(exception: unknown, host: ArgumentsHost) { const { request, response } = this.getCtxBase(host); const status = HttpStatus.INTERNAL_SERVER_ERROR; @@ -168,7 +168,6 @@ export class GlobalExceptionFilter implements ExceptionFilter { code: 'INTERNAL_SERVER_ERROR', message: 'Произошла непредвиденная ошибка на сервере', details: [], - stack: exception?.stack, }), ); } @@ -177,11 +176,11 @@ export class GlobalExceptionFilter implements ExceptionFilter { request: FastifyRequest, status: number, data: { - readonly code: string; - readonly message: string; - readonly details: readonly any[]; - readonly stack?: string; - readonly service?: string; + code: string; + message: string; + details: Record[] | undefined | null; + stack?: string; + service?: string; }, ) { const requestId = request.id ?? request.headers['x-request-id']; @@ -221,13 +220,22 @@ export class GlobalExceptionFilter implements ExceptionFilter { } private log( - exception: any, + exception: unknown, host: ArgumentsHost, status: number, extraData: Record = {}, ) { const { request } = this.getCtxBase(host); + const hasMessage = (err: unknown): err is { message: string } => + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string'; + + const stack = exception instanceof Error ? exception.stack : undefined; + const errMessage = hasMessage(exception) ? exception.message : 'Unknown Error'; + const logData = { request_id: request.id || request.headers['x-request-id'] || 'unknown', triggered_by: 'filter_exception', @@ -240,11 +248,11 @@ export class GlobalExceptionFilter implements ExceptionFilter { user_agent: request.headers['user-agent'] || 'unknown', controller: 'Unknown', handler: 'Unknown', - stack: exception instanceof Error ? exception.stack : undefined, + stack, error_details: extraData, }; - const message = `Exception Filter: ${logData.method} ${logData.path} | ${status} | ${exception?.message || 'Unknown Error'}`; + const message = `Exception Filter: ${logData.method} ${logData.path} | ${status} | ${errMessage}`; if (status >= 500) { this.logger.error(message, logData); diff --git a/src/shared/error/swagger.ts b/src/shared/error/swagger.ts index 4ca24cf..71be846 100644 --- a/src/shared/error/swagger.ts +++ b/src/shared/error/swagger.ts @@ -3,15 +3,13 @@ import { ApiResponse, getSchemaPath } from '@nestjs/swagger'; import { GlobalErrorResponse } from './schema'; +type TDetails = { field: string; message: string; code: string }[]; + export const ApiErrorResponse = ( status: number, bizCode: string, description: string, - details?: readonly { - readonly field: string; - readonly message: string; - readonly code: string; - }[], + details: TDetails = [], ) => ApiResponse({ status, @@ -54,7 +52,7 @@ export const ApiNotFound = (description: string = 'Ресурс не найде export const ApiValidationError = ( description: string = 'Ошибка валидации входных данных', - fields: readonly any[] = [], + fields: TDetails = [], ) => applyDecorators(ApiErrorResponse(400, 'VALIDATION_FAILED', description, fields)); export const ApiConflict = (description: string = 'Ресурс уже существует') => @@ -63,7 +61,7 @@ export const ApiConflict = (description: string = 'Ресурс уже суще export const ApiTooManyRequests = (description: string = 'Слишком много попыток') => applyDecorators(ApiErrorResponse(429, 'TOO_MANY_REQUESTS', description)); -export const DATABASE_ERRORS: Record = { +export const DATABASE_ERRORS: Record = { '23505': { code: 409, msg: 'Запись с таким значением уже существует (дубликат).' }, '23503': { code: 409, msg: 'Ошибка внешнего ключа: связанная запись не найдена.' }, '22P02': { code: 400, msg: 'Неверный формат данных (например, некорректный UUID).' }, diff --git a/src/shared/interceptors/http-metrics.interceptor.ts b/src/shared/interceptors/http-metrics.interceptor.ts index 7b047da..ea1af63 100644 --- a/src/shared/interceptors/http-metrics.interceptor.ts +++ b/src/shared/interceptors/http-metrics.interceptor.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; import { Histogram } from 'prom-client'; -import { Observable, throwError } from 'rxjs'; +import { throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import type { FastifyReply, FastifyRequest } from 'fastify'; @@ -18,7 +18,7 @@ export class HttpMetricsInterceptor implements NestInterceptor { private readonly histogram: Histogram, ) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + intercept(context: ExecutionContext, next: CallHandler) { const ctx = context.switchToHttp(); const request = ctx.getRequest(); const response = ctx.getResponse(); diff --git a/src/shared/interceptors/zod-validation.interceptor.ts b/src/shared/interceptors/zod-validation.interceptor.ts index 6e11740..f6f20d3 100644 --- a/src/shared/interceptors/zod-validation.interceptor.ts +++ b/src/shared/interceptors/zod-validation.interceptor.ts @@ -19,7 +19,7 @@ export class ZodValidationInterceptor implements NestInterceptor): Observable { const handler = context.getHandler(); - const metadata = this.reflector.get<{ readonly schema: z.ZodTypeAny } | undefined>( + const metadata = this.reflector.get<{ schema: z.ZodTypeAny } | undefined>( ZOD_RESPONSE_TOKEN, handler, ); diff --git a/src/shared/media/index.ts b/src/shared/media/index.ts deleted file mode 100644 index 9e463f3..0000000 --- a/src/shared/media/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { MediaModule } from './media.module'; -export * from './interfaces/media.interface'; -export * from './media.constant'; diff --git a/src/shared/media/strategies/media.strategy.ts b/src/shared/media/strategies/media.strategy.ts deleted file mode 100644 index c94c1ab..0000000 --- a/src/shared/media/strategies/media.strategy.ts +++ /dev/null @@ -1,4 +0,0 @@ -export abstract class MediaDispatchStrategy { - abstract readonly jobName: string; - abstract createPayload(dto: any, userId: string, path: string): any; -} diff --git a/src/shared/schemas/index.ts b/src/shared/schemas/index.ts index 8b946b2..054c824 100644 --- a/src/shared/schemas/index.ts +++ b/src/shared/schemas/index.ts @@ -3,3 +3,4 @@ export * from './avatar-response.schema'; export * from './datetime.schema'; export * from './search.schema'; export * from './sorting.schema'; +export * from './response.schema'; diff --git a/src/shared/schemas/pagination.schema.ts b/src/shared/schemas/pagination.schema.ts index 5fb6361..53006cb 100644 --- a/src/shared/schemas/pagination.schema.ts +++ b/src/shared/schemas/pagination.schema.ts @@ -1,3 +1,4 @@ +import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; export const PaginationBaseSchema = z.object({ @@ -63,3 +64,5 @@ export const createPaginationSchema = (itemSchema: T) => items: z.array(itemSchema), meta: paginationResponseSchema, }); + +export class PaginationQuery extends createZodDto(PaginationSchema) {} diff --git a/src/shared/dtos/response.dto.ts b/src/shared/schemas/response.schema.ts similarity index 100% rename from src/shared/dtos/response.dto.ts rename to src/shared/schemas/response.schema.ts diff --git a/src/shared/schemas/sorting.schema.ts b/src/shared/schemas/sorting.schema.ts index cff59b2..6737fe3 100644 --- a/src/shared/schemas/sorting.schema.ts +++ b/src/shared/schemas/sorting.schema.ts @@ -16,6 +16,7 @@ export const createSortingSchema = this.updateInvitationUc.execute(teamId, code, userId, dto); - public getMyTeams = (userId: string, pagination: any) => - this.getMyTeamsUc.execute(userId, pagination); + public getMyTeams = (userId: string) => this.getMyTeamsUc.execute(userId); public getMyInvites = (email: string) => this.getMyInvitesUc.execute(email); } diff --git a/src/teams/application/use-cases/base/create-team.use-case.ts b/src/teams/application/use-cases/base/create-team.use-case.ts index 4fb0b55..dec55f5 100644 --- a/src/teams/application/use-cases/base/create-team.use-case.ts +++ b/src/teams/application/use-cases/base/create-team.use-case.ts @@ -28,7 +28,6 @@ export class CreateTeamUseCase { { code: 'TEAM_CREATE_FAILED', message: 'Не удалось создать команду', - details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/teams/application/use-cases/base/delete-team.use-case.ts b/src/teams/application/use-cases/base/delete-team.use-case.ts index f0cb9a0..c56459b 100644 --- a/src/teams/application/use-cases/base/delete-team.use-case.ts +++ b/src/teams/application/use-cases/base/delete-team.use-case.ts @@ -51,7 +51,6 @@ export class DeleteTeamUseCase { { code: 'TEAM_DELETE_FAILED', message: 'Не удалось удалить команду', - details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/teams/application/use-cases/base/get-my-teams.use-case.ts b/src/teams/application/use-cases/base/get-my-teams.use-case.ts index 6ed57a5..96d1b49 100644 --- a/src/teams/application/use-cases/base/get-my-teams.use-case.ts +++ b/src/teams/application/use-cases/base/get-my-teams.use-case.ts @@ -11,8 +11,8 @@ export class GetMyTeamsUseCase { private readonly cfg: ConfigService, ) {} - async execute(userId: string, pagination: Record) { - const teams = await this.teamsRepo.findByUser(userId, pagination); + async execute(userId: string) { + const teams = await this.teamsRepo.findByUser(userId); const cdn = this.getCdnBaseUrl(); return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); diff --git a/src/teams/application/use-cases/base/update-team.use-case.ts b/src/teams/application/use-cases/base/update-team.use-case.ts index 2cdd00b..53c78e0 100644 --- a/src/teams/application/use-cases/base/update-team.use-case.ts +++ b/src/teams/application/use-cases/base/update-team.use-case.ts @@ -54,7 +54,6 @@ export class UpdateTeamUseCase { { code: 'TEAM_UPDATE_FAILED', message: 'Ошибка при обновлении данных команды', - details: [{ reason: error instanceof Error ? error.message : 'Unknown error' }], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/teams/domain/repository/teams.repository.interface.ts b/src/teams/domain/repository/teams.repository.interface.ts index b7dee49..aa958cf 100644 --- a/src/teams/domain/repository/teams.repository.interface.ts +++ b/src/teams/domain/repository/teams.repository.interface.ts @@ -29,13 +29,9 @@ export interface ITeamsRepository { remove(id: string, userId: string): Promise; findMember(teamId: string, userId: string): Promise; - findMembers(teamId: string): Promise; + findMembers(teamId: string): Promise; findById(teamId: string): Promise; - findByUser( - userId: string, - // TODO: ADD ZOD QUERY - pagination: { readonly search?: string; readonly limit?: number; readonly offset?: number }, - ): Promise; + findByUser(userId: string): Promise; updateTeamAvatar(teamId: string, url: string): Promise; updateTeamBanner(teamId: string, url: string): Promise; @@ -44,7 +40,7 @@ export interface ITeamsRepository { updateMember( teamId: string, userId: string, - dto: { readonly role?: string; readonly status?: string }, + dto: { role?: string; status?: string }, ): Promise; removeMember(teamId: string, userId: string): Promise; } diff --git a/src/teams/infrastructure/listeners/update-media.listener.ts b/src/teams/infrastructure/listeners/update-media.listener.ts index fb53079..b3d9247 100644 --- a/src/teams/infrastructure/listeners/update-media.listener.ts +++ b/src/teams/infrastructure/listeners/update-media.listener.ts @@ -1,8 +1,8 @@ +import { MEDIA_JOBS, MEDIA_QUEUES, UpdateMediaTeam } from '@core/media'; import { TeamMemberPolicy } from '@core/teams/domain/policy'; import { ITeamsRepository } from '@core/teams/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; -import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaTeam } from '@shared/media'; import { type Job, UnrecoverableError } from 'bullmq'; import type { TeamRole } from '@shared/entities'; diff --git a/src/teams/infrastructure/persistence/repositories/teams.repository.ts b/src/teams/infrastructure/persistence/repositories/teams.repository.ts index 0c3ed2b..cc11935 100644 --- a/src/teams/infrastructure/persistence/repositories/teams.repository.ts +++ b/src/teams/infrastructure/persistence/repositories/teams.repository.ts @@ -2,7 +2,7 @@ import { ITeamsRepository } from '@core/teams/domain/repository'; import * as scUsers from '@core/user/infrastructure/persistence/models'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject } from '@nestjs/common'; -import { and, desc, eq, ilike, isNull } from 'drizzle-orm'; +import { and, desc, eq, isNull } from 'drizzle-orm'; import * as schema from '../models'; @@ -14,7 +14,7 @@ export class TeamsRepository implements ITeamsRepository { private readonly db: DatabaseService, ) {} - public readonly addMember = async (dto: NewTeamMember) => { + public addMember = async (dto: NewTeamMember) => { const result = await this.db .insert(schema.teamMembers) .values(dto) @@ -25,7 +25,7 @@ export class TeamsRepository implements ITeamsRepository { return (result?.count ?? 0) > 0; }; - public readonly create = async (ownerId: string, dto: NewTeam) => + public create = async (ownerId: string, dto: NewTeam) => this.db.transaction(async (tx) => { const [team] = await tx .insert(schema.teams) @@ -50,7 +50,7 @@ export class TeamsRepository implements ITeamsRepository { }; }); - public readonly update = async (id: string, dto: Partial) => + public update = async (id: string, dto: Partial) => this.db.transaction(async (tx) => { const [team] = await tx .update(schema.teams) @@ -68,7 +68,7 @@ export class TeamsRepository implements ITeamsRepository { }; }); - public readonly remove = async (teamId: string, userId: string) => { + public remove = async (teamId: string, userId: string) => { const result = await this.db .update(schema.teams) .set({ @@ -79,7 +79,7 @@ export class TeamsRepository implements ITeamsRepository { return (result?.count ?? 0) > 0; }; - public readonly findMember = async (teamId: string, userId: string) => { + public findMember = async (teamId: string, userId: string) => { const [member] = await this.membersQuery.where( and(eq(schema.teamMembers.teamId, teamId), eq(schema.teamMembers.userId, userId)), ); @@ -87,27 +87,18 @@ export class TeamsRepository implements ITeamsRepository { return member || null; }; - public readonly findMembers = async (teamId: string) => + public findMembers = async (teamId: string) => this.membersQuery .where(eq(schema.teamMembers.teamId, teamId)) .orderBy(desc(schema.teamMembers.joinedAt)); - public readonly findByUser = async ( - userId: string, - pagination: { readonly search?: string; readonly limit?: number; readonly offset?: number }, - ) => { - const { search, limit = 10, offset = 0 } = pagination; - + public findByUser = async (userId: string) => { const filters = [ eq(schema.teamMembers.userId, userId), eq(schema.teamMembers.status, 'active'), isNull(schema.teams.deletedAt), ]; - if (search) { - filters.push(ilike(schema.teams.name, `%${search}%`)); - } - const query = this.db .select({ id: schema.teams.id, @@ -120,14 +111,12 @@ export class TeamsRepository implements ITeamsRepository { .from(schema.teamMembers) .innerJoin(schema.teams, eq(schema.teams.id, schema.teamMembers.teamId)) .where(and(...filters)) - .orderBy(desc(schema.teamMembers.joinedAt)) - .limit(limit) - .offset(offset); + .orderBy(desc(schema.teamMembers.joinedAt)); return query; }; - public readonly findById = async (teamId: string) => { + public findById = async (teamId: string) => { const [team] = await this.db.select().from(schema.teams).where(eq(schema.teams.id, teamId)); if (!team) { return null; @@ -135,7 +124,7 @@ export class TeamsRepository implements ITeamsRepository { return team; }; - public readonly removeMember = async (teamId: string, userId: string) => { + public removeMember = async (teamId: string, userId: string) => { const result = await this.db .delete(schema.teamMembers) .where( @@ -145,11 +134,7 @@ export class TeamsRepository implements ITeamsRepository { return (result?.count ?? 0) > 0; }; - public readonly updateMember = async ( - teamId: string, - userId: string, - dto: Partial, - ) => { + public updateMember = async (teamId: string, userId: string, dto: Partial) => { const { role, status } = dto; const data = { diff --git a/src/user/application/controller/index.ts b/src/user/application/controllers/index.ts similarity index 100% rename from src/user/application/controller/index.ts rename to src/user/application/controllers/index.ts diff --git a/src/user/application/controller/settings/controller.ts b/src/user/application/controllers/settings/controller.ts similarity index 100% rename from src/user/application/controller/settings/controller.ts rename to src/user/application/controllers/settings/controller.ts diff --git a/src/user/application/controller/settings/swagger.ts b/src/user/application/controllers/settings/swagger.ts similarity index 95% rename from src/user/application/controller/settings/swagger.ts rename to src/user/application/controllers/settings/swagger.ts index 4a478df..642b0d5 100644 --- a/src/user/application/controller/settings/swagger.ts +++ b/src/user/application/controllers/settings/swagger.ts @@ -1,8 +1,8 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { UpdateNotificationsDto } from '../../dtos'; diff --git a/src/user/application/controller/user/controller.ts b/src/user/application/controllers/user/controller.ts similarity index 80% rename from src/user/application/controller/user/controller.ts rename to src/user/application/controllers/user/controller.ts index ee47c00..a99f061 100644 --- a/src/user/application/controller/user/controller.ts +++ b/src/user/application/controllers/user/controller.ts @@ -1,6 +1,6 @@ import { Body, Get, Patch, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { PaginationDto } from '@shared/dtos'; +import { PaginationQuery } from '@shared/schemas'; import { UpdateProfileDto } from '../../dtos'; import { UserFacade } from '../../user.facade'; @@ -25,7 +25,7 @@ export class UserController { @Get('activity') @GetMeActivitySwagger() - async getActivity(@Query() query: PaginationDto, @GetUserId() id: string) { - return this.facade.getActivity(id, query.page, query.limit); + async getActivity(@Query() query: PaginationQuery, @GetUserId() id: string) { + return this.facade.getActivity(id, query); } } diff --git a/src/user/application/controller/user/swagger.ts b/src/user/application/controllers/user/swagger.ts similarity index 98% rename from src/user/application/controller/user/swagger.ts rename to src/user/application/controllers/user/swagger.ts index 922411d..eb97b69 100644 --- a/src/user/application/controller/user/swagger.ts +++ b/src/user/application/controllers/user/swagger.ts @@ -1,8 +1,8 @@ import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiExtraModels, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger'; -import { ActionResponse } from '@shared/dtos'; import { ApiUnauthorized, ApiValidationError } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; +import { ActionResponse } from '@shared/schemas'; import { UpdateProfileDto, UserActivityResponse, UserResponse } from '../../dtos'; diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts index 383176d..49f33e7 100644 --- a/src/user/application/use-cases/find-profile.query.ts +++ b/src/user/application/use-cases/find-profile.query.ts @@ -4,11 +4,12 @@ import { ConfigService } from '@nestjs/config'; import { BaseException } from '@shared/error'; import { ImageHelper } from '@shared/utils'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + @Injectable() export class FindProfileQuery { constructor( - @Inject('IUserRepository') - private readonly userRepo: IUserRepository, + @Inject('IUserRepository') private readonly userRepo: IUserRepository, private readonly cfg: ConfigService, ) {} @@ -17,7 +18,10 @@ export class FindProfileQuery { if (!entity?.user) { throw new BaseException( - { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, HttpStatus.NOT_FOUND, ); } diff --git a/src/user/application/use-cases/find-user.query.ts b/src/user/application/use-cases/find-user.query.ts index cb4b06f..7ce055d 100644 --- a/src/user/application/use-cases/find-user.query.ts +++ b/src/user/application/use-cases/find-user.query.ts @@ -2,6 +2,15 @@ import { IUserRepository } from '@core/user/domain/repository'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + +import type { UserWithSecurity } from '../../domain/entities'; + +type TParams = { + email?: string; + id?: string; +}; + @Injectable() export class FindUserQuery { constructor( @@ -9,20 +18,58 @@ export class FindUserQuery { private readonly repository: IUserRepository, ) {} - async execute(params: { email?: string; id?: string }) { - if (params.email) { - return this.repository.findByEmail(params.email); + async execute( + params: TParams, + options?: { throwIfNotFound?: true; throwIfExists?: false }, + ): Promise; + async execute( + params: TParams, + options: { throwIfNotFound: false; throwIfExists?: boolean }, + ): Promise; + async execute( + params: TParams, + options: { throwIfExists: true; throwIfNotFound?: boolean }, + ): Promise; + async execute( + params: TParams, + options?: { throwIfNotFound?: boolean; throwIfExists?: boolean }, + ): Promise { + const { throwIfNotFound = true, throwIfExists = false } = options || {}; + + if (!params.email && !params.id) { + throw new BaseException( + { + code: UserErrorCodes.QUERY_PARAMS_MISSING, + message: UserErrorMessages[UserErrorCodes.QUERY_PARAMS_MISSING], + }, + HttpStatus.BAD_REQUEST, + ); } - if (params.id) { - return this.repository.findById(params.id); + + const result = params.email + ? await this.repository.findByEmail(params.email) + : await this.repository.findById(params.id!); + + if (!result && throwIfNotFound) { + throw new BaseException( + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (result && throwIfExists) { + throw new BaseException( + { + code: UserErrorCodes.ALREADY_EXISTS, + message: UserErrorMessages[UserErrorCodes.ALREADY_EXISTS], + }, + HttpStatus.CONFLICT, + ); } - throw new BaseException( - { - code: 'QUERY_PARAMS_MISSING', - message: 'Не указаны параметры поиска', - }, - HttpStatus.BAD_REQUEST, - ); + return result; } } diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts index f6012d5..4439e68 100644 --- a/src/user/application/use-cases/get-activity.query.ts +++ b/src/user/application/use-cases/get-activity.query.ts @@ -1,5 +1,6 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; +import { PaginationQuery } from '@shared/schemas'; @Injectable() export class GetActivityQuery { @@ -8,7 +9,9 @@ export class GetActivityQuery { private readonly userRepo: IUserRepository, ) {} - async execute(id: string, page: number, limit: number) { + async execute(id: string, query: PaginationQuery) { + const { limit, page } = query; + const safeLimit = Math.min(limit, 50); const offset = (page - 1) * safeLimit; diff --git a/src/user/application/use-cases/index.ts b/src/user/application/use-cases/index.ts index 53a5e6f..76ba9b7 100644 --- a/src/user/application/use-cases/index.ts +++ b/src/user/application/use-cases/index.ts @@ -6,13 +6,11 @@ import { RegisterUserUseCase } from './register-user.use-case'; import { UpdateNotificationsUseCase } from './update-notifications.use-case'; import { UpdatePasswordUseCase } from './update-password.use-case'; import { UpdateProfileUseCase } from './update-profile.use-case'; -import { UploadAvatarUseCase } from './upload-avatar.use-case'; export * from './register-user.use-case'; export * from './update-notifications.use-case'; export * from './update-password.use-case'; export * from './update-profile.use-case'; -export * from './upload-avatar.use-case'; export * from './find-profile.query'; export * from './find-user.query'; @@ -24,7 +22,6 @@ export const UserUseCases = [ UpdateNotificationsUseCase, UpdatePasswordUseCase, UpdateProfileUseCase, - UploadAvatarUseCase, ]; export const UserQueries = [FindProfileQuery, FindByIdsQuery, FindUserQuery, GetActivityQuery]; diff --git a/src/user/application/use-cases/register-user.use-case.ts b/src/user/application/use-cases/register-user.use-case.ts index ba5ee61..9dd931e 100644 --- a/src/user/application/use-cases/register-user.use-case.ts +++ b/src/user/application/use-cases/register-user.use-case.ts @@ -3,6 +3,8 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; import { BaseException } from '@shared/error'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + import type { NewUser } from '@core/user/domain/entities'; @Injectable() @@ -12,14 +14,14 @@ export class RegisterUserUseCase { private readonly repository: IUserRepository, ) {} - async execute(dto: NewUser & { readonly password: string | null }) { + async execute(dto: NewUser & { password: string | null }) { const existingUser = await this.repository.findByEmail(dto.email); if (existingUser?.user) { throw new BaseException( { - code: 'USER_ALREADY_EXISTS', - message: `Пользователь с email ${dto.email} уже зарегистрирован`, + code: UserErrorCodes.ALREADY_EXISTS, + message: UserErrorMessages[UserErrorCodes.ALREADY_EXISTS], details: [{ target: 'email', value: dto.email }], }, HttpStatus.CONFLICT, @@ -48,9 +50,8 @@ export class RegisterUserUseCase { throw new BaseException( { - code: 'USER_REGISTRATION_FAILED', - message: 'Не удалось завершить регистрацию', - details: [{ reason: error instanceof Error ? error.message : 'DB error' }], + code: UserErrorCodes.CREATE_FAILED, + message: UserErrorMessages[UserErrorCodes.CREATE_FAILED], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/user/application/use-cases/update-notifications.use-case.ts b/src/user/application/use-cases/update-notifications.use-case.ts index a47e539..6090158 100644 --- a/src/user/application/use-cases/update-notifications.use-case.ts +++ b/src/user/application/use-cases/update-notifications.use-case.ts @@ -4,6 +4,7 @@ import { createId } from '@paralleldrive/cuid2'; import { BaseException } from '@shared/error'; import { removeUndefined } from '@shared/utils'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; import { UpdateNotificationsDto } from '../dtos'; @Injectable() @@ -18,28 +19,22 @@ export class UpdateNotificationsUseCase { if (!user) { throw new BaseException( - { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, HttpStatus.NOT_FOUND, ); } try { - const isUpdated = await this.userRepo.updateNotifications( + const result = await this.userRepo.updateNotifications( id, removeUndefined({ email: dto.email, push: dto.push, }), ); - if (!isUpdated) { - throw new BaseException( - { - code: 'NOTIFICATIONS_UPDATE_FAILED', - message: 'Не удалось обновить настройки уведомлений', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } await this.userRepo.logActivity({ id: createId(), @@ -48,7 +43,7 @@ export class UpdateNotificationsUseCase { }); return { - success: true, + success: result, message: 'Настройки уведомлений обновлены', }; } catch (error) { @@ -58,13 +53,8 @@ export class UpdateNotificationsUseCase { throw new BaseException( { - code: 'USER_SETTINGS_ERROR', - message: 'Ошибка при сохранении настроек пользователя', - details: [ - { - reason: error instanceof Error ? error.message : 'Database error', - }, - ], + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/user/application/use-cases/update-password.use-case.ts b/src/user/application/use-cases/update-password.use-case.ts index c718ae3..c9aa6fd 100644 --- a/src/user/application/use-cases/update-password.use-case.ts +++ b/src/user/application/use-cases/update-password.use-case.ts @@ -2,6 +2,8 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Injectable, Inject, HttpStatus } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; + @Injectable() export class UpdatePasswordUseCase { constructor( @@ -15,33 +17,24 @@ export class UpdatePasswordUseCase { if (!result?.user) { throw new BaseException( { - code: 'USER_NOT_FOUND', - message: 'Пользователь для обновления пароля не найден', + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], }, HttpStatus.NOT_FOUND, ); } try { - const isUpdated = await this.repository.updatePasswordHash(result.user.id, password); - - if (!isUpdated) { - throw new BaseException( - { - code: 'PASSWORD_UPDATE_FAILED', - message: 'Запись не была изменена', - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); + return this.repository.updatePasswordHash(result.user.id, password); + } catch (error) { + if (error instanceof BaseException) { + throw error; } - return isUpdated; - } catch (error) { throw new BaseException( { - code: 'DATABASE_ERROR', - message: 'Ошибка при работе с БД', - details: [{ reason: error instanceof Error ? error.message : 'Unknown' }], + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], }, HttpStatus.INTERNAL_SERVER_ERROR, ); diff --git a/src/user/application/use-cases/update-profile.use-case.ts b/src/user/application/use-cases/update-profile.use-case.ts index d2dd35e..a1c2302 100644 --- a/src/user/application/use-cases/update-profile.use-case.ts +++ b/src/user/application/use-cases/update-profile.use-case.ts @@ -4,6 +4,7 @@ import { createId } from '@paralleldrive/cuid2'; import { BaseException } from '@shared/error'; import { removeUndefined } from '@shared/utils'; +import { UserErrorCodes, UserErrorMessages } from '../../domain/errors'; import { UpdateProfileDto } from '../dtos'; @Injectable() @@ -18,7 +19,10 @@ export class UpdateProfileUseCase { if (!entity?.user) { throw new BaseException( - { code: 'USER_NOT_FOUND', message: 'Пользователь не найден' }, + { + code: UserErrorCodes.NOT_FOUND, + message: UserErrorMessages[UserErrorCodes.NOT_FOUND], + }, HttpStatus.NOT_FOUND, ); } @@ -33,34 +37,41 @@ export class UpdateProfileUseCase { theme, }; - const isUpdated = await this.userRepo.updateProfile( - entity.user.id, - removeUndefined(profile), - preferences, - ); + try { + const result = await this.userRepo.updateProfile( + entity.user.id, + removeUndefined(profile), + preferences, + ); + + await this.userRepo.logActivity({ + id: createId(), + userId: id, + eventType: 'PROFILE_UPDATED', + }); + + return { success: result, message: 'Профиль успешно обновлен' }; + } catch (error) { + if (error instanceof BaseException) { + throw error; + } - if (!isUpdated) { throw new BaseException( - { code: 'PROFILE_UPDATE_FAILED', message: 'Не удалось обновить данные' }, + { + code: UserErrorCodes.UPDATE_FAILED, + message: UserErrorMessages[UserErrorCodes.UPDATE_FAILED], + }, HttpStatus.INTERNAL_SERVER_ERROR, ); } - - await this.userRepo.logActivity({ - id: createId(), - userId: id, - eventType: 'PROFILE_UPDATED', - }); - - return { success: true, message: 'Профиль успешно обновлен' }; } private validatePronouns(dto: UpdateProfileDto) { if (dto.pronouns === 'other' && (!dto.pronounsCustom || dto.pronounsCustom.trim() === '')) { throw new BaseException( { - code: 'PRONOUNS_CUSTOM_REQUIRED', - message: 'Пожалуйста, укажите пользовательские местоимения', + code: UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED, + message: UserErrorMessages[UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED], }, HttpStatus.BAD_REQUEST, ); @@ -69,8 +80,8 @@ export class UpdateProfileUseCase { if (dto.pronounsCustom && dto.pronounsCustom.length > 50) { throw new BaseException( { - code: 'PRONOUNS_CUSTOM_TOO_LONG', - message: 'Пользовательские местоимения не могут превышать 50 символов', + code: UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG, + message: UserErrorMessages[UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG], }, HttpStatus.BAD_REQUEST, ); diff --git a/src/user/application/use-cases/upload-avatar.use-case.ts b/src/user/application/use-cases/upload-avatar.use-case.ts deleted file mode 100644 index 8037f75..0000000 --- a/src/user/application/use-cases/upload-avatar.use-case.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { IUserRepository } from '@core/user/domain/repository'; -import { Inject, Injectable } from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; - -@Injectable() -export class UploadAvatarUseCase { - constructor( - @Inject('IUserRepository') - private readonly userRepo: IUserRepository, - ) {} - - async execute(userId: string) { - // const result = await this.mediaService.uploadUserAvatar(userId, fileDto); - - // const [sm, md, lg] = await Promise.all([ - // this.imagor.get(result, 'small'), - // this.imagor.get(result, 'medium'), - // this.imagor.get(result, 'large'), - // ]); - - const result = ''; - await this.userRepo.updateAvatar(userId, result); - - await this.userRepo.logActivity({ - id: createId(), - userId, - eventType: 'AVATAR_CHANGED', - metadata: { - url: result, - }, - }); - - return { - success: true, - }; - } -} diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts index 75cb84d..28be54f 100644 --- a/src/user/application/user.facade.ts +++ b/src/user/application/user.facade.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { PaginationQuery } from '@shared/schemas'; import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; import { @@ -21,8 +22,8 @@ export class UserFacade { return this.findProfileQuery.execute(userId); } - public async getActivity(userId: string, page: number, limit: number) { - return this.getActivityQuery.execute(userId, page, limit); + public async getActivity(userId: string, query: PaginationQuery) { + return this.getActivityQuery.execute(userId, query); } public async updateProfile(userId: string, dto: UpdateProfileDto) { diff --git a/src/user/domain/errors/index.ts b/src/user/domain/errors/index.ts new file mode 100644 index 0000000..0d3cb44 --- /dev/null +++ b/src/user/domain/errors/index.ts @@ -0,0 +1 @@ +export * from './user.error'; diff --git a/src/user/domain/errors/user.error.ts b/src/user/domain/errors/user.error.ts new file mode 100644 index 0000000..86757da --- /dev/null +++ b/src/user/domain/errors/user.error.ts @@ -0,0 +1,30 @@ +export const UserErrorCodes = { + QUERY_PARAMS_MISSING: 'USER.QUERY_PARAMS_MISSING', + WEAK_PASSWORD: 'USER.WEAK_PASSWORD', + PRONOUNS_CUSTOM_REQUIRED: 'USER.PRONOUNS_CUSTOM_REQUIRED', + PRONOUNS_CUSTOM_TOO_LONG: 'USER.PRONOUNS_CUSTOM_TOO_LONG', + NOT_FOUND: 'USER.NOT_FOUND', + ALREADY_EXISTS: 'USER.ALREADY_EXISTS', + EMAIL_ALREADY_EXISTS: 'USER.EMAIL_ALREADY_EXISTS', + DATA_CORRUPTION: 'USER.DATA_CORRUPTION', + CREATE_FAILED: 'USER.CREATE_FAILED', + UPDATE_FAILED: 'USER.UPDATE_FAILED', + DELETE_FAILED: 'USER.DELETE_FAILED', +} as const; + +export type UserErrorCode = (typeof UserErrorCodes)[keyof typeof UserErrorCodes]; + +export const UserErrorMessages: Record = { + [UserErrorCodes.QUERY_PARAMS_MISSING]: 'Не указаны параметры поиска пользователя', + [UserErrorCodes.WEAK_PASSWORD]: 'Пароль слишком простой. Используйте более сложный пароль', + [UserErrorCodes.PRONOUNS_CUSTOM_REQUIRED]: 'Пожалуйста, укажите пользовательские местоимения', + [UserErrorCodes.PRONOUNS_CUSTOM_TOO_LONG]: + 'Пользовательские местоимения не могут превышать 50 символов', + [UserErrorCodes.NOT_FOUND]: 'Пользователь не найден', + [UserErrorCodes.ALREADY_EXISTS]: 'Пользователь уже существует', + [UserErrorCodes.EMAIL_ALREADY_EXISTS]: 'Пользователь с таким email уже существует', + [UserErrorCodes.DATA_CORRUPTION]: 'Ошибка целостности данных пользователя', + [UserErrorCodes.CREATE_FAILED]: 'Не удалось создать пользователя', + [UserErrorCodes.UPDATE_FAILED]: 'Не удалось обновить данные пользователя', + [UserErrorCodes.DELETE_FAILED]: 'Не удалось удалить пользователя', +}; diff --git a/src/user/infrastructure/listeners/update-avatar.listener.ts b/src/user/infrastructure/listeners/update-avatar.listener.ts index 2e12368..08f24e6 100644 --- a/src/user/infrastructure/listeners/update-avatar.listener.ts +++ b/src/user/infrastructure/listeners/update-avatar.listener.ts @@ -1,7 +1,7 @@ +import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@core/media'; import { IUserRepository } from '@core/user/domain/repository'; import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Inject } from '@nestjs/common'; -import { MEDIA_JOBS, MEDIA_QUEUES, type UpdateMediaUser } from '@shared/media'; import { UnrecoverableError, type Job } from 'bullmq'; @Processor(MEDIA_QUEUES.SAVE_ENTITY) diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 0010dc4..4ec60c3 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { UserController, UserSettingsController } from './application/controller'; +import { UserController, UserSettingsController } from './application/controllers'; import { USER_EXTERNAL_USE_CASES, UserQueries, UserUseCases } from './application/use-cases'; import { UserFacade } from './application/user.facade'; import { LISTENERS } from './infrastructure/listeners';