Compare commits

..
2 Commits
Author SHA1 Message Date
vadim 46903e9ec5 avatar
Gitea Actions / Deploy (push) Successful in 1m52s
2026-10-23 19:26:26 +03:00
vadim f085517732 email 2026-10-23 19:26:26 +03:00
20 changed files with 411 additions and 24 deletions

No files matched your search

@@ -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;
+7 -2
View File
@@ -13,6 +13,10 @@ model User {
email String email String
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
@@ -25,7 +29,7 @@ model User {
notificationPreferences NotificationPreference[] notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[] notificationMessages NotificationMessage[]
comments Comment[] comments Comment[]
uploadedFiles File[] uploadedFiles File[] @relation(name: "fileUploader")
issueChanges IssueChange[] issueChanges IssueChange[]
@@unique([email]) @@unique([email])
@@ -229,11 +233,12 @@ model File {
mimeType String mimeType String
size Int size Int
uploaderId String? 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) createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[] issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[] commentAttachments CommentAttachment[]
avatarOf User? @relation(name: "userAvatar")
@@unique([storageKey]) @@unique([storageKey])
@@index([uploaderId]) @@index([uploaderId])
+11 -15
View File
@@ -12,10 +12,7 @@ export const auth = betterAuth({
emailOTP({ emailOTP({
async sendVerificationOTP({ email, otp, type }) { async sendVerificationOTP({ email, otp, type }) {
if (type === 'sign-in') { if (type === 'sign-in') {
// Send the OTP for sign in EmailSender.sendInBackground({
console.log({ email, otp, type });
void EmailSender.getTransporter().sendMail({
to: email, to: email,
subject: `Your OTP code is: ${otp}`, subject: `Your OTP code is: ${otp}`,
text: `Your OTP code is: ${otp}`, text: `Your OTP code is: ${otp}`,
@@ -23,8 +20,13 @@ export const auth = betterAuth({
} else if (type === 'email-verification') { } else if (type === 'email-verification') {
// Send the OTP for email verification // Send the OTP for email verification
console.log({ email, otp, type }); console.log({ email, otp, type });
} else if (type === 'forget-password') {
EmailSender.sendInBackground({
to: email,
subject: `Your password reset code is: ${otp}`,
text: `Your password reset code is: ${otp}`,
});
} else { } else {
// Send the OTP for password reset
console.log({ email, otp, type }); console.log({ email, otp, type });
} }
}, },
@@ -47,26 +49,21 @@ export const auth = betterAuth({
autoSignIn: false, autoSignIn: false,
requireEmailVerification: true, requireEmailVerification: true,
onExistingUserSignUp: async ({ user }, request) => { onExistingUserSignUp: async ({ user }, request) => {
console.log('onExistingUserSignUp', { user }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Sign-up attempt with your email', subject: 'Sign-up attempt with your email',
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.', text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
}); });
}, },
sendResetPassword: async ({ user, url, token }, request) => { sendResetPassword: async ({ user, url, token }, request) => {
console.log('sendResetPassword', { user, url, token }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Reset your password', subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`, text: `Click the link to reset your password: ${url}`,
}); });
}, },
onPasswordReset: async ({ user }, request) => { onPasswordReset: async ({ user }, request) => {
// your logic here EmailSender.sendInBackground({
console.log('onPasswordReset', { user });
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Reset your password result', subject: 'Reset your password result',
text: `Password for user ${user.email} has been reset.`, text: `Password for user ${user.email} has been reset.`,
@@ -77,8 +74,7 @@ export const auth = betterAuth({
sendOnSignIn: true, sendOnSignIn: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
console.log('emailVerification', { user, url }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Verify your email address', subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`, text: `Click the link to verify your email: ${url}`,
+33 -2
View File
@@ -1,13 +1,44 @@
import { Logger } from '@nestjs/common';
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer'; import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
export default class EmailSender { export default class EmailSender {
static getTransporter() { private static readonly logger = new Logger(EmailSender.name);
return nodemailer.createTransport({ private static transporter?: Transporter;
private static isLocal() {
return process.env.NODE_ENV === 'local';
}
private static getTransporter() {
this.transporter ??= nodemailer.createTransport({
service: 'gmail', service: 'gmail',
auth: { auth: {
user: process.env.GOOGLE_APP_USER, user: process.env.GOOGLE_APP_USER,
pass: process.env.GOOGLE_APP_PASSWORD, pass: process.env.GOOGLE_APP_PASSWORD,
}, },
}); });
return this.transporter;
}
/** Sends an email; throws on failure. In local env only logs the message. */
static async send(options: SendMailOptions): Promise<void> {
if (this.isLocal()) {
this.logger.log(
`[local] email not sent\n to: ${String(options.to)}\n subject: ${options.subject}\n text: ${String(options.text)}`,
);
return;
}
await this.getTransporter().sendMail(options);
}
/** Fire-and-forget variant: never throws, errors are logged. */
static sendInBackground(options: SendMailOptions): void {
this.send(options).catch((error: unknown) => {
this.logger.error(
`Failed to send email "${options.subject}" to ${String(options.to)}`,
error instanceof Error ? error.stack : String(error),
);
});
} }
} }
@@ -16,7 +16,7 @@ export class EmailChannelSender implements NotificationChannelSender {
select: { email: true }, select: { email: true },
}); });
await EmailSender.getTransporter().sendMail({ await EmailSender.send({
to: user.email, to: user.email,
subject: params.subject ?? 'Уведомление', subject: params.subject ?? 'Уведомление',
text: params.content, text: params.content,
@@ -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);
});
});
@@ -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<string[]> {
return this.userRepository.findAvatarFileIds(fileIds);
}
/** Аватары показываются рядом с задачами и комментариями, поэтому видны всем пользователям */
async isVisibleThrough(fileId: string): Promise<boolean> {
const referenced = await this.userRepository.findAvatarFileIds([fileId]);
return referenced.length > 0;
}
}
@@ -0,0 +1,3 @@
export class RemoveAvatarCommand {
constructor(public readonly userId: string) {}
}
@@ -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);
});
});
@@ -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<UserDto> {
const user = await this.userRepository.setAvatar(command.userId, null);
if (!user) {
throw new UserNotFoundException(command.userId);
}
return UserApplicationMapper.toDto(user);
}
}
@@ -0,0 +1,6 @@
export class SetAvatarCommand {
constructor(
public readonly userId: string,
public readonly fileId: string,
) {}
}
@@ -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);
});
});
@@ -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<UserDto> {
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);
}
}
@@ -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(),
});
@@ -0,0 +1,6 @@
export class AvatarNotImageException extends Error {
constructor(fileId: string) {
super(`Файл id=${fileId} нельзя сделать аватаром: нужна картинка PNG, JPEG, GIF или WebP`);
this.name = 'AvatarNotImageException';
}
}
@@ -8,4 +8,8 @@ export interface IUserRepository {
findById(id: string): Promise<User | null>; findById(id: string): Promise<User | null>;
findByIds(ids: string[]): Promise<User[]>; findByIds(ids: string[]): Promise<User[]>;
findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>; findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>;
/** null снимает аватар. Возвращает null, если пользователя нет */
setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null>;
/** Подмножество fileIds, которые являются чьим-то аватаром */
findAvatarFileIds(fileIds: string[]): Promise<string[]>;
} }
@@ -38,4 +38,22 @@ export class UserPrismaRepository implements IUserRepository {
meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) }, meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) },
}; };
} }
async setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null> {
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<string[]> {
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);
}
} }
@@ -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 { AllowAnonymous, OptionalAuth, Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { GetUserUseCase } from '../../application/use-cases/get-user/get-user.use-case.js'; 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'; 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 { ListUsersQuery } from '../../application/use-cases/list-users/list-users.query.js';
import { FilterUserDto } from '../dto/filter-user.dto.js'; import { FilterUserDto } from '../dto/filter-user.dto.js';
import { UserNotFoundException } from '../../domain/exceptions/user-not-found.exception.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') @Controller('users')
export class UserController { export class UserController {
constructor( constructor(
private readonly getUserUseCase: GetUserUseCase, private readonly getUserUseCase: GetUserUseCase,
private readonly listUsersUseCase: ListUsersUseCase, private readonly listUsersUseCase: ListUsersUseCase,
private readonly setAvatarUseCase: SetAvatarUseCase,
private readonly removeAvatarUseCase: RemoveAvatarUseCase,
) {} ) {}
@Get('me') @Get('me')
@@ -19,6 +40,17 @@ export class UserController {
return session; 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') @Get('public')
@AllowAnonymous() // Allow anonymous access (no authentication required) @AllowAnonymous() // Allow anonymous access (no authentication required)
async publicRoute() { async publicRoute() {
@@ -41,14 +73,20 @@ export class UserController {
return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id))); return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id)));
} }
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */ /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> { private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try { try {
return await fn(); return await fn();
} catch (error) { } catch (error) {
if (error instanceof UserNotFoundException) { if (error instanceof UserNotFoundException || error instanceof FileNotFoundException) {
throw new NotFoundException(error.message); 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; throw error;
} }
} }
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class SetAvatarRequestDto {
/** id картинки, загруженной через POST /files */
@IsString()
@IsNotEmpty()
@MaxLength(64)
fileId: string;
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; 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 { UserPrismaRepository } from './infrastructure/persistence/prisma/repositories/user.prisma-repository.js';
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js'; import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js';
import { UserController } from './presentation/controllers/user.controller.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 { 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 { ListUsersUseCase } from './application/use-cases/list-users/list-users.use-case.js';
import { UserResolver } from './presentation/resolvers/user.resolver.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({ @Module({
imports: [PrismaModule], imports: [PrismaModule, FileModule],
controllers: [UserController], controllers: [UserController],
providers: [ providers: [
GetUserUseCase, GetUserUseCase,
GetUsersUseCase, GetUsersUseCase,
ListUsersUseCase, ListUsersUseCase,
SetAvatarUseCase,
RemoveAvatarUseCase,
UserAvatarFileOwnerService,
UserResolver, UserResolver,
{ {
// Domain видит только интерфейс IUserRepository, // Domain видит только интерфейс IUserRepository,