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
|
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
@@ -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
@@ -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[]>;
|
||||||
}
|
}
|
||||||
+18
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user