Add attachments to comments
- CommentAttachment link table (comment <-> file) + migration - fileIds in create/update comment; update replaces the whole set, synced in one transaction with the comment - CommentDto.attachments; empty for deleted comments - Soft delete drops attachments so GC can collect the files - Max 10 files per comment, ordered by upload time (uuid v7) - Registers comments as a file owner - Rename CommentAuthorExpanderService -> CommentExpanderService (expands authors and files) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
423afa658d
commit
60fb39f98f
28 files changed
+522
-69
No files matched your search
+40
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
|
||||
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js';
|
||||
import { CommentAttachmentFileOwnerService } from './comment-attachment-file-owner.service.js';
|
||||
import { buildIssue, mockCommentRepository, mockIssueRepository } from '../use-cases/test-helpers.js';
|
||||
|
||||
describe('CommentAttachmentFileOwnerService', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
let issueRepository: IIssueRepository;
|
||||
let registry: FileOwnerRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
commentRepository = mockCommentRepository();
|
||||
issueRepository = mockIssueRepository();
|
||||
registry = new FileOwnerRegistry();
|
||||
new CommentAttachmentFileOwnerService(commentRepository, issueRepository, registry).onModuleInit();
|
||||
});
|
||||
|
||||
it('registers itself, so GC keeps files attached to comments', async () => {
|
||||
vi.mocked(commentRepository.findReferencedFileIds).mockResolvedValue(['a']);
|
||||
|
||||
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
|
||||
});
|
||||
|
||||
it('makes a file visible to users who see the issue of its comment', async () => {
|
||||
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
|
||||
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-2');
|
||||
});
|
||||
|
||||
it('hides a file when the issue is not visible', async () => {
|
||||
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||
|
||||
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
|
||||
import {
|
||||
ISSUE_REPOSITORY,
|
||||
type IIssueRepository,
|
||||
} from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js';
|
||||
|
||||
/**
|
||||
* Регистрирует комментарии как владельца файлов: без этого GC file-модуля
|
||||
* считал бы прикреплённые к комментариям файлы сиротами и удалял их.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommentAttachmentFileOwnerService implements FileOwner, OnModuleInit {
|
||||
readonly name = 'comment';
|
||||
|
||||
constructor(
|
||||
@Inject(COMMENT_REPOSITORY)
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly fileOwners: FileOwnerRegistry,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.fileOwners.register(this);
|
||||
}
|
||||
|
||||
findReferenced(fileIds: string[]): Promise<string[]> {
|
||||
return this.commentRepository.findReferencedFileIds(fileIds);
|
||||
}
|
||||
|
||||
/** Файл виден, если видна задача хоть одного неудалённого комментария с ним */
|
||||
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||
const issueIds = await this.commentRepository.findIssueIdsByAttachedFile(fileId);
|
||||
|
||||
for (const issueId of issueIds) {
|
||||
if (await this.issueRepository.findById(issueId, userId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -4,11 +4,16 @@ import { CommentDto } from '../dto/comment.dto.js';
|
||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
import { GetFilesUseCase } from '../../../file/application/use-cases/get-files/get-files.use-case.js';
|
||||
import { GetFilesQuery } from '../../../file/application/use-cases/get-files/get-files.query.js';
|
||||
|
||||
/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */
|
||||
/** Раскрывает автора и вложения комментария (или списка комментариев) */
|
||||
@Injectable()
|
||||
export class CommentAuthorExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
export class CommentExpanderService {
|
||||
constructor(
|
||||
private readonly getUsersUseCase: GetUsersUseCase,
|
||||
private readonly getFilesUseCase: GetFilesUseCase,
|
||||
) {}
|
||||
|
||||
async expand(comment: Comment): Promise<CommentDto> {
|
||||
const [dto] = await this.expandAll([comment]);
|
||||
@@ -19,9 +24,15 @@ export class CommentAuthorExpanderService {
|
||||
if (comments.length === 0) return [];
|
||||
|
||||
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
|
||||
const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [];
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
const fileIds = comments.flatMap((comment) => comment.fileIds);
|
||||
|
||||
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById));
|
||||
const [users, files] = await Promise.all([
|
||||
userIds.length > 0 ? this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [],
|
||||
fileIds.length > 0 ? this.getFilesUseCase.execute(new GetFilesQuery(fileIds)) : [],
|
||||
]);
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
const filesById = new Map(files.map((file) => [file.id, file]));
|
||||
|
||||
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById, filesById));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user