Compare commits
2
Commits
de1b3754df
...
46903e9ec5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46903e9ec5 | ||
|
|
f085517732 |
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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
+11
-15
@@ -12,10 +12,7 @@ export const auth = betterAuth({
|
||||
emailOTP({
|
||||
async sendVerificationOTP({ email, otp, type }) {
|
||||
if (type === 'sign-in') {
|
||||
// Send the OTP for sign in
|
||||
console.log({ email, otp, type });
|
||||
|
||||
void EmailSender.getTransporter().sendMail({
|
||||
EmailSender.sendInBackground({
|
||||
to: email,
|
||||
subject: `Your OTP code is: ${otp}`,
|
||||
text: `Your OTP code is: ${otp}`,
|
||||
@@ -23,8 +20,13 @@ export const auth = betterAuth({
|
||||
} else if (type === 'email-verification') {
|
||||
// Send the OTP for email verification
|
||||
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 {
|
||||
// Send the OTP for password reset
|
||||
console.log({ email, otp, type });
|
||||
}
|
||||
},
|
||||
@@ -47,26 +49,21 @@ export const auth = betterAuth({
|
||||
autoSignIn: false,
|
||||
requireEmailVerification: true,
|
||||
onExistingUserSignUp: async ({ user }, request) => {
|
||||
console.log('onExistingUserSignUp', { user });
|
||||
|
||||
void EmailSender.getTransporter().sendMail({
|
||||
EmailSender.sendInBackground({
|
||||
to: user.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.',
|
||||
});
|
||||
},
|
||||
sendResetPassword: async ({ user, url, token }, request) => {
|
||||
console.log('sendResetPassword', { user, url, token });
|
||||
void EmailSender.getTransporter().sendMail({
|
||||
EmailSender.sendInBackground({
|
||||
to: user.email,
|
||||
subject: 'Reset your password',
|
||||
text: `Click the link to reset your password: ${url}`,
|
||||
});
|
||||
},
|
||||
onPasswordReset: async ({ user }, request) => {
|
||||
// your logic here
|
||||
console.log('onPasswordReset', { user });
|
||||
void EmailSender.getTransporter().sendMail({
|
||||
EmailSender.sendInBackground({
|
||||
to: user.email,
|
||||
subject: 'Reset your password result',
|
||||
text: `Password for user ${user.email} has been reset.`,
|
||||
@@ -77,8 +74,7 @@ export const auth = betterAuth({
|
||||
sendOnSignIn: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
console.log('emailVerification', { user, url });
|
||||
void EmailSender.getTransporter().sendMail({
|
||||
EmailSender.sendInBackground({
|
||||
to: user.email,
|
||||
subject: 'Verify your email address',
|
||||
text: `Click the link to verify your email: ${url}`,
|
||||
|
||||
+33
-2
@@ -1,13 +1,44 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
|
||||
|
||||
export default class EmailSender {
|
||||
static getTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
private static readonly logger = new Logger(EmailSender.name);
|
||||
private static transporter?: Transporter;
|
||||
|
||||
private static isLocal() {
|
||||
return process.env.NODE_ENV === 'local';
|
||||
}
|
||||
|
||||
private static getTransporter() {
|
||||
this.transporter ??= nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.GOOGLE_APP_USER,
|
||||
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 },
|
||||
});
|
||||
|
||||
await EmailSender.getTransporter().sendMail({
|
||||
await EmailSender.send({
|
||||
to: user.email,
|
||||
subject: params.subject ?? 'Уведомление',
|
||||
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>;
|
||||
findByIds(ids: string[]): Promise<User[]>;
|
||||
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[]>;
|
||||
}
|
||||
+18
@@ -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<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 { 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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SetAvatarRequestDto {
|
||||
/** id картинки, загруженной через POST /files */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
fileId: string;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user