Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 54 additions & 32 deletions src/shared/media/decorators/extract-media-req.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FastifyRequest>();

if (!req.isMultipart()) {
Expand All @@ -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<string, string> = {};
const fields: Record<string, string> = {};

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;
}
},
);
7 changes: 7 additions & 0 deletions src/shared/utils/format-bytes.util.ts
Original file line number Diff line number Diff line change
@@ -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];
};
8 changes: 1 addition & 7 deletions src/teams/application/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
3 changes: 1 addition & 2 deletions src/teams/application/dtos/member.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions src/teams/application/dtos/team.dto.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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 команды'),
Expand All @@ -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
Expand Down
14 changes: 1 addition & 13 deletions src/teams/application/use-cases/base/get-my-teams.use-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,8 @@ export class GetMyTeamsUseCase {
async execute(userId: string, pagination: Record<string, string>) {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -138,4 +143,12 @@ export class SendInvitationUseCase {
removeOnComplete: true,
});
}

private getCdnBaseUrl(): string {
const domain = this.cfg.get<string>('DOMAIN');
const bucket = this.cfg.get<string>('S3_BUCKET_NAME');
const endpoint = this.cfg.get<string>('S3_ENDPOINT');

return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`;
}
}
Loading