From 46903e9ec54462bf2d72900092bec5a215399d63 Mon Sep 17 00:00:00 2001 From: Vadim Date: Fri, 23 Oct 2026 19:25:54 +0300 Subject: [PATCH] avatar --- .../20261023120000_user_avatar/migration.sql | 9 +++ prisma/schema.prisma | 9 ++- .../user-avatar-file-owner.service.spec.ts | 37 +++++++++++ .../user-avatar-file-owner.service.ts | 32 ++++++++++ .../remove-avatar/remove-avatar.command.ts | 3 + .../remove-avatar.use-case.spec.ts | 31 +++++++++ .../remove-avatar/remove-avatar.use-case.ts | 24 +++++++ .../set-avatar/set-avatar.command.ts | 6 ++ .../set-avatar/set-avatar.use-case.spec.ts | 64 +++++++++++++++++++ .../set-avatar/set-avatar.use-case.ts | 45 +++++++++++++ .../application/use-cases/test-helpers.ts | 22 +++++++ .../exceptions/avatar-not-image.exception.ts | 6 ++ .../repositories/user.repository.interface.ts | 4 ++ .../repositories/user.prisma-repository.ts | 18 ++++++ .../controllers/user.controller.ts | 44 ++++++++++++- .../user/presentation/dto/set-avatar.dto.ts | 9 +++ src/modules/user/user.module.ts | 9 ++- 17 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20261023120000_user_avatar/migration.sql create mode 100644 src/modules/user/application/services/user-avatar-file-owner.service.spec.ts create mode 100644 src/modules/user/application/services/user-avatar-file-owner.service.ts create mode 100644 src/modules/user/application/use-cases/remove-avatar/remove-avatar.command.ts create mode 100644 src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.spec.ts create mode 100644 src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.ts create mode 100644 src/modules/user/application/use-cases/set-avatar/set-avatar.command.ts create mode 100644 src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.spec.ts create mode 100644 src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.ts create mode 100644 src/modules/user/application/use-cases/test-helpers.ts create mode 100644 src/modules/user/domain/exceptions/avatar-not-image.exception.ts create mode 100644 src/modules/user/presentation/dto/set-avatar.dto.ts diff --git a/prisma/migrations/20261023120000_user_avatar/migration.sql b/prisma/migrations/20261023120000_user_avatar/migration.sql new file mode 100644 index 0000000..d3198f6 --- /dev/null +++ b/prisma/migrations/20261023120000_user_avatar/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "avatarFileId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_avatarFileId_key" ON "user"("avatarFileId"); + +-- AddForeignKey +ALTER TABLE "user" ADD CONSTRAINT "user_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "file"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8dd8d19..6d2ba8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,10 @@ model User { email String emailVerified Boolean @default(false) image String? + /// Файл аватара; image хранит его URL для better-auth и клиента. + /// Меняется только через PUT /users/me/avatar — там проверяется, что файл свой. + avatarFileId String? @unique + avatarFile File? @relation(name: "userAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] @@ -25,7 +29,7 @@ model User { notificationPreferences NotificationPreference[] notificationMessages NotificationMessage[] comments Comment[] - uploadedFiles File[] + uploadedFiles File[] @relation(name: "fileUploader") issueChanges IssueChange[] @@unique([email]) @@ -229,11 +233,12 @@ model File { mimeType String size Int uploaderId String? - uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) + uploader User? @relation(name: "fileUploader", fields: [uploaderId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @db.Timestamptz(3) issueAttachments IssueAttachment[] commentAttachments CommentAttachment[] + avatarOf User? @relation(name: "userAvatar") @@unique([storageKey]) @@index([uploaderId]) diff --git a/src/modules/user/application/services/user-avatar-file-owner.service.spec.ts b/src/modules/user/application/services/user-avatar-file-owner.service.spec.ts new file mode 100644 index 0000000..5ad62f1 --- /dev/null +++ b/src/modules/user/application/services/user-avatar-file-owner.service.spec.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js'; +import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js'; +import { UserAvatarFileOwnerService } from './user-avatar-file-owner.service.js'; +import { mockUserRepository } from '../use-cases/test-helpers.js'; + +describe('UserAvatarFileOwnerService', () => { + let userRepository: IUserRepository; + let registry: FileOwnerRegistry; + let owner: UserAvatarFileOwnerService; + + beforeEach(() => { + userRepository = mockUserRepository(); + registry = new FileOwnerRegistry(); + owner = new UserAvatarFileOwnerService(userRepository, registry); + owner.onModuleInit(); + }); + + it('registers itself, so GC keeps avatars', async () => { + vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['a']); + + expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a'])); + }); + + it('makes an avatar visible to any user', async () => { + vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['file-1']); + + expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true); + expect(userRepository.findAvatarFileIds).toHaveBeenCalledWith(['file-1']); + }); + + it('hides a file that is nobody’s avatar', async () => { + vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue([]); + + expect(await owner.isVisibleThrough('file-1')).toBe(false); + }); +}); diff --git a/src/modules/user/application/services/user-avatar-file-owner.service.ts b/src/modules/user/application/services/user-avatar-file-owner.service.ts new file mode 100644 index 0000000..995c175 --- /dev/null +++ b/src/modules/user/application/services/user-avatar-file-owner.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; +import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository.interface.js'; +import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js'; + +/** + * Регистрирует аватары как владельца файлов: без этого GC file-модуля + * удалял бы их как сирот, а другие пользователи не видели бы чужие аватары. + */ +@Injectable() +export class UserAvatarFileOwnerService implements FileOwner, OnModuleInit { + readonly name = 'user-avatar'; + + constructor( + @Inject(USER_REPOSITORY) + private readonly userRepository: IUserRepository, + private readonly fileOwners: FileOwnerRegistry, + ) {} + + onModuleInit(): void { + this.fileOwners.register(this); + } + + findReferenced(fileIds: string[]): Promise { + return this.userRepository.findAvatarFileIds(fileIds); + } + + /** Аватары показываются рядом с задачами и комментариями, поэтому видны всем пользователям */ + async isVisibleThrough(fileId: string): Promise { + const referenced = await this.userRepository.findAvatarFileIds([fileId]); + return referenced.length > 0; + } +} diff --git a/src/modules/user/application/use-cases/remove-avatar/remove-avatar.command.ts b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.command.ts new file mode 100644 index 0000000..325875d --- /dev/null +++ b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.command.ts @@ -0,0 +1,3 @@ +export class RemoveAvatarCommand { + constructor(public readonly userId: string) {} +} diff --git a/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.spec.ts b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.spec.ts new file mode 100644 index 0000000..e9a85fc --- /dev/null +++ b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.spec.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js'; +import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js'; +import { RemoveAvatarUseCase } from './remove-avatar.use-case.js'; +import { RemoveAvatarCommand } from './remove-avatar.command.js'; +import { buildUser, mockUserRepository } from '../test-helpers.js'; + +describe('RemoveAvatarUseCase', () => { + let userRepository: IUserRepository; + let useCase: RemoveAvatarUseCase; + + beforeEach(() => { + userRepository = mockUserRepository(); + useCase = new RemoveAvatarUseCase(userRepository); + }); + + it('clears the avatar', async () => { + vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser()); + + const dto = await useCase.execute(new RemoveAvatarCommand('user-1')); + + expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', null); + expect(dto.image).toBeNull(); + }); + + it('throws UserNotFoundException when the user is gone', async () => { + vi.mocked(userRepository.setAvatar).mockResolvedValue(null); + + await expect(useCase.execute(new RemoveAvatarCommand('user-1'))).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.ts b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.ts new file mode 100644 index 0000000..fe81c88 --- /dev/null +++ b/src/modules/user/application/use-cases/remove-avatar/remove-avatar.use-case.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js'; +import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js'; +import { RemoveAvatarCommand } from './remove-avatar.command.js'; +import { UserDto } from '../../dto/user.dto.js'; +import { UserApplicationMapper } from '../../mappers/user-application.mapper.js'; + +/** Снимает аватар; сам файл, оставшийся без владельца, удалит GC file-модуля */ +@Injectable() +export class RemoveAvatarUseCase { + constructor( + @Inject(USER_REPOSITORY) + private readonly userRepository: IUserRepository, + ) {} + + async execute(command: RemoveAvatarCommand): Promise { + const user = await this.userRepository.setAvatar(command.userId, null); + if (!user) { + throw new UserNotFoundException(command.userId); + } + + return UserApplicationMapper.toDto(user); + } +} diff --git a/src/modules/user/application/use-cases/set-avatar/set-avatar.command.ts b/src/modules/user/application/use-cases/set-avatar/set-avatar.command.ts new file mode 100644 index 0000000..8e83d76 --- /dev/null +++ b/src/modules/user/application/use-cases/set-avatar/set-avatar.command.ts @@ -0,0 +1,6 @@ +export class SetAvatarCommand { + constructor( + public readonly userId: string, + public readonly fileId: string, + ) {} +} diff --git a/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.spec.ts b/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.spec.ts new file mode 100644 index 0000000..d77e4ee --- /dev/null +++ b/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js'; +import type { FileAccessService } from '../../../../file/application/services/file-access.service.js'; +import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js'; +import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js'; +import { buildFile } from '../../../../file/application/use-cases/test-helpers.js'; +import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js'; +import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js'; +import { SetAvatarUseCase } from './set-avatar.use-case.js'; +import { SetAvatarCommand } from './set-avatar.command.js'; +import { buildUser, mockUserRepository } from '../test-helpers.js'; + +describe('SetAvatarUseCase', () => { + let userRepository: IUserRepository; + let fileAccess: FileAccessService; + let useCase: SetAvatarUseCase; + + beforeEach(() => { + userRepository = mockUserRepository(); + fileAccess = { getVisibleFile: vi.fn() } as unknown as FileAccessService; + useCase = new SetAvatarUseCase(userRepository, fileAccess); + + vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile()); + vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser({ image: '/api/files/file-1/download' })); + }); + + it('sets an own image as the avatar', async () => { + const dto = await useCase.execute(new SetAvatarCommand('user-1', 'file-1')); + + expect(fileAccess.getVisibleFile).toHaveBeenCalledWith('file-1', 'user-1'); + expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', { + fileId: 'file-1', + image: '/api/files/file-1/download', + }); + expect(dto.image).toBe('/api/files/file-1/download'); + }); + + it('rejects a file uploaded by someone else', async () => { + vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ uploaderId: 'user-2' })); + + await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileForbiddenException); + expect(userRepository.setAvatar).not.toHaveBeenCalled(); + }); + + it('rejects a file that is not an inline image', async () => { + vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ mimeType: 'image/svg+xml' })); + + await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(AvatarNotImageException); + expect(userRepository.setAvatar).not.toHaveBeenCalled(); + }); + + it('does not set an invisible file', async () => { + vi.mocked(fileAccess.getVisibleFile).mockRejectedValue(new FileNotFoundException('file-1')); + + await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileNotFoundException); + expect(userRepository.setAvatar).not.toHaveBeenCalled(); + }); + + it('throws UserNotFoundException when the user is gone', async () => { + vi.mocked(userRepository.setAvatar).mockResolvedValue(null); + + await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(UserNotFoundException); + }); +}); diff --git a/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.ts b/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.ts new file mode 100644 index 0000000..c103667 --- /dev/null +++ b/src/modules/user/application/use-cases/set-avatar/set-avatar.use-case.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js'; +import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js'; +import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js'; +import { FileAccessService } from '../../../../file/application/services/file-access.service.js'; +import { SetAvatarCommand } from './set-avatar.command.js'; +import { UserDto } from '../../dto/user.dto.js'; +import { UserApplicationMapper } from '../../mappers/user-application.mapper.js'; + +/** URL, по которому клиент показывает аватар; доступ проверяет GET /files/:id/download */ +export const avatarUrl = (fileId: string) => `/api/files/${fileId}/download`; + +/** + * Делает аватаром файл, заранее загруженный через POST /files. + * Только свой файл — аватар виден всем, иначе так можно было бы раскрыть + * чужое вложение. И только картинку, которую браузер покажет inline. + * + * Прежний аватар остаётся без владельца, его удалит GC file-модуля. + */ +@Injectable() +export class SetAvatarUseCase { + constructor( + @Inject(USER_REPOSITORY) + private readonly userRepository: IUserRepository, + private readonly fileAccess: FileAccessService, + ) {} + + async execute(command: SetAvatarCommand): Promise { + const file = await this.fileAccess.getVisibleFile(command.fileId, command.userId); + file.assertOwnedBy(command.userId); + if (!file.isInlineViewable) { + throw new AvatarNotImageException(command.fileId); + } + + const user = await this.userRepository.setAvatar(command.userId, { + fileId: command.fileId, + image: avatarUrl(command.fileId), + }); + if (!user) { + throw new UserNotFoundException(command.userId); + } + + return UserApplicationMapper.toDto(user); + } +} diff --git a/src/modules/user/application/use-cases/test-helpers.ts b/src/modules/user/application/use-cases/test-helpers.ts new file mode 100644 index 0000000..c63e5b7 --- /dev/null +++ b/src/modules/user/application/use-cases/test-helpers.ts @@ -0,0 +1,22 @@ +import { vi } from 'vitest'; +import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js'; +import { User } from '../../domain/entities/user.entity.js'; + +export const buildUser = (overrides: Partial<{ id: string; image: string | null }> = {}) => + User.reconstitute({ + id: overrides.id ?? 'user-1', + name: 'Alice', + email: 'alice@example.com', + emailVerified: true, + image: overrides.image === undefined ? null : overrides.image, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + +export const mockUserRepository = (): IUserRepository => ({ + findById: vi.fn(), + findByIds: vi.fn(), + findAll: vi.fn(), + setAvatar: vi.fn(), + findAvatarFileIds: vi.fn(), +}); diff --git a/src/modules/user/domain/exceptions/avatar-not-image.exception.ts b/src/modules/user/domain/exceptions/avatar-not-image.exception.ts new file mode 100644 index 0000000..a7afb01 --- /dev/null +++ b/src/modules/user/domain/exceptions/avatar-not-image.exception.ts @@ -0,0 +1,6 @@ +export class AvatarNotImageException extends Error { + constructor(fileId: string) { + super(`Файл id=${fileId} нельзя сделать аватаром: нужна картинка PNG, JPEG, GIF или WebP`); + this.name = 'AvatarNotImageException'; + } +} diff --git a/src/modules/user/domain/repositories/user.repository.interface.ts b/src/modules/user/domain/repositories/user.repository.interface.ts index 74bce3f..dcb5b79 100644 --- a/src/modules/user/domain/repositories/user.repository.interface.ts +++ b/src/modules/user/domain/repositories/user.repository.interface.ts @@ -8,4 +8,8 @@ export interface IUserRepository { findById(id: string): Promise; findByIds(ids: string[]): Promise; findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>; + /** null снимает аватар. Возвращает null, если пользователя нет */ + setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise; + /** Подмножество fileIds, которые являются чьим-то аватаром */ + findAvatarFileIds(fileIds: string[]): Promise; } diff --git a/src/modules/user/infrastructure/persistence/prisma/repositories/user.prisma-repository.ts b/src/modules/user/infrastructure/persistence/prisma/repositories/user.prisma-repository.ts index e09896d..6fbc4ef 100644 --- a/src/modules/user/infrastructure/persistence/prisma/repositories/user.prisma-repository.ts +++ b/src/modules/user/infrastructure/persistence/prisma/repositories/user.prisma-repository.ts @@ -38,4 +38,22 @@ export class UserPrismaRepository implements IUserRepository { meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) }, }; } + + async setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise { + const { count } = await this.prisma.user.updateMany({ + where: { id: userId }, + data: { avatarFileId: avatar?.fileId ?? null, image: avatar?.image ?? null }, + }); + return count > 0 ? this.findById(userId) : null; + } + + async findAvatarFileIds(fileIds: string[]): Promise { + if (fileIds.length === 0) return []; + + const records = await this.prisma.user.findMany({ + where: { avatarFileId: { in: fileIds } }, + select: { avatarFileId: true }, + }); + return records.map((record) => record.avatarFileId as string); + } } diff --git a/src/modules/user/presentation/controllers/user.controller.ts b/src/modules/user/presentation/controllers/user.controller.ts index 2c28f3c..bb1104d 100644 --- a/src/modules/user/presentation/controllers/user.controller.ts +++ b/src/modules/user/presentation/controllers/user.controller.ts @@ -1,4 +1,15 @@ -import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Delete, + ForbiddenException, + Get, + NotFoundException, + Param, + Put, + Query, +} from '@nestjs/common'; import { AllowAnonymous, OptionalAuth, Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { GetUserUseCase } from '../../application/use-cases/get-user/get-user.use-case.js'; import { ListUsersUseCase } from '../../application/use-cases/list-users/list-users.use-case.js'; @@ -6,12 +17,22 @@ import { GetUserQuery } from '../../application/use-cases/get-user/get-user.quer import { ListUsersQuery } from '../../application/use-cases/list-users/list-users.query.js'; import { FilterUserDto } from '../dto/filter-user.dto.js'; import { UserNotFoundException } from '../../domain/exceptions/user-not-found.exception.js'; +import { AvatarNotImageException } from '../../domain/exceptions/avatar-not-image.exception.js'; +import { SetAvatarUseCase } from '../../application/use-cases/set-avatar/set-avatar.use-case.js'; +import { RemoveAvatarUseCase } from '../../application/use-cases/remove-avatar/remove-avatar.use-case.js'; +import { SetAvatarCommand } from '../../application/use-cases/set-avatar/set-avatar.command.js'; +import { RemoveAvatarCommand } from '../../application/use-cases/remove-avatar/remove-avatar.command.js'; +import { SetAvatarRequestDto } from '../dto/set-avatar.dto.js'; +import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js'; +import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js'; @Controller('users') export class UserController { constructor( private readonly getUserUseCase: GetUserUseCase, private readonly listUsersUseCase: ListUsersUseCase, + private readonly setAvatarUseCase: SetAvatarUseCase, + private readonly removeAvatarUseCase: RemoveAvatarUseCase, ) {} @Get('me') @@ -19,6 +40,17 @@ export class UserController { return session; } + /** Картинка загружается отдельно (POST /files), здесь — только привязка к пользователю */ + @Put('me/avatar') + setAvatar(@Body() dto: SetAvatarRequestDto, @Session() session: UserSession) { + return this.handleNotFound(() => this.setAvatarUseCase.execute(new SetAvatarCommand(session.user.id, dto.fileId))); + } + + @Delete('me/avatar') + removeAvatar(@Session() session: UserSession) { + return this.handleNotFound(() => this.removeAvatarUseCase.execute(new RemoveAvatarCommand(session.user.id))); + } + @Get('public') @AllowAnonymous() // Allow anonymous access (no authentication required) async publicRoute() { @@ -41,14 +73,20 @@ export class UserController { return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id))); } - /** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */ + /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ private async handleNotFound(fn: () => Promise): Promise { try { return await fn(); } catch (error) { - if (error instanceof UserNotFoundException) { + if (error instanceof UserNotFoundException || error instanceof FileNotFoundException) { throw new NotFoundException(error.message); } + if (error instanceof FileForbiddenException) { + throw new ForbiddenException(error.message); + } + if (error instanceof AvatarNotImageException) { + throw new BadRequestException(error.message); + } throw error; } } diff --git a/src/modules/user/presentation/dto/set-avatar.dto.ts b/src/modules/user/presentation/dto/set-avatar.dto.ts new file mode 100644 index 0000000..cdbed38 --- /dev/null +++ b/src/modules/user/presentation/dto/set-avatar.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class SetAvatarRequestDto { + /** id картинки, загруженной через POST /files */ + @IsString() + @IsNotEmpty() + @MaxLength(64) + fileId: string; +} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 7e6e26e..4137838 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; +import { FileModule } from '../file/file.module.js'; import { UserPrismaRepository } from './infrastructure/persistence/prisma/repositories/user.prisma-repository.js'; import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js'; import { UserController } from './presentation/controllers/user.controller.js'; @@ -7,14 +8,20 @@ import { GetUserUseCase } from './application/use-cases/get-user/get-user.use-ca import { GetUsersUseCase } from './application/use-cases/get-users/get-users.use-case.js'; import { ListUsersUseCase } from './application/use-cases/list-users/list-users.use-case.js'; import { UserResolver } from './presentation/resolvers/user.resolver.js'; +import { SetAvatarUseCase } from './application/use-cases/set-avatar/set-avatar.use-case.js'; +import { RemoveAvatarUseCase } from './application/use-cases/remove-avatar/remove-avatar.use-case.js'; +import { UserAvatarFileOwnerService } from './application/services/user-avatar-file-owner.service.js'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, FileModule], controllers: [UserController], providers: [ GetUserUseCase, GetUsersUseCase, ListUsersUseCase, + SetAvatarUseCase, + RemoveAvatarUseCase, + UserAvatarFileOwnerService, UserResolver, { // Domain видит только интерфейс IUserRepository,