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'; 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/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 { 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}`; + } }