From a438190ba686b4c6e6237a75372f401508a76f28 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 9 Jun 2026 16:21:29 +0300 Subject: [PATCH 1/4] feat(media): add Fastify error parsing --- src/shared/error/filter.ts | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index b06dc7f..4b173e5 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -36,6 +36,17 @@ export class GlobalExceptionFilter implements ExceptionFilter { return this.parseDatabase(exception, host); } + if ( + exception instanceof Error && + 'code' in exception && + typeof (exception as any).code === 'string' + ) { + return this.parseFastifyError( + exception as Error & { code: string; statusCode?: number }, + host, + ); + } + return this.handleUnknownError(exception, host); } @@ -152,6 +163,45 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); }; + private parseFastifyError = async ( + exception: Error & { code: string; statusCode?: number }, + host: ArgumentsHost, + ) => { + const { request, response } = this.getCtxBase(host); + + let status = exception.statusCode || HttpStatus.BAD_REQUEST; + let code = exception.code; + let message = exception.message || 'Fastify execution error'; + + switch (exception.code) { + case 'FST_ERR_MULTIPART_REACHED_LIMIT': + status = HttpStatus.PAYLOAD_TOO_LARGE; + code = 'FILE_TOO_LARGE'; + message = 'Размер загружаемого файла превышает допустимый лимит (5 MB)'; + break; + + default: + if (!exception.statusCode || exception.statusCode >= 500) { + return this.handleUnknownError(exception, host); + } + break; + } + + this.log(exception, host, status, { + fastifyCode: exception.code, + type: 'FASTIFY_EXCEPTION', + }); + + return response.status(status).send( + this.formatErrorResponse(request, status, { + code, + message, + details: [], + stack: exception.stack, + }), + ); + }; + private handleUnknownError(exception: any, host: ArgumentsHost) { const { request, response } = this.getCtxBase(host); const status = HttpStatus.INTERNAL_SERVER_ERROR; From ac20d3f370a331b98a97d811f7f7ff4e410ffebd Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 9 Jun 2026 16:22:57 +0300 Subject: [PATCH 2/4] fix(teams): update invitation team avatar data --- src/teams/application/dtos/member.dto.ts | 3 +-- .../invitions/send-invitation.use-case.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/teams/application/dtos/member.dto.ts b/src/teams/application/dtos/member.dto.ts index 000d496..6cf7ca0 100644 --- a/src/teams/application/dtos/member.dto.ts +++ b/src/teams/application/dtos/member.dto.ts @@ -59,9 +59,8 @@ export const UserInviteSchema = z.object({ teamAvatar: z .string() .url() + .nullable() .describe('URL аватара команды (может быть null, если аватар не установлен)'), - //TODO: replace with right schema after handling avatar in use-case - // avatar: AvatarResponseSchema, role: z.string().describe('Роль'), inviterName: z.string().describe('Имя пригласившего'), expiresAt: z diff --git a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts index d02e930..6aabe8a 100644 --- a/src/teams/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/teams/application/use-cases/invitions/send-invitation.use-case.ts @@ -13,6 +13,7 @@ import { TeamMemberPolicy } from '@core/teams/domain/policy'; import type { TeamRole } from '@shared/entities'; import { CACHE_SERVICE } from '@shared/adapters/cache/constants'; import { ICacheService } from '@shared/adapters/cache/ports'; +import { ImageHelper } from '@shared/utils'; @Injectable() export class SendInvitationUseCase { @@ -105,10 +106,14 @@ export class SendInvitationUseCase { private buildInviteData(team: any, inviter: any, dto: InviteMemberDto): TeamInvite { const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); + + const cdn = this.getCdnBaseUrl(); + const { small } = ImageHelper.buildResponsiveUrls(cdn, team.avatarUrl); + return { teamId: team.id, teamName: team.name, - teamAvatar: team.avatarUrl, + teamAvatar: small, email: dto.email.toLowerCase(), role: (dto.role || 'member') as TeamRole, inviterId: inviter.userId, @@ -138,4 +143,12 @@ export class SendInvitationUseCase { removeOnComplete: true, }); } + + private getCdnBaseUrl(): string { + const domain = this.cfg.get('DOMAIN'); + const bucket = this.cfg.get('S3_BUCKET_NAME'); + const endpoint = this.cfg.get('S3_ENDPOINT'); + + return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + } } From 3eed191f9531358edeff3b8ad479606c7a523101 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 9 Jun 2026 18:29:38 +0300 Subject: [PATCH 3/4] refactor(teams): remove pagination from user teams --- src/teams/application/dtos/team.dto.ts | 9 +++------ .../use-cases/base/get-my-teams.use-case.ts | 14 +------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/teams/application/dtos/team.dto.ts b/src/teams/application/dtos/team.dto.ts index 63d2ed7..9dc36d5 100644 --- a/src/teams/application/dtos/team.dto.ts +++ b/src/teams/application/dtos/team.dto.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; import { createZodDto } from 'nestjs-zod'; -import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; +import { AvatarResponseSchema } from '@shared/schemas'; import { ActionResponseSchema } from '@shared/dtos'; export const CreateTeamSchema = z.object({ @@ -52,9 +52,8 @@ export const UserTeamSchema = z.object({ permissions: TeamPermissionsSchema.describe('Объект прав доступа текущего пользователя'), }); -export class UserTeamResponse extends createZodDto(UserTeamSchema) {} - -export class UserTeamsResponse extends createZodDto(createPaginationSchema(UserTeamSchema)) {} +export const UserTeamsSchema = z.array(UserTeamSchema); +export class UserTeamsResponse extends createZodDto(UserTeamsSchema) {} export const TeamResponseSchema = z.object({ id: z.string().describe('Уникальный ID команды'), @@ -65,8 +64,6 @@ export const TeamResponseSchema = z.object({ .url() .nullable() .describe('URL аватара команды или null, если аватар отсутствует'), - //TODO: replace with schema - // avatar: AvatarResponseSchema, coverUrl: z.string().nullable().describe('URL обложки команды'), ownerId: z.string().nullable().describe('ID владельца команды'), createdAt: z 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 75bdf5d..e8277fc 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 @@ -14,20 +14,8 @@ export class GetMyTeamsUseCase { async execute(userId: string, pagination: Record) { const teams = await this.teamsRepo.findByUser(userId, pagination); const cdn = this.getCdnBaseUrl(); - const data = teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); - return { - // TODO: реализовать полноценную пагинацию (total/limit/page/hasNextPage) для команд пользователя. - items: data, - meta: { - total: data.length, - totalPages: data.length ? 1 : 0, - page: 1, - limit: data.length, - hasPrevPage: false, - hasNextPage: false, - }, - }; + return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); } private getCdnBaseUrl(): string { From 1a08fe401862e1b80b5fc4188411a18f34e7af06 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 10 Jun 2026 11:56:19 +0300 Subject: [PATCH 4/4] feat(media): improve media request handling and file size validation --- src/shared/error/filter.ts | 50 ----------- .../decorators/extract-media-req.decorator.ts | 86 ++++++++++++------- src/shared/utils/format-bytes.util.ts | 7 ++ src/teams/application/dtos/index.ts | 8 +- 4 files changed, 62 insertions(+), 89 deletions(-) create mode 100644 src/shared/utils/format-bytes.util.ts diff --git a/src/shared/error/filter.ts b/src/shared/error/filter.ts index 4b173e5..b06dc7f 100644 --- a/src/shared/error/filter.ts +++ b/src/shared/error/filter.ts @@ -36,17 +36,6 @@ export class GlobalExceptionFilter implements ExceptionFilter { return this.parseDatabase(exception, host); } - if ( - exception instanceof Error && - 'code' in exception && - typeof (exception as any).code === 'string' - ) { - return this.parseFastifyError( - exception as Error & { code: string; statusCode?: number }, - host, - ); - } - return this.handleUnknownError(exception, host); } @@ -163,45 +152,6 @@ export class GlobalExceptionFilter implements ExceptionFilter { ); }; - private parseFastifyError = async ( - exception: Error & { code: string; statusCode?: number }, - host: ArgumentsHost, - ) => { - const { request, response } = this.getCtxBase(host); - - let status = exception.statusCode || HttpStatus.BAD_REQUEST; - let code = exception.code; - let message = exception.message || 'Fastify execution error'; - - switch (exception.code) { - case 'FST_ERR_MULTIPART_REACHED_LIMIT': - status = HttpStatus.PAYLOAD_TOO_LARGE; - code = 'FILE_TOO_LARGE'; - message = 'Размер загружаемого файла превышает допустимый лимит (5 MB)'; - break; - - default: - if (!exception.statusCode || exception.statusCode >= 500) { - return this.handleUnknownError(exception, host); - } - break; - } - - this.log(exception, host, status, { - fastifyCode: exception.code, - type: 'FASTIFY_EXCEPTION', - }); - - return response.status(status).send( - this.formatErrorResponse(request, status, { - code, - message, - details: [], - stack: exception.stack, - }), - ); - }; - private handleUnknownError(exception: any, host: ArgumentsHost) { const { request, response } = this.getCtxBase(host); const status = HttpStatus.INTERNAL_SERVER_ERROR; diff --git a/src/shared/media/decorators/extract-media-req.decorator.ts b/src/shared/media/decorators/extract-media-req.decorator.ts index d06bfbc..f347c32 100644 --- a/src/shared/media/decorators/extract-media-req.decorator.ts +++ b/src/shared/media/decorators/extract-media-req.decorator.ts @@ -2,12 +2,14 @@ import { createParamDecorator, type ExecutionContext, HttpStatus } from '@nestjs import type { FastifyRequest } from 'fastify'; import { IMAGE_MIME_TYPES } from '../../constants'; import { BaseException } from '@shared/error'; +import { formatBytes } from '@shared/utils/format-bytes.util'; export const ExtractMediaReq = createParamDecorator( async ( { allowedMimetypes = IMAGE_MIME_TYPES }: { allowedMimetypes?: string[] } = {}, ctx: ExecutionContext, ) => { + const maxFileSize = 5 * 1024 * 1024; const req = ctx.switchToHttp().getRequest(); if (!req.isMultipart()) { @@ -23,44 +25,64 @@ export const ExtractMediaReq = createParamDecorator( ); } - const file = await req.file(); - if (!file) { - throw new BaseException( - { - code: 'FILE_NOT_FOUND', - message: 'Файл не был передан в запросе', - }, - HttpStatus.BAD_REQUEST, - ); - } + try { + const file = await req.file(); + if (!file) { + throw new BaseException( + { + code: 'FILE_NOT_FOUND', + message: 'Файл не был передан в запросе', + }, + HttpStatus.BAD_REQUEST, + ); + } - const buffer = await file.toBuffer(); + const buffer = await file.toBuffer(); - if (allowedMimetypes?.length && !allowedMimetypes.includes(file.mimetype)) { - throw new BaseException( - { code: 'INVALID_FILE_TYPE', message: 'Недопустимый формат файла' }, - HttpStatus.UNSUPPORTED_MEDIA_TYPE, - ); - } + if (allowedMimetypes?.length && !allowedMimetypes.includes(file.mimetype)) { + throw new BaseException( + { code: 'INVALID_FILE_TYPE', message: 'Недопустимый формат файла' }, + HttpStatus.UNSUPPORTED_MEDIA_TYPE, + ); + } - const fields: Record = {}; + const fields: Record = {}; - for (const key in file.fields) { - if (key === 'file') continue; + for (const key in file.fields) { + if (key === 'file') continue; - const field = file.fields[key]; - if (field && !Array.isArray(field) && 'value' in field) { - fields[key] = String(field.value); + const field = file.fields[key]; + if (field && !Array.isArray(field) && 'value' in field) { + fields[key] = String(field.value); + } } - } - return { - file: { - filename: file.filename, - mimetype: file.mimetype, - buffer, - }, - ...fields, - }; + return { + file: { + filename: file.filename, + mimetype: file.mimetype, + buffer, + }, + ...fields, + }; + } catch (e) { + if (e?.code === 'FST_REQ_FILE_TOO_LARGE') { + throw new BaseException( + { + code: 'FILE_TOO_LARGE', + message: `Размер файла слишком большой. Максимальный размер: ${formatBytes(maxFileSize)}`, + details: [ + { + target: 'file', + message: `Размер файла превышает лимит в ${formatBytes(maxFileSize)}`, + }, + ], + }, + HttpStatus.PAYLOAD_TOO_LARGE, + ); + } + + throw e; + } }, ); diff --git a/src/shared/utils/format-bytes.util.ts b/src/shared/utils/format-bytes.util.ts new file mode 100644 index 0000000..7e6f905 --- /dev/null +++ b/src/shared/utils/format-bytes.util.ts @@ -0,0 +1,7 @@ +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; diff --git a/src/teams/application/dtos/index.ts b/src/teams/application/dtos/index.ts index 2fae57e..3342878 100644 --- a/src/teams/application/dtos/index.ts +++ b/src/teams/application/dtos/index.ts @@ -11,10 +11,4 @@ export { TeamInvitationResponse, TeamInvitationsResponse, } from './invitation.dto'; -export { - CreateTeamDto, - UpdateTeamDto, - UserTeamResponse, - UserTeamsResponse, - TeamResponse, -} from './team.dto'; +export { CreateTeamDto, UpdateTeamDto, UserTeamsResponse, TeamResponse } from './team.dto';