issue observers Наблюдатели задачи видят её и комментируют, но не редактируют. - таблица issue_observer, наблюдатели — часть сущности Issue - видимость: reporter, assignee или наблюдатель (задачи, история, SSE) - правка/удаление задачи и вложений — только reporter/assignee (403 наблюдателю) - observers в create/update, DELETE /issues/:id/observers/me, фильтр role - история изменений и уведомления новым наблюдателям Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46903e9ec5
commit
b1f6b61994
54 files changed
+901
-69
No files matched your search
@@ -12,6 +12,9 @@ export class IssueWithUsersDto {
|
||||
reporterId: string | null;
|
||||
assignee: UserDto | null;
|
||||
reporter: UserDto | null;
|
||||
observerIds: string[];
|
||||
/** Удалённые пользователи сюда не попадают */
|
||||
observers: UserDto[];
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -8,6 +8,7 @@ export class IssueDto {
|
||||
type: string;
|
||||
assigneeId: string | null;
|
||||
reporterId: string | null;
|
||||
observerIds: string[];
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -15,6 +15,7 @@ export class IssueApplicationMapper {
|
||||
type: issue.type,
|
||||
assigneeId: issue.assignee,
|
||||
reporterId: issue.reporter,
|
||||
observerIds: [...issue.observers],
|
||||
projectId: issue.projectId,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
@@ -22,7 +23,7 @@ export class IssueApplicationMapper {
|
||||
};
|
||||
}
|
||||
|
||||
/** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */
|
||||
/** Раскрывает assignee/reporter/наблюдателей в полноценных пользователей (см. GetIssueUseCase) */
|
||||
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
|
||||
return {
|
||||
id: issue.id as number,
|
||||
@@ -36,6 +37,8 @@ export class IssueApplicationMapper {
|
||||
reporterId: issue.reporter,
|
||||
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
|
||||
reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null,
|
||||
observerIds: [...issue.observers],
|
||||
observers: issue.observers.flatMap((id) => usersById.get(id) ?? []),
|
||||
projectId: issue.projectId,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
|
||||
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.js';
|
||||
import { IssueObserverAddedEvent } from '../../domain/events/issue-observer-added.event.js';
|
||||
import type { Issue } from '../../domain/entities/issue.entity.js';
|
||||
|
||||
/**
|
||||
* Уведомляет нового исполнителя задачи (в т.ч. при самоназначении)
|
||||
* по всем включённым у него каналам.
|
||||
* и новых наблюдателей по всем включённым у них каналам.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueAssignmentNotifierService {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
notify(issue: Issue, domainEvents: unknown[]): void {
|
||||
this.notifyAssignee(issue, domainEvents);
|
||||
this.notifyObservers(issue, domainEvents);
|
||||
}
|
||||
|
||||
private notifyAssignee(issue: Issue, domainEvents: unknown[]): void {
|
||||
const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent);
|
||||
// Последнее назначение могло быть отменено снятием исполнителя в той же операции
|
||||
if (!assigned || assigned.assignee !== issue.assignee) return;
|
||||
@@ -22,4 +28,21 @@ export class IssueAssignmentNotifierService {
|
||||
content: issue.title,
|
||||
});
|
||||
}
|
||||
|
||||
private notifyObservers(issue: Issue, domainEvents: unknown[]): void {
|
||||
const added = new Set(
|
||||
domainEvents.filter((event) => event instanceof IssueObserverAddedEvent).map((event) => event.observer),
|
||||
);
|
||||
|
||||
// Наблюдатель мог быть снят или назначен исполнителем в той же операции
|
||||
for (const userId of added) {
|
||||
if (!issue.observers.includes(userId)) continue;
|
||||
|
||||
this.notificationsService.notify({
|
||||
userId,
|
||||
subject: `You are now observing ${issue.key}`,
|
||||
content: issue.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../project/domain/repositories/project.repository.interface.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 type { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueObserverNotFoundException } from '../../domain/exceptions/issue-observer-not-found.exception.js';
|
||||
|
||||
/** Участники задачи (assignee и наблюдатели) за пределами самой сущности */
|
||||
@Injectable()
|
||||
export class IssueParticipantsService {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
private readonly getUsersUseCase: GetUsersUseCase,
|
||||
) {}
|
||||
|
||||
/** Проверяет до сохранения, что новые наблюдатели существуют — иначе упадёт внешний ключ */
|
||||
async ensureObserversExist(userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const found = new Set(users.map((user) => user.id));
|
||||
const missing = userIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new IssueObserverNotFoundException(missing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнитель и наблюдатели становятся участниками проекта, чтобы видеть его в списке.
|
||||
* Наблюдатель — с ролью OBSERVER: задачи в проекте он создавать не может.
|
||||
*/
|
||||
async addToProject(issue: Issue): Promise<void> {
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee, 'MEMBER');
|
||||
}
|
||||
for (const userId of issue.observers) {
|
||||
await this.projectRepository.addMember(issue.projectId, userId, 'OBSERVER');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { IssueApplicationMapper } from '../mappers/issue-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';
|
||||
|
||||
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */
|
||||
/** Раскрывает assignee/reporter/наблюдателей задачи (или списка задач) в полноценных пользователей */
|
||||
@Injectable()
|
||||
export class IssueUserExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
@@ -19,7 +19,11 @@ export class IssueUserExpanderService {
|
||||
if (issues.length === 0) return [];
|
||||
|
||||
const userIds = [
|
||||
...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))),
|
||||
...new Set(
|
||||
issues
|
||||
.flatMap((issue) => [issue.reporter, issue.assignee, ...issue.observers])
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
|
||||
@@ -9,5 +9,6 @@ export class CreateIssueCommand {
|
||||
public readonly assignee?: string,
|
||||
public readonly dueDate?: Date,
|
||||
public readonly type?: string,
|
||||
public readonly observers?: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import { IssueUserExpanderService } from '../../services/issue-user-expander.ser
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
|
||||
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js';
|
||||
import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js';
|
||||
|
||||
describe('CreateIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
@@ -16,6 +21,7 @@ describe('CreateIssueUseCase', () => {
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let notificationsService: NotificationsService;
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let useCase: CreateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -35,6 +41,7 @@ describe('CreateIssueUseCase', () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findRole: vi.fn(async () => 'MEMBER' as const),
|
||||
addMember: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
@@ -46,12 +53,16 @@ describe('CreateIssueUseCase', () => {
|
||||
})),
|
||||
} as unknown as IssueUserExpanderService;
|
||||
notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
|
||||
getUsersUseCase = {
|
||||
execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))),
|
||||
} as unknown as GetUsersUseCase;
|
||||
useCase = new CreateIssueUseCase(
|
||||
repository,
|
||||
projectRepository,
|
||||
issueEventsService,
|
||||
issueUserExpander,
|
||||
new IssueAssignmentNotifierService(notificationsService),
|
||||
new IssueParticipantsService(projectRepository, getUsersUseCase),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -186,4 +197,53 @@ describe('CreateIssueUseCase', () => {
|
||||
expect(task.type).toBe('TASK');
|
||||
expect(bug.type).toBe('BUG');
|
||||
});
|
||||
|
||||
it('checks project access on behalf of the reporter', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1', 'project-1'));
|
||||
|
||||
expect(projectRepository.findRole).toHaveBeenCalledWith('project-1', 'user-1');
|
||||
});
|
||||
|
||||
it('refuses to create an issue in a project the reporter has no access to', async () => {
|
||||
vi.mocked(projectRepository.findRole).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-9', 'project-1')),
|
||||
).rejects.toBeInstanceOf(IssueProjectNotFoundException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
expect(projectRepository.addMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids a project observer from creating issues', async () => {
|
||||
vi.mocked(projectRepository.findRole).mockResolvedValue('OBSERVER');
|
||||
|
||||
await expect(
|
||||
useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-5', 'project-1')),
|
||||
).rejects.toBeInstanceOf(IssueProjectForbiddenException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the assignee to the project as a member and observers as observers', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
await useCase.execute(
|
||||
new CreateIssueCommand(
|
||||
'ISSUE-1',
|
||||
'Fix login bug',
|
||||
'user-1',
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
'user-2',
|
||||
undefined,
|
||||
undefined,
|
||||
['user-3'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-2', 'MEMBER');
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-3', 'OBSERVER');
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,14 @@ import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js';
|
||||
import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js';
|
||||
import { CreateIssueCommand } from './create-issue.command.js';
|
||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -22,6 +25,7 @@ export class CreateIssueUseCase {
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||
private readonly issueParticipants: IssueParticipantsService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
@@ -33,16 +37,26 @@ export class CreateIssueUseCase {
|
||||
type: command.type,
|
||||
assignee: command.assignee,
|
||||
reporter: command.reporter,
|
||||
observers: command.observers,
|
||||
projectId: command.projectId,
|
||||
dueDate: command.dueDate,
|
||||
});
|
||||
|
||||
// Создавать задачи может владелец или участник проекта, но не наблюдатель.
|
||||
// Чужой проект неотличим от несуществующего — 404, как и раньше.
|
||||
const role = await this.projectRepository.findRole(command.projectId, command.reporter);
|
||||
if (!role) {
|
||||
throw new IssueProjectNotFoundException(command.projectId);
|
||||
}
|
||||
if (role === 'OBSERVER') {
|
||||
throw new IssueProjectForbiddenException(command.projectId);
|
||||
}
|
||||
|
||||
await this.issueParticipants.ensureObserversExist([...draft.observers]);
|
||||
const issue = await this.issueRepository.create(draft);
|
||||
const dto = await this.issueUserExpander.expand(issue);
|
||||
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
||||
}
|
||||
await this.issueParticipants.addToProject(issue);
|
||||
|
||||
const domainEvents = draft.pullDomainEvents();
|
||||
this.issueAssignmentNotifier.notify(issue, domainEvents);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { DeleteIssueUseCase } from './delete-issue.use-case.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
@@ -17,6 +18,7 @@ const buildIssue = () =>
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
observers: ['user-5'],
|
||||
projectId: 'project-1',
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
@@ -73,4 +75,13 @@ describe('DeleteIssueUseCase', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('forbids an observer from deleting the issue', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await expect(useCase.execute(new DeleteIssueCommand(1, 'user-5'))).rejects.toBeInstanceOf(
|
||||
IssueEditForbiddenException,
|
||||
);
|
||||
expect(repository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
@@ -20,6 +21,10 @@ export class DeleteIssueUseCase {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (!issue.canBeEditedBy(command.userId)) {
|
||||
throw new IssueEditForbiddenException(command.id);
|
||||
}
|
||||
|
||||
await this.issueRepository.delete(command.id);
|
||||
|
||||
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IssueUserRole } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
|
||||
export class ListIssuesQuery {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
@@ -9,5 +11,6 @@ export class ListIssuesQuery {
|
||||
public readonly assigneeId?: string,
|
||||
public readonly hasDueDate?: boolean,
|
||||
public readonly type?: string[],
|
||||
public readonly role?: IssueUserRole,
|
||||
) {}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export class ListIssuesUseCase {
|
||||
assigneeId: query.assigneeId,
|
||||
hasDueDate: query.hasDueDate,
|
||||
type: query.type,
|
||||
role: query.role,
|
||||
});
|
||||
|
||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class UnobserveIssueCommand {
|
||||
constructor(
|
||||
public readonly id: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { UnobserveIssueUseCase } from './unobserve-issue.use-case.js';
|
||||
import { UnobserveIssueCommand } from './unobserve-issue.command.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Title',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
observers: ['user-5', 'user-6'],
|
||||
projectId: 'project-1',
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('UnobserveIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueEventsService: IssueEventsService;
|
||||
let useCase: UnobserveIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findByDueDateRange: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
useCase = new UnobserveIssueUseCase(repository, issueEventsService);
|
||||
});
|
||||
|
||||
it('removes the current user from observers on behalf of themselves', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
const received: unknown[] = [];
|
||||
issueEventsService.onIssueUpdated().subscribe((issue) => received.push(issue));
|
||||
|
||||
await useCase.execute(new UnobserveIssueCommand(1, 'user-5'));
|
||||
|
||||
const [saved, actorId] = vi.mocked(repository.update).mock.calls[0]!;
|
||||
expect(saved.observers).toEqual(['user-6']);
|
||||
expect(actorId).toBe('user-5');
|
||||
expect(received).toEqual([expect.objectContaining({ observerIds: ['user-6'] })]);
|
||||
});
|
||||
|
||||
it('does nothing for a user who is not an observer', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await useCase.execute(new UnobserveIssueCommand(1, 'user-1'));
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the issue is not visible', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new UnobserveIssueCommand(1, 'user-9'))).rejects.toBeInstanceOf(
|
||||
IssueNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { UnobserveIssueCommand } from './unobserve-issue.command.js';
|
||||
|
||||
/**
|
||||
* Наблюдатель сам отписывается от задачи. Редактировать задачу он не может,
|
||||
* поэтому это отдельная операция, а не часть UpdateIssueUseCase.
|
||||
* Повторный вызов (или вызов не наблюдателем) ничего не меняет.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnobserveIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
) {}
|
||||
|
||||
async execute(command: UnobserveIssueCommand): Promise<void> {
|
||||
const issue = await this.issueRepository.findById(command.id, command.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (!issue.observers.includes(command.userId)) return;
|
||||
|
||||
issue.removeObserver(command.userId);
|
||||
await this.issueRepository.update(issue, command.userId);
|
||||
|
||||
this.issueEventsService.emitIssueUpdated(IssueApplicationMapper.toDto(issue));
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,7 @@ export class UpdateIssueCommand {
|
||||
public readonly assignee?: string | null,
|
||||
public readonly dueDate?: Date | null,
|
||||
public readonly type?: string,
|
||||
/** Полный новый список наблюдателей */
|
||||
public readonly observers?: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { IIssueRepository } from '../../../domain/repositories/issue.reposi
|
||||
import type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { IssueObserverNotFoundException } from '../../../domain/exceptions/issue-observer-not-found.exception.js';
|
||||
import { UpdateIssueUseCase } from './update-issue.use-case.js';
|
||||
import { UpdateIssueCommand } from './update-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
@@ -10,8 +12,11 @@ import { IssueUserExpanderService } from '../../services/issue-user-expander.ser
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
|
||||
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
const buildIssue = (overrides: { assignee?: string | null; observers?: string[] } = {}) =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
@@ -26,6 +31,7 @@ const buildIssue = () =>
|
||||
dueDate: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UpdateIssueUseCase', () => {
|
||||
@@ -34,6 +40,7 @@ describe('UpdateIssueUseCase', () => {
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let notificationsService: NotificationsService;
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let useCase: UpdateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,6 +60,7 @@ describe('UpdateIssueUseCase', () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findRole: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
@@ -64,12 +72,15 @@ describe('UpdateIssueUseCase', () => {
|
||||
})),
|
||||
} as unknown as IssueUserExpanderService;
|
||||
notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
|
||||
getUsersUseCase = {
|
||||
execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))),
|
||||
} as unknown as GetUsersUseCase;
|
||||
useCase = new UpdateIssueUseCase(
|
||||
repository,
|
||||
projectRepository,
|
||||
issueEventsService,
|
||||
issueUserExpander,
|
||||
new IssueAssignmentNotifierService(notificationsService),
|
||||
new IssueParticipantsService(projectRepository, getUsersUseCase),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,7 +96,7 @@ describe('UpdateIssueUseCase', () => {
|
||||
});
|
||||
|
||||
it('passes the acting user to the repository as the author of the changes', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ assignee: 'user-7' }));
|
||||
|
||||
await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
|
||||
|
||||
@@ -166,4 +177,77 @@ describe('UpdateIssueUseCase', () => {
|
||||
|
||||
expect(dto.type).toBe('BUG');
|
||||
});
|
||||
|
||||
describe('observers', () => {
|
||||
it('forbids an observer from editing the issue', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] }));
|
||||
|
||||
await expect(useCase.execute(new UpdateIssueCommand(1, 'user-5', 'Hijacked'))).rejects.toBeInstanceOf(
|
||||
IssueEditForbiddenException,
|
||||
);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces observers, adds them to the project and notifies only the new ones', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5', 'user-6'] }));
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
1,
|
||||
'user-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['user-6', 'user-8'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(dto.observerIds).toEqual(['user-6', 'user-8']);
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-8'] }));
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-8', 'OBSERVER');
|
||||
expect(notificationsService.notify).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsService.notify).toHaveBeenCalledWith({
|
||||
userId: 'user-8',
|
||||
subject: 'You are now observing ISSUE-1',
|
||||
content: 'Original title',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown users before saving', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await expect(
|
||||
useCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
1,
|
||||
'user-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['ghost'],
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(IssueObserverNotFoundException);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the new assignee from observers', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] }));
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-5'),
|
||||
);
|
||||
|
||||
expect(dto.assigneeId).toBe('user-5');
|
||||
expect(dto.observerIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { UpdateIssueCommand } from './update-issue.command.js';
|
||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,11 +15,10 @@ export class UpdateIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||
private readonly issueParticipants: IssueParticipantsService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
@@ -31,6 +28,12 @@ export class UpdateIssueUseCase {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (!issue.canBeEditedBy(command.userId)) {
|
||||
throw new IssueEditForbiddenException(command.id);
|
||||
}
|
||||
|
||||
const observersBefore = [...issue.observers];
|
||||
|
||||
if (command.title !== undefined) {
|
||||
issue.updateTitle(command.title);
|
||||
}
|
||||
@@ -63,12 +66,16 @@ export class UpdateIssueUseCase {
|
||||
issue.updateDueDate(command.dueDate);
|
||||
}
|
||||
|
||||
// После assignee: новый исполнитель отбрасывается из наблюдателей
|
||||
if (command.observers !== undefined) {
|
||||
issue.setObservers(command.observers);
|
||||
}
|
||||
|
||||
await this.issueParticipants.ensureObserversExist(issue.observers.filter((id) => !observersBefore.includes(id)));
|
||||
await this.issueRepository.update(issue, command.userId);
|
||||
const dto = await this.issueUserExpander.expand(issue);
|
||||
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
||||
}
|
||||
await this.issueParticipants.addToProject(issue);
|
||||
|
||||
const domainEvents = issue.pullDomainEvents();
|
||||
this.issueAssignmentNotifier.notify(issue, domainEvents);
|
||||
|
||||
Reference in new issue
Block a user