create issues fix
Gitea Actions / Deploy (push) Canceled after 5m13s

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:
vadimandClaude Opus 5.5 committed 2026-10-25 22:23:49 +03:00
1 parent 46903e9ec5
commit b1f6b61994
54 files changed
+899 -67

No files matched your search

@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "issue_observer" (
"issueId" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "issue_observer_pkey" PRIMARY KEY ("issueId","userId")
);
-- CreateIndex
CREATE INDEX "issue_observer_userId_idx" ON "issue_observer"("userId");
-- AddForeignKey
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ProjectRole" AS ENUM ('MEMBER', 'OBSERVER');
-- AlterTable
ALTER TABLE "project_member" ADD COLUMN "role" "ProjectRole" NOT NULL DEFAULT 'MEMBER';
+25
View File
@@ -31,6 +31,7 @@ model User {
comments Comment[] comments Comment[]
uploadedFiles File[] @relation(name: "fileUploader") uploadedFiles File[] @relation(name: "fileUploader")
issueChanges IssueChange[] issueChanges IssueChange[]
observedIssues IssueObserver[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -103,6 +104,7 @@ model Issue {
comments Comment[] comments Comment[]
attachments IssueAttachment[] attachments IssueAttachment[]
changes IssueChange[] changes IssueChange[]
observers IssueObserver[]
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -118,6 +120,21 @@ model Issue {
@@map("issue") @@map("issue")
} }
/// Наблюдатель задачи: видит её и может комментировать, но не редактировать.
/// Редактируют задачу только reporter и assignee.
model IssueObserver {
issueId Int
userId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([issueId, userId])
@@index([userId])
@@map("issue_observer")
}
model Project { model Project {
id String @id @default(uuid(7)) id String @id @default(uuid(7))
name String name String
@@ -135,10 +152,18 @@ model Project {
@@map("project") @@map("project")
} }
/// MEMBER создаёт задачи в проекте; OBSERVER только видит проект —
/// он наблюдает за отдельными задачами (IssueObserver).
enum ProjectRole {
MEMBER
OBSERVER
}
model ProjectMember { model ProjectMember {
id String @id @default(uuid(7)) id String @id @default(uuid(7))
projectId String projectId String
userId String userId String
role ProjectRole @default(MEMBER)
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@ -5,7 +5,7 @@ export class IssueFieldChangeDto {
field: IssueChangeField; field: IssueChangeField;
oldValue: string | null; oldValue: string | null;
newValue: string | null; newValue: string | null;
/** Только для field = assignee: пользователи из oldValue/newValue (null — не назначен или удалён) */ /** Только для field = assignee/observer: пользователи из oldValue/newValue (null — не назначен или удалён) */
oldUser?: UserDto | null; oldUser?: UserDto | null;
newUser?: UserDto | null; newUser?: UserDto | null;
} }
@@ -10,7 +10,7 @@ import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
/** /**
* Статистика задач по дням, считается по истории изменений (issue_change). * Статистика задач по дням, считается по истории изменений (issue_change).
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee. * Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель.
*/ */
@Injectable() @Injectable()
export class GetDailyIssueStatsUseCase { export class GetDailyIssueStatsUseCase {
@@ -14,6 +14,10 @@ import {
} from '../../../domain/repositories/issue-history.repository.interface.js'; } from '../../../domain/repositories/issue-history.repository.interface.js';
import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js'; import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js';
import { GetIssueHistoryQuery } from './get-issue-history.query.js'; import { GetIssueHistoryQuery } from './get-issue-history.query.js';
import type { IssueChangeField } from '../../../../issue/domain/entities/issue-change.js';
/** Поля, значения которых — id пользователей */
const USER_FIELDS = new Set<IssueChangeField>(['assignee', 'observer']);
@Injectable() @Injectable()
export class GetIssueHistoryUseCase { export class GetIssueHistoryUseCase {
@@ -55,7 +59,7 @@ export class GetIssueHistoryUseCase {
oldValue: record.oldValue, oldValue: record.oldValue,
newValue: record.newValue, newValue: record.newValue,
}; };
if (record.field === 'assignee') { if (USER_FIELDS.has(record.field)) {
change.oldUser = userOf(record.oldValue); change.oldUser = userOf(record.oldValue);
change.newUser = userOf(record.newValue); change.newUser = userOf(record.newValue);
} }
@@ -65,12 +69,12 @@ export class GetIssueHistoryUseCase {
return [...entries.values()]; return [...entries.values()];
} }
/** Авторы изменений и назначенные/снятые исполнители */ /** Авторы изменений, назначенные/снятые исполнители и наблюдатели */
private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> { private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> {
const ids = new Set<string>(); const ids = new Set<string>();
for (const record of records) { for (const record of records) {
if (record.actorId) ids.add(record.actorId); if (record.actorId) ids.add(record.actorId);
if (record.field === 'assignee') { if (USER_FIELDS.has(record.field)) {
if (record.oldValue) ids.add(record.oldValue); if (record.oldValue) ids.add(record.oldValue);
if (record.newValue) ids.add(record.newValue); if (record.newValue) ids.add(record.newValue);
} }
@@ -39,8 +39,8 @@ export interface DailyStatusCount {
export interface IIssueHistoryRepository { export interface IIssueHistoryRepository {
/** История задачи от старых изменений к новым */ /** История задачи от старых изменений к новым */
findByIssue(issueId: number): Promise<IssueChangeRecord[]>; findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
/** Только задачи, видимые пользователю (reporter/assignee) */ /** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>; countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
/** Только задачи, видимые пользователю (reporter/assignee) */ /** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>; countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
} }
@@ -11,13 +11,14 @@ import type {
} from '../../../../domain/repositories/issue-history.repository.interface.js'; } from '../../../../domain/repositories/issue-history.repository.interface.js';
/** /**
* Задачи, видимые пользователю: reporter или assignee — то же правило, что * Задачи, видимые пользователю: reporter, assignee или наблюдатель — то же
* ownedByUser в IssuePrismaRepository. Возвращает CTE "visible" (id задач). * правило, что visibleTo в IssuePrismaRepository. Возвращает CTE "visible" (id задач).
*/ */
function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql { function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql {
return Prisma.sql`"visible" AS ( return Prisma.sql`"visible" AS (
SELECT "id" FROM "issue" SELECT "id" FROM "issue"
WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId}) WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId}
OR EXISTS (SELECT 1 FROM "issue_observer" o WHERE o."issueId" = "issue"."id" AND o."userId" = ${range.userId}))
${range.projectId ? Prisma.sql`AND "projectId" = ${range.projectId}` : Prisma.empty} ${range.projectId ? Prisma.sql`AND "projectId" = ${range.projectId}` : Prisma.empty}
)`; )`;
} }
@@ -36,7 +36,7 @@ export class IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit
/** /**
* Файл виден, если видна хоть одна задача, к которой он прикреплён. * Файл виден, если видна хоть одна задача, к которой он прикреплён.
* Видимость проверяем через IIssueRepository.findById, чтобы правило * Видимость проверяем через IIssueRepository.findById, чтобы правило
* (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче. * (reporter/assignee/наблюдатель) жило в одном месте. Обычно файл прикреплён к одной задаче.
*/ */
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> { async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId); const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
@@ -8,6 +8,7 @@ import {
type IIssueRepository, type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js'; } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js'; import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js'; import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js';
import { AttachFileCommand } from './attach-file.command.js'; import { AttachFileCommand } from './attach-file.command.js';
@@ -15,7 +16,7 @@ import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js';
/** /**
* Прикрепляет к задаче файл, заранее загруженный через POST /files. * Прикрепляет к задаче файл, заранее загруженный через POST /files.
* Прикреплять может любой, кому видна задача (как и править её), * Прикреплять может тот, кто может править задачу (не наблюдатель),
* но только собственный файл — см. StoredFile.assertOwnedBy. * но только собственный файл — см. StoredFile.assertOwnedBy.
*/ */
@Injectable() @Injectable()
@@ -29,9 +30,13 @@ export class AttachFileUseCase {
) {} ) {}
async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> { async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> {
if (!(await this.issueRepository.findById(command.issueId, command.userId))) { const issue = await this.issueRepository.findById(command.issueId, command.userId);
if (!issue) {
throw new IssueNotFoundException(command.issueId); throw new IssueNotFoundException(command.issueId);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.issueId);
}
const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId); const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId);
const attachment = await this.attachmentRepository.create( const attachment = await this.attachmentRepository.create(
@@ -8,12 +8,14 @@ import {
type IIssueRepository, type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js'; } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js'; import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js';
import { DetachFileCommand } from './detach-file.command.js'; import { DetachFileCommand } from './detach-file.command.js';
/** /**
* Открепляет файл от задачи. Сам файл не удаляется: если он больше * Открепляет файл от задачи. Сам файл не удаляется: если он больше
* ни к чему не прикреплён, его удалит GC file-модуля. * ни к чему не прикреплён, его удалит GC file-модуля.
* Наблюдателям задачи недоступно — как и её правка.
*/ */
@Injectable() @Injectable()
export class DetachFileUseCase { export class DetachFileUseCase {
@@ -25,9 +27,13 @@ export class DetachFileUseCase {
) {} ) {}
async execute(command: DetachFileCommand): Promise<void> { async execute(command: DetachFileCommand): Promise<void> {
if (!(await this.issueRepository.findById(command.issueId, command.userId))) { const issue = await this.issueRepository.findById(command.issueId, command.userId);
if (!issue) {
throw new IssueNotFoundException(command.issueId); throw new IssueNotFoundException(command.issueId);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.issueId);
}
if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) { if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) {
throw new IssueAttachmentNotFoundException(command.issueId, command.fileId); throw new IssueAttachmentNotFoundException(command.issueId, command.fileId);
@@ -23,6 +23,7 @@ import { IssueAttachmentNotFoundException } from '../../domain/exceptions/issue-
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js'; import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js'; import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
import { IssueEditForbiddenException } from '../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
/** /**
* Вложения задачи. Сам файл загружается отдельно (POST /files), * Вложения задачи. Сам файл загружается отдельно (POST /files),
@@ -78,7 +79,7 @@ export class IssueAttachmentController {
) { ) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof FileForbiddenException) { if (error instanceof FileForbiddenException || error instanceof IssueEditForbiddenException) {
throw new ForbiddenException(error.message); throw new ForbiddenException(error.message);
} }
throw error; throw error;
@@ -12,6 +12,9 @@ export class IssueWithUsersDto {
reporterId: string | null; reporterId: string | null;
assignee: UserDto | null; assignee: UserDto | null;
reporter: UserDto | null; reporter: UserDto | null;
observerIds: string[];
/** Удалённые пользователи сюда не попадают */
observers: UserDto[];
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -8,6 +8,7 @@ export class IssueDto {
type: string; type: string;
assigneeId: string | null; assigneeId: string | null;
reporterId: string | null; reporterId: string | null;
observerIds: string[];
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -15,6 +15,7 @@ export class IssueApplicationMapper {
type: issue.type, type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
observerIds: [...issue.observers],
projectId: issue.projectId, projectId: issue.projectId,
dueDate: issue.dueDate, dueDate: issue.dueDate,
createdAt: issue.createdAt, 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 { static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
return { return {
id: issue.id as number, id: issue.id as number,
@@ -36,6 +37,8 @@ export class IssueApplicationMapper {
reporterId: issue.reporter, reporterId: issue.reporter,
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null, assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
reporter: issue.reporter ? (usersById.get(issue.reporter) ?? 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, projectId: issue.projectId,
dueDate: issue.dueDate, dueDate: issue.dueDate,
createdAt: issue.createdAt, createdAt: issue.createdAt,
@@ -1,17 +1,23 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { NotificationsService } from '../../../notification/application/services/notifications.service.js'; import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.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'; import type { Issue } from '../../domain/entities/issue.entity.js';
/** /**
* Уведомляет нового исполнителя задачи (в т.ч. при самоназначении) * Уведомляет нового исполнителя задачи (в т.ч. при самоназначении)
* по всем включённым у него каналам. * и новых наблюдателей по всем включённым у них каналам.
*/ */
@Injectable() @Injectable()
export class IssueAssignmentNotifierService { export class IssueAssignmentNotifierService {
constructor(private readonly notificationsService: NotificationsService) {} constructor(private readonly notificationsService: NotificationsService) {}
notify(issue: Issue, domainEvents: unknown[]): void { 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); const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent);
// Последнее назначение могло быть отменено снятием исполнителя в той же операции // Последнее назначение могло быть отменено снятием исполнителя в той же операции
if (!assigned || assigned.assignee !== issue.assignee) return; if (!assigned || assigned.assignee !== issue.assignee) return;
@@ -22,4 +28,21 @@ export class IssueAssignmentNotifierService {
content: issue.title, 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 { 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 { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */ /** Раскрывает assignee/reporter/наблюдателей задачи (или списка задач) в полноценных пользователей */
@Injectable() @Injectable()
export class IssueUserExpanderService { export class IssueUserExpanderService {
constructor(private readonly getUsersUseCase: GetUsersUseCase) {} constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
@@ -19,7 +19,11 @@ export class IssueUserExpanderService {
if (issues.length === 0) return []; if (issues.length === 0) return [];
const userIds = [ 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 users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
const usersById = new Map(users.map((user) => [user.id, user])); const usersById = new Map(users.map((user) => [user.id, user]));
@@ -9,5 +9,6 @@ export class CreateIssueCommand {
public readonly assignee?: string, public readonly assignee?: string,
public readonly dueDate?: Date, public readonly dueDate?: Date,
public readonly type?: string, 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 { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.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', () => { describe('CreateIssueUseCase', () => {
let repository: IIssueRepository; let repository: IIssueRepository;
@@ -16,6 +21,7 @@ describe('CreateIssueUseCase', () => {
let issueEventsService: IssueEventsService; let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService; let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService; let notificationsService: NotificationsService;
let getUsersUseCase: GetUsersUseCase;
let useCase: CreateIssueUseCase; let useCase: CreateIssueUseCase;
beforeEach(() => { beforeEach(() => {
@@ -35,6 +41,7 @@ describe('CreateIssueUseCase', () => {
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
findRole: vi.fn(async () => 'MEMBER' as const),
addMember: vi.fn(), addMember: vi.fn(),
}; };
issueEventsService = new IssueEventsService(); issueEventsService = new IssueEventsService();
@@ -46,12 +53,16 @@ describe('CreateIssueUseCase', () => {
})), })),
} as unknown as IssueUserExpanderService; } as unknown as IssueUserExpanderService;
notificationsService = { notify: vi.fn() } as unknown as NotificationsService; 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( useCase = new CreateIssueUseCase(
repository, repository,
projectRepository, projectRepository,
issueEventsService, issueEventsService,
issueUserExpander, issueUserExpander,
new IssueAssignmentNotifierService(notificationsService), new IssueAssignmentNotifierService(notificationsService),
new IssueParticipantsService(projectRepository, getUsersUseCase),
); );
}); });
@@ -186,4 +197,53 @@ describe('CreateIssueUseCase', () => {
expect(task.type).toBe('TASK'); expect(task.type).toBe('TASK');
expect(bug.type).toBe('BUG'); 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, PROJECT_REPOSITORY,
type IProjectRepository, type IProjectRepository,
} from '../../../../project/domain/repositories/project.repository.interface.js'; } 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 { CreateIssueCommand } from './create-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.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'; import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
@Injectable() @Injectable()
@@ -22,6 +25,7 @@ export class CreateIssueUseCase {
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService, private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService, private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
private readonly issueParticipants: IssueParticipantsService,
) {} ) {}
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> { async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
@@ -33,16 +37,26 @@ export class CreateIssueUseCase {
type: command.type, type: command.type,
assignee: command.assignee, assignee: command.assignee,
reporter: command.reporter, reporter: command.reporter,
observers: command.observers,
projectId: command.projectId, projectId: command.projectId,
dueDate: command.dueDate, 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 issue = await this.issueRepository.create(draft);
const dto = await this.issueUserExpander.expand(issue); const dto = await this.issueUserExpander.expand(issue);
if (issue.assignee) { await this.issueParticipants.addToProject(issue);
await this.projectRepository.addMember(issue.projectId, issue.assignee);
}
const domainEvents = draft.pullDomainEvents(); const domainEvents = draft.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents); 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 type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js'; import { Issue } from '../../../domain/entities/issue.entity.js';
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.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 { DeleteIssueUseCase } from './delete-issue.use-case.js';
import { DeleteIssueCommand } from './delete-issue.command.js'; import { DeleteIssueCommand } from './delete-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
@@ -17,6 +18,7 @@ const buildIssue = () =>
type: 'TASK', type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
observers: ['user-5'],
projectId: 'project-1', projectId: 'project-1',
dueDate: null, dueDate: null,
createdAt: new Date(), 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 { Inject, Injectable } from '@nestjs/common';
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.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 { DeleteIssueCommand } from './delete-issue.command.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
@@ -20,6 +21,10 @@ export class DeleteIssueUseCase {
throw new IssueNotFoundException(command.id); throw new IssueNotFoundException(command.id);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.id);
}
await this.issueRepository.delete(command.id); await this.issueRepository.delete(command.id);
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue)); this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
@@ -1,3 +1,5 @@
import type { IssueUserRole } from '../../../domain/repositories/issue.repository.interface.js';
export class ListIssuesQuery { export class ListIssuesQuery {
constructor( constructor(
public readonly limit: number, public readonly limit: number,
@@ -9,5 +11,6 @@ export class ListIssuesQuery {
public readonly assigneeId?: string, public readonly assigneeId?: string,
public readonly hasDueDate?: boolean, public readonly hasDueDate?: boolean,
public readonly type?: string[], public readonly type?: string[],
public readonly role?: IssueUserRole,
) {} ) {}
} }
@@ -24,6 +24,7 @@ export class ListIssuesUseCase {
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
hasDueDate: query.hasDueDate, hasDueDate: query.hasDueDate,
type: query.type, type: query.type,
role: query.role,
}); });
return { list: await this.issueUserExpander.expandAll(list), meta }; return { list: await this.issueUserExpander.expandAll(list), meta };
@@ -0,0 +1,6 @@
export class UnobserveIssueCommand {
constructor(
public readonly id: number,
public readonly userId: string,
) {}
}
@@ -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 assignee?: string | null,
public readonly dueDate?: Date | null, public readonly dueDate?: Date | null,
public readonly type?: string, 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 type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js'; import { Issue } from '../../../domain/entities/issue.entity.js';
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.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 { UpdateIssueUseCase } from './update-issue.use-case.js';
import { UpdateIssueCommand } from './update-issue.command.js'; import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.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 { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.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({ Issue.reconstitute({
id: 1, id: 1,
key: 'ISSUE-1', key: 'ISSUE-1',
@@ -26,6 +31,7 @@ const buildIssue = () =>
dueDate: null, dueDate: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
}); });
describe('UpdateIssueUseCase', () => { describe('UpdateIssueUseCase', () => {
@@ -34,6 +40,7 @@ describe('UpdateIssueUseCase', () => {
let issueEventsService: IssueEventsService; let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService; let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService; let notificationsService: NotificationsService;
let getUsersUseCase: GetUsersUseCase;
let useCase: UpdateIssueUseCase; let useCase: UpdateIssueUseCase;
beforeEach(() => { beforeEach(() => {
@@ -53,6 +60,7 @@ describe('UpdateIssueUseCase', () => {
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
findRole: vi.fn(),
addMember: vi.fn(), addMember: vi.fn(),
}; };
issueEventsService = new IssueEventsService(); issueEventsService = new IssueEventsService();
@@ -64,12 +72,15 @@ describe('UpdateIssueUseCase', () => {
})), })),
} as unknown as IssueUserExpanderService; } as unknown as IssueUserExpanderService;
notificationsService = { notify: vi.fn() } as unknown as NotificationsService; 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( useCase = new UpdateIssueUseCase(
repository, repository,
projectRepository,
issueEventsService, issueEventsService,
issueUserExpander, issueUserExpander,
new IssueAssignmentNotifierService(notificationsService), 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 () => { 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')); await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
@@ -166,4 +177,77 @@ describe('UpdateIssueUseCase', () => {
expect(dto.type).toBe('BUG'); 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 { Inject, Injectable } from '@nestjs/common';
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; 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 { 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 { UpdateIssueCommand } from './update-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.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'; import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
@Injectable() @Injectable()
@@ -17,11 +15,10 @@ export class UpdateIssueUseCase {
constructor( constructor(
@Inject(ISSUE_REPOSITORY) @Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository, private readonly issueRepository: IIssueRepository,
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService, private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService, private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
private readonly issueParticipants: IssueParticipantsService,
) {} ) {}
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> { async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
@@ -31,6 +28,12 @@ export class UpdateIssueUseCase {
throw new IssueNotFoundException(command.id); throw new IssueNotFoundException(command.id);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.id);
}
const observersBefore = [...issue.observers];
if (command.title !== undefined) { if (command.title !== undefined) {
issue.updateTitle(command.title); issue.updateTitle(command.title);
} }
@@ -63,12 +66,16 @@ export class UpdateIssueUseCase {
issue.updateDueDate(command.dueDate); 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); await this.issueRepository.update(issue, command.userId);
const dto = await this.issueUserExpander.expand(issue); const dto = await this.issueUserExpander.expand(issue);
if (issue.assignee) { await this.issueParticipants.addToProject(issue);
await this.projectRepository.addMember(issue.projectId, issue.assignee);
}
const domainEvents = issue.pullDomainEvents(); const domainEvents = issue.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents); this.issueAssignmentNotifier.notify(issue, domainEvents);
@@ -11,6 +11,7 @@ export const ISSUE_CHANGE_FIELDS = [
'type', 'type',
'assignee', 'assignee',
'dueDate', 'dueDate',
'observer',
] as const; ] as const;
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number]; export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
@@ -18,6 +19,8 @@ export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
/** /**
* Изменение одного поля задачи. Значения сериализованы в строки: * Изменение одного поля задачи. Значения сериализованы в строки:
* priority — число, dueDate — ISO-дата, assignee — id пользователя. * priority — число, dueDate — ISO-дата, assignee — id пользователя.
* observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id)
* или снят (oldValue = id, newValue = null).
*/ */
export interface IssueFieldChange { export interface IssueFieldChange {
field: IssueChangeField; field: IssueChangeField;
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { Issue } from './issue.entity.js'; import { Issue } from './issue.entity.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js'; import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
import { IssueObserverAddedEvent } from '../events/issue-observer-added.event.js';
describe('Issue', () => { describe('Issue', () => {
describe('create', () => { describe('create', () => {
@@ -248,4 +249,92 @@ describe('Issue', () => {
expect(issue.pullChanges()).toEqual([{ field: 'status', oldValue: 'OPEN', newValue: 'DONE' }]); expect(issue.pullChanges()).toEqual([{ field: 'status', oldValue: 'OPEN', newValue: 'DONE' }]);
}); });
}); });
describe('observers', () => {
const load = (observers: string[] = []) =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Title',
content: null,
status: 'OPEN',
priority: 0,
type: 'TASK',
assignee: 'user-2',
reporter: 'user-1',
observers,
projectId: 'project-1',
dueDate: null,
createdAt: new Date(),
updatedAt: new Date(),
});
it('only the reporter and the assignee can edit the issue', () => {
const issue = load(['user-3']);
expect(issue.canBeEditedBy('user-1')).toBe(true);
expect(issue.canBeEditedBy('user-2')).toBe(true);
expect(issue.canBeEditedBy('user-3')).toBe(false);
});
it('creates an issue with observers, skipping duplicates, the reporter and the assignee', () => {
const issue = Issue.create({
key: 'ISSUE-1',
title: 'Title',
reporter: 'user-1',
assignee: 'user-2',
observers: ['user-3', 'user-1', 'user-2', 'user-3', 'user-4'],
projectId: 'project-1',
});
expect(issue.observers).toEqual(['user-3', 'user-4']);
expect(issue.pullDomainEvents().filter((event) => event instanceof IssueObserverAddedEvent)).toEqual([
expect.objectContaining({ observer: 'user-3' }),
expect.objectContaining({ observer: 'user-4' }),
]);
expect(issue.pullChanges().map((change) => change.field)).toEqual(['created', 'status']);
});
it('records one history row per added or removed observer', () => {
const issue = load(['user-3', 'user-4']);
issue.setObservers(['user-4', 'user-5']);
expect(issue.observers).toEqual(['user-4', 'user-5']);
expect(issue.pullChanges()).toEqual([
{ field: 'observer', oldValue: 'user-3', newValue: null },
{ field: 'observer', oldValue: null, newValue: 'user-5' },
]);
});
it('does nothing when the list is unchanged', () => {
const issue = load(['user-3']);
issue.setObservers(['user-3', 'user-1']);
expect(issue.pullChanges()).toEqual([]);
expect(issue.pullDomainEvents()).toEqual([]);
});
it('cancels out adding and removing the same observer', () => {
const issue = load();
issue.addObserver('user-3');
issue.removeObserver('user-3');
expect(issue.pullChanges()).toEqual([]);
});
it('removes the observer who becomes the assignee', () => {
const issue = load(['user-3']);
issue.assignTo('user-3');
expect(issue.observers).toEqual([]);
expect(issue.pullChanges()).toEqual([
{ field: 'assignee', oldValue: 'user-2', newValue: 'user-3' },
{ field: 'observer', oldValue: 'user-3', newValue: null },
]);
});
});
}); });
@@ -5,6 +5,7 @@ import { IssueType } from '../value-objects/issue-type.vo.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueUpdatedEvent } from '../events/issue-updated.event.js'; import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js'; import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
import { IssueObserverAddedEvent } from '../events/issue-observer-added.event.js';
import type { IssueChangeField, IssueFieldChange } from './issue-change.js'; import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
interface IssueProps { interface IssueProps {
@@ -18,6 +19,8 @@ interface IssueProps {
assignee: string | null; assignee: string | null;
/** null — автор задачи удалил аккаунт */ /** null — автор задачи удалил аккаунт */
reporter: string | null; reporter: string | null;
/** Видят задачу и комментируют, но не редактируют. Не пересекаются с reporter/assignee. */
observers: string[];
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -37,8 +40,11 @@ interface IssueProps {
*/ */
export class Issue { export class Issue {
private domainEvents: unknown[] = []; private domainEvents: unknown[] = [];
/** Несохранённые изменения полей, по одному на поле, в порядке изменения */ /**
private changes = new Map<IssueChangeField, IssueFieldChange>(); * Несохранённые изменения полей, по одному на поле, в порядке изменения.
* Ключ — поле, для observer — поле и id наблюдателя.
*/
private changes = new Map<string, IssueFieldChange>();
private constructor(private readonly props: IssueProps) {} private constructor(private readonly props: IssueProps) {}
@@ -51,6 +57,7 @@ export class Issue {
type?: string; type?: string;
assignee?: string | null; assignee?: string | null;
reporter: string; reporter: string;
observers?: string[];
projectId: string; projectId: string;
dueDate?: Date | null; dueDate?: Date | null;
}): Issue { }): Issue {
@@ -64,6 +71,7 @@ export class Issue {
type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(), type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(),
assignee: params.assignee ?? null, assignee: params.assignee ?? null,
reporter: params.reporter, reporter: params.reporter,
observers: [],
projectId: params.projectId, projectId: params.projectId,
dueDate: params.dueDate ?? null, dueDate: params.dueDate ?? null,
createdAt: new Date(), createdAt: new Date(),
@@ -76,6 +84,11 @@ export class Issue {
if (issue.assignee) { if (issue.assignee) {
issue.addDomainEvent(new IssueAssignedEvent(issue.key, issue.assignee)); issue.addDomainEvent(new IssueAssignedEvent(issue.key, issue.assignee));
} }
// Как и assignee, наблюдатели при создании в историю не пишутся — только события
for (const userId of issue.normalizeObservers(params.observers ?? [])) {
issue.props.observers.push(userId);
issue.addDomainEvent(new IssueObserverAddedEvent(issue.key, userId));
}
return issue; return issue;
} }
@@ -91,6 +104,7 @@ export class Issue {
type: string; type: string;
assignee: string | null; assignee: string | null;
reporter: string | null; reporter: string | null;
observers?: string[];
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -106,6 +120,7 @@ export class Issue {
type: IssueType.create(props.type), type: IssueType.create(props.type),
assignee: props.assignee, assignee: props.assignee,
reporter: props.reporter, reporter: props.reporter,
observers: [...(props.observers ?? [])],
projectId: props.projectId, projectId: props.projectId,
dueDate: props.dueDate, dueDate: props.dueDate,
createdAt: props.createdAt, createdAt: props.createdAt,
@@ -149,6 +164,15 @@ export class Issue {
return this.props.reporter; return this.props.reporter;
} }
get observers(): readonly string[] {
return this.props.observers;
}
/** Редактировать задачу могут только reporter и assignee, наблюдатели — нет */
canBeEditedBy(userId: string): boolean {
return this.reporter === userId || this.assignee === userId;
}
get projectId(): string { get projectId(): string {
return this.props.projectId; return this.props.projectId;
} }
@@ -200,6 +224,8 @@ export class Issue {
this.recordChange('assignee', this.assignee, userId); this.recordChange('assignee', this.assignee, userId);
this.props.assignee = userId; this.props.assignee = userId;
this.addDomainEvent(new IssueAssignedEvent(this.key, userId)); this.addDomainEvent(new IssueAssignedEvent(this.key, userId));
// Исполнитель и так видит задачу и может её редактировать
this.removeObserver(userId);
} }
unassign(): void { unassign(): void {
@@ -212,6 +238,35 @@ export class Issue {
this.props.dueDate = dueDate; this.props.dueDate = dueDate;
} }
/**
* Заменяет список наблюдателей. reporter и assignee отбрасываются:
* у них и так больше прав, чем у наблюдателя.
*/
setObservers(userIds: string[]): void {
const next = this.normalizeObservers(userIds);
for (const userId of this.observers.filter((id) => !next.includes(id))) {
this.removeObserver(userId);
}
for (const userId of next) {
this.addObserver(userId);
}
}
addObserver(userId: string): void {
if (this.observers.includes(userId) || this.normalizeObservers([userId]).length === 0) return;
this.recordChange('observer', null, userId, `observer:${userId}`);
this.props.observers.push(userId);
this.addDomainEvent(new IssueObserverAddedEvent(this.key, userId));
}
removeObserver(userId: string): void {
if (!this.observers.includes(userId)) return;
this.recordChange('observer', userId, null, `observer:${userId}`);
this.props.observers = this.props.observers.filter((id) => id !== userId);
}
/** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */ /** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */
pullChanges(): IssueFieldChange[] { pullChanges(): IssueFieldChange[] {
const changes = [...this.changes.values()]; const changes = [...this.changes.values()];
@@ -224,14 +279,19 @@ export class Issue {
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля * ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
* сливается с предыдущей, а возврат к исходному значению её отменяет. * сливается с предыдущей, а возврат к исходному значению её отменяет.
*/ */
private recordChange(field: IssueChangeField, oldValue: string | null, newValue: string | null): void { private recordChange(
field: IssueChangeField,
oldValue: string | null,
newValue: string | null,
key: string = field,
): void {
if (oldValue === newValue) return; if (oldValue === newValue) return;
const pending = this.changes.get(field); const pending = this.changes.get(key);
if (!pending) { if (!pending) {
this.changes.set(field, { field, oldValue, newValue }); this.changes.set(key, { field, oldValue, newValue });
} else if (pending.oldValue === newValue) { } else if (pending.oldValue === newValue) {
this.changes.delete(field); this.changes.delete(key);
} else { } else {
pending.newValue = newValue; pending.newValue = newValue;
} }
@@ -239,6 +299,11 @@ export class Issue {
this.touch(); this.touch();
} }
/** Уникальные id без reporter/assignee, в исходном порядке */
private normalizeObservers(userIds: string[]): string[] {
return [...new Set(userIds)].filter((id) => id !== '' && id !== this.reporter && id !== this.assignee);
}
private touch(): void { private touch(): void {
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
this.addDomainEvent(new IssueUpdatedEvent(this.key)); this.addDomainEvent(new IssueUpdatedEvent(this.key));
@@ -0,0 +1,7 @@
export class IssueObserverAddedEvent {
constructor(
public readonly issueKey: string,
public readonly observer: string,
public readonly occurredAt: Date = new Date(),
) {}
}
@@ -0,0 +1,7 @@
/** Задача видна пользователю (он наблюдатель), но редактировать её он не может */
export class IssueEditForbiddenException extends Error {
constructor(id: number) {
super(`Задача с id=${id} доступна наблюдателю только для чтения`);
this.name = 'IssueEditForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class IssueObserverNotFoundException extends Error {
constructor(userIds: string[]) {
super(`Пользователи не найдены: ${userIds.join(', ')}`);
this.name = 'IssueObserverNotFoundException';
}
}
@@ -0,0 +1,7 @@
/** Проект виден пользователю (он наблюдатель), но создавать в нём задачи он не может */
export class IssueProjectForbiddenException extends Error {
constructor(projectId: string) {
super(`В проекте с id=${projectId} наблюдатель не может создавать задачи`);
this.name = 'IssueProjectForbiddenException';
}
}
@@ -4,8 +4,15 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */ /** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const ISSUE_REPOSITORY = Symbol('ISSUE_REPOSITORY'); export const ISSUE_REPOSITORY = Symbol('ISSUE_REPOSITORY');
/** Роль пользователя в задаче — фильтр списка «мои задачи» */
export const ISSUE_USER_ROLES = ['assigned', 'reported', 'observing'] as const;
export type IssueUserRole = (typeof ISSUE_USER_ROLES)[number];
export interface IIssueRepository { export interface IIssueRepository {
/** Задача видна только пользователю, который является в ней reporter или assignee */ /**
* Задача видна своим reporter, assignee и наблюдателям. Редактировать её
* могут только reporter и assignee — см. Issue.canBeEditedBy.
*/
findById(id: number, userId: string): Promise<Issue | null>; findById(id: number, userId: string): Promise<Issue | null>;
findByCKey(ckey: string, userId: string): Promise<Issue | null>; findByCKey(ckey: string, userId: string): Promise<Issue | null>;
findAll(params: { findAll(params: {
@@ -18,6 +25,7 @@ export interface IIssueRepository {
projectId?: string; projectId?: string;
hasDueDate?: boolean; hasDueDate?: boolean;
type?: string[]; type?: string[];
role?: IssueUserRole;
}): Promise<{ list: Issue[]; meta: PaginationMeta }>; }): Promise<{ list: Issue[]; meta: PaginationMeta }>;
/** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */ /** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */
findByDueDateRange(params: { findByDueDateRange(params: {
@@ -32,7 +40,7 @@ export interface IIssueRepository {
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>; countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */ /** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
create(issue: Issue): Promise<Issue>; create(issue: Issue): Promise<Issue>;
/** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */ /** Сохраняет задачу, её наблюдателей и накопленные изменения полей в историю одной транзакцией */
update(issue: Issue, actorId: string): Promise<void>; update(issue: Issue, actorId: string): Promise<void>;
delete(id: number): Promise<void>; delete(id: number): Promise<void>;
} }
@@ -17,6 +17,7 @@ describe('IssuePrismaMapper', () => {
dueDate: null, dueDate: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'), updatedAt: new Date('2026-01-02T00:00:00.000Z'),
observers: [{ userId: 'user-3' }],
}; };
it('maps a Prisma record to a domain entity', () => { it('maps a Prisma record to a domain entity', () => {
@@ -29,6 +30,7 @@ describe('IssuePrismaMapper', () => {
expect(issue.priority).toBe(2); expect(issue.priority).toBe(2);
expect(issue.type).toBe('BUG'); expect(issue.type).toBe('BUG');
expect(issue.assignee).toBe('user-2'); expect(issue.assignee).toBe('user-2');
expect(issue.observers).toEqual(['user-3']);
}); });
it('maps a new domain entity to Prisma create input', () => { it('maps a new domain entity to Prisma create input', () => {
@@ -2,8 +2,11 @@ import type { Issue as PrismaIssue, Prisma } from '../../../../../../generated/p
import { Issue } from '../../../../domain/entities/issue.entity.js'; import { Issue } from '../../../../domain/entities/issue.entity.js';
import { getCKey } from '../../../../domain/helpers/get-ckey.js'; import { getCKey } from '../../../../domain/helpers/get-ckey.js';
/** Запись задачи вместе с id наблюдателей (см. WITH_OBSERVERS в репозитории) */
export type PrismaIssueWithObservers = PrismaIssue & { observers: Array<{ userId: string }> };
export class IssuePrismaMapper { export class IssuePrismaMapper {
static toDomain(record: PrismaIssue): Issue { static toDomain(record: PrismaIssueWithObservers): Issue {
return Issue.reconstitute({ return Issue.reconstitute({
id: record.id, id: record.id,
key: getCKey(record.id, record.key), key: getCKey(record.id, record.key),
@@ -14,6 +17,7 @@ export class IssuePrismaMapper {
type: record.type, type: record.type,
assignee: record.assigneeId, assignee: record.assigneeId,
reporter: record.reporterId, reporter: record.reporterId,
observers: record.observers.map((observer) => observer.userId),
projectId: record.projectId, projectId: record.projectId,
dueDate: record.dueDate, dueDate: record.dueDate,
createdAt: record.createdAt, createdAt: record.createdAt,
@@ -31,6 +35,7 @@ export class IssuePrismaMapper {
type: issue.type, type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
observers: { create: issue.observers.map((userId) => ({ userId })) },
projectId: issue.projectId, projectId: issue.projectId,
dueDate: issue.dueDate, dueDate: issue.dueDate,
createdAt: issue.createdAt, createdAt: issue.createdAt,
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js'; import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Issue } from '../../../../domain/entities/issue.entity.js'; import { Issue } from '../../../../domain/entities/issue.entity.js';
import { IIssueRepository } from '../../../../domain/repositories/issue.repository.interface.js'; import { IIssueRepository, IssueUserRole } from '../../../../domain/repositories/issue.repository.interface.js';
import { IssuePrismaMapper } from '../mappers/issue.mapper.js'; import { IssuePrismaMapper } from '../mappers/issue.mapper.js';
import { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.js'; import { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.js';
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
@@ -13,9 +13,26 @@ import type { IssueFieldChange } from '../../../../domain/entities/issue-change.
const FOREIGN_KEY_VIOLATION = 'P2003'; const FOREIGN_KEY_VIOLATION = 'P2003';
/** Задача видна только своему reporter/assignee */ /** id наблюдателей подгружаются вместе с задачей — они часть сущности */
function ownedByUser(userId: string) { const WITH_OBSERVERS = { observers: { select: { userId: true } } } satisfies Prisma.IssueInclude;
return [{ assigneeId: userId }, { reporterId: userId }];
/**
* Задача видна своим reporter, assignee и наблюдателям.
* То же правило — в visibleIssuesCte (history) и в SSE-фильтре IssueController.
*/
function visibleTo(userId: string): Prisma.IssueWhereInput[] {
return [{ assigneeId: userId }, { reporterId: userId }, { observers: { some: { userId } } }];
}
function roleFilter(userId: string, role: IssueUserRole): Prisma.IssueWhereInput {
switch (role) {
case 'assigned':
return { assigneeId: userId };
case 'reported':
return { reporterId: userId };
case 'observing':
return { observers: { some: { userId } } };
}
} }
/** Видимость и фильтры, общие для списка и календаря */ /** Видимость и фильтры, общие для списка и календаря */
@@ -24,9 +41,11 @@ function commonFilters(params: {
status?: string[]; status?: string[];
assigneeId?: string; assigneeId?: string;
projectId?: string; projectId?: string;
role?: IssueUserRole;
}): Prisma.IssueWhereInput[] { }): Prisma.IssueWhereInput[] {
return [ return [
{ OR: ownedByUser(params.userId) }, { OR: visibleTo(params.userId) },
...(params.role ? [roleFilter(params.userId, params.role)] : []),
...(params.status?.length ? [{ status: { in: params.status } }] : []), ...(params.status?.length ? [{ status: { in: params.status } }] : []),
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), ...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []),
...(params.projectId ? [{ projectId: params.projectId }] : []), ...(params.projectId ? [{ projectId: params.projectId }] : []),
@@ -50,7 +69,8 @@ export class IssuePrismaRepository implements IIssueRepository {
async findById(id: number, userId: string): Promise<Issue | null> { async findById(id: number, userId: string): Promise<Issue | null> {
const record = await this.prisma.issue.findFirst({ const record = await this.prisma.issue.findFirst({
where: { id, OR: ownedByUser(userId) }, where: { id, OR: visibleTo(userId) },
include: WITH_OBSERVERS,
}); });
return record ? IssuePrismaMapper.toDomain(record) : null; return record ? IssuePrismaMapper.toDomain(record) : null;
} }
@@ -58,7 +78,8 @@ export class IssuePrismaRepository implements IIssueRepository {
async findByCKey(ckey: string, userId: string): Promise<Issue | null> { async findByCKey(ckey: string, userId: string): Promise<Issue | null> {
const { id, key } = splitCKey(ckey); const { id, key } = splitCKey(ckey);
const record = await this.prisma.issue.findFirst({ const record = await this.prisma.issue.findFirst({
where: { id, key, OR: ownedByUser(userId) }, where: { id, key, OR: visibleTo(userId) },
include: WITH_OBSERVERS,
}); });
return record ? IssuePrismaMapper.toDomain(record) : null; return record ? IssuePrismaMapper.toDomain(record) : null;
@@ -74,6 +95,7 @@ export class IssuePrismaRepository implements IIssueRepository {
projectId?: string; projectId?: string;
hasDueDate?: boolean; hasDueDate?: boolean;
type?: string[]; type?: string[];
role?: IssueUserRole;
}): Promise<{ list: Issue[]; meta: PaginationMeta }> { }): Promise<{ list: Issue[]; meta: PaginationMeta }> {
const page = Math.max(1, params?.page); const page = Math.max(1, params?.page);
const limit = Math.max(1, params?.limit); const limit = Math.max(1, params?.limit);
@@ -101,6 +123,7 @@ export class IssuePrismaRepository implements IIssueRepository {
const [records, totalCount] = await this.prisma.$transaction([ const [records, totalCount] = await this.prisma.$transaction([
this.prisma.issue.findMany({ this.prisma.issue.findMany({
...queryArgs, ...queryArgs,
include: WITH_OBSERVERS,
take: limit, take: limit,
skip: (page - 1) * limit, skip: (page - 1) * limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -127,6 +150,7 @@ export class IssuePrismaRepository implements IIssueRepository {
where: { where: {
AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }], AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }],
}, },
include: WITH_OBSERVERS,
take: Math.max(1, params.limit), take: Math.max(1, params.limit),
orderBy: [{ dueDate: 'asc' }, { id: 'asc' }], orderBy: [{ dueDate: 'asc' }, { id: 'asc' }],
}); });
@@ -137,7 +161,7 @@ export class IssuePrismaRepository implements IIssueRepository {
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> { async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
const groups = await this.prisma.issue.groupBy({ const groups = await this.prisma.issue.groupBy({
by: ['status'], by: ['status'],
where: { AND: [{ OR: ownedByUser(userId) }, ...(projectId ? [{ projectId }] : [])] }, where: { AND: [{ OR: visibleTo(userId) }, ...(projectId ? [{ projectId }] : [])] },
_count: { _all: true }, _count: { _all: true },
}); });
@@ -151,6 +175,7 @@ export class IssuePrismaRepository implements IIssueRepository {
const record = await this.prisma.$transaction(async (tx) => { const record = await this.prisma.$transaction(async (tx) => {
const created = await tx.issue.create({ const created = await tx.issue.create({
data: IssuePrismaMapper.toCreateInput(issue), data: IssuePrismaMapper.toCreateInput(issue),
include: WITH_OBSERVERS,
}); });
await tx.issueChange.createMany({ await tx.issueChange.createMany({
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt), data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
@@ -181,8 +206,19 @@ export class IssuePrismaRepository implements IIssueRepository {
return; return;
} }
const observers = [...issue.observers];
await this.prisma.$transaction([ await this.prisma.$transaction([
updateIssue, updateIssue,
// Наблюдатели меняются только вместе с записью observer в истории
...(changes.some((change) => change.field === 'observer')
? [
this.prisma.issueObserver.deleteMany({ where: { issueId, userId: { notIn: observers } } }),
this.prisma.issueObserver.createMany({
data: observers.map((userId) => ({ issueId, userId })),
skipDuplicates: true,
}),
]
: []),
this.prisma.issueChange.createMany({ this.prisma.issueChange.createMany({
data: toChangeRows(issueId, changes, actorId, issue.updatedAt), data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
}), }),
+4
View File
@@ -18,6 +18,8 @@ import { IssueUserExpanderService } from './application/services/issue-user-expa
import { IssueAssignmentNotifierService } from './application/services/issue-assignment-notifier.service.js'; import { IssueAssignmentNotifierService } from './application/services/issue-assignment-notifier.service.js';
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js'; import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
import { UnobserveIssueUseCase } from './application/use-cases/unobserve-issue/unobserve-issue.use-case.js';
import { IssueParticipantsService } from './application/services/issue-participants.service.js';
@Module({ @Module({
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule], imports: [PrismaModule, ProjectModule, UserModule, NotificationModule],
@@ -31,10 +33,12 @@ import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar
DeleteIssueUseCase, DeleteIssueUseCase,
GetIssueStatsUseCase, GetIssueStatsUseCase,
ListCalendarIssuesUseCase, ListCalendarIssuesUseCase,
UnobserveIssueUseCase,
IssueResolver, IssueResolver,
IssueEventsService, IssueEventsService,
IssueUserExpanderService, IssueUserExpanderService,
IssueAssignmentNotifierService, IssueAssignmentNotifierService,
IssueParticipantsService,
{ {
// Domain видит только интерфейс IIssueRepository, // Domain видит только интерфейс IIssueRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь // конкретную реализацию (Prisma) подставляет DI-контейнер здесь
@@ -3,6 +3,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -42,6 +43,11 @@ import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-cale
import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js'; import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js';
import { CalendarIssuesQueryDto } from '../dto/calendar-issues-query.dto.js'; import { CalendarIssuesQueryDto } from '../dto/calendar-issues-query.dto.js';
import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js'; import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js';
import { IssueEditForbiddenException } from '../../domain/exceptions/issue-edit-forbidden.exception.js';
import { IssueProjectForbiddenException } from '../../domain/exceptions/issue-project-forbidden.exception.js';
import { IssueObserverNotFoundException } from '../../domain/exceptions/issue-observer-not-found.exception.js';
import { UnobserveIssueUseCase } from '../../application/use-cases/unobserve-issue/unobserve-issue.use-case.js';
import { UnobserveIssueCommand } from '../../application/use-cases/unobserve-issue/unobserve-issue.command.js';
@Controller('issues') @Controller('issues')
export class IssueController { export class IssueController {
@@ -54,6 +60,7 @@ export class IssueController {
private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase,
private readonly getIssueStatsUseCase: GetIssueStatsUseCase, private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
private readonly unobserveIssueUseCase: UnobserveIssueUseCase,
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
) {} ) {}
@@ -71,6 +78,7 @@ export class IssueController {
dto.assignee, dto.assignee,
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined, dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
dto.type, dto.type,
dto.observers,
), ),
), ),
); );
@@ -89,6 +97,7 @@ export class IssueController {
query.assigneeId, query.assigneeId,
query.hasDueDate, query.hasDueDate,
query.type, query.type,
query.role,
), ),
); );
} }
@@ -118,13 +127,15 @@ export class IssueController {
/** /**
* SSE-поток для зарегистрированных пользователей (доступ уже проверен * SSE-поток для зарегистрированных пользователей (доступ уже проверен
* глобальным AuthGuard): уведомляет о каждой созданной, обновлённой * глобальным AuthGuard): уведомляет о каждой созданной, обновлённой
* и удалённой задаче, видимой текущему пользователю (reporter/assignee — * и удалённой задаче, видимой текущему пользователю (reporter/assignee/наблюдатель —
* то же правило видимости, что и в IssuePrismaRepository.ownedByUser). * то же правило видимости, что и в IssuePrismaRepository.visibleTo).
*/ */
@Sse('events') @Sse('events')
issueCreatedEvents(@Session() session: UserSession): Observable<MessageEvent> { issueCreatedEvents(@Session() session: UserSession): Observable<MessageEvent> {
const isVisibleToUser = (issue: IssueDto) => const isVisibleToUser = (issue: IssueDto) =>
issue.reporterId === session.user.id || issue.assigneeId === session.user.id; issue.reporterId === session.user.id ||
issue.assigneeId === session.user.id ||
issue.observerIds.includes(session.user.id);
return merge( return merge(
this.issueEventsService.onIssueCreated().pipe( this.issueEventsService.onIssueCreated().pipe(
@@ -170,6 +181,7 @@ export class IssueController {
dto.assignee, dto.assignee,
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate), dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
dto.type, dto.type,
dto.observers,
), ),
), ),
); );
@@ -181,6 +193,13 @@ export class IssueController {
await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id))); await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id)));
} }
/** Наблюдатель отписывается от задачи сам (снять других может только reporter/assignee через PATCH) */
@Delete(':id/observers/me')
@HttpCode(HttpStatus.NO_CONTENT)
async unobserve(@Param('id', ParseIntPipe) id: number, @Session() session: UserSession) {
await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id)));
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> { private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try { try {
@@ -189,9 +208,12 @@ export class IssueController {
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof InvalidCalendarRangeException) { if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) {
throw new BadRequestException(error.message); throw new BadRequestException(error.message);
} }
if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error; throw error;
} }
} }
@@ -1,4 +1,16 @@
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import {
ArrayMaxSize,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
export class CreateIssueRequestDto { export class CreateIssueRequestDto {
@@ -36,4 +48,11 @@ export class CreateIssueRequestDto {
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
dueDate?: string; dueDate?: string;
/** id пользователей-наблюдателей: видят задачу и комментируют, но не редактируют */
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
} }
@@ -1,4 +1,6 @@
import { IsIn, IsOptional } from 'class-validator';
import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
import { ISSUE_USER_ROLES, type IssueUserRole } from '../../domain/repositories/issue.repository.interface.js';
import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js'; import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js';
export class FilterIssueDto { export class FilterIssueDto {
@@ -26,4 +28,9 @@ export class FilterIssueDto {
/** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */ /** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */
@BooleanDecorator({ isOptional: true }) @BooleanDecorator({ isOptional: true })
hasDueDate?: boolean; hasDueDate?: boolean;
/** Только задачи, где текущий пользователь исполнитель, автор или наблюдатель */
@IsOptional()
@IsIn(ISSUE_USER_ROLES)
role?: IssueUserRole;
} }
@@ -1,4 +1,15 @@
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import {
ArrayMaxSize,
IsArray,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
@@ -33,4 +44,11 @@ export class UpdateIssueRequestDto {
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()
dueDate?: string | null; dueDate?: string | null;
/** id пользователей-наблюдателей (полный новый список): видят задачу и комментируют, но не редактируют */
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
} }
@@ -1,5 +1,17 @@
import { Field, InputType, Int } from '@nestjs/graphql'; import { Field, InputType, Int } from '@nestjs/graphql';
import { IsDate, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import {
ArrayMaxSize,
IsArray,
IsDate,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
@InputType() @InputType()
@@ -46,4 +58,11 @@ export class CreateIssueInput {
@IsOptional() @IsOptional()
@IsDate() @IsDate()
dueDate?: Date; dueDate?: Date;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
} }
@@ -30,12 +30,18 @@ export class IssueDetailType {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
reporterId: string | null; reporterId: string | null;
@Field(() => [String])
observerIds: string[];
@Field(() => UserType, { nullable: true }) @Field(() => UserType, { nullable: true })
assignee: UserType | null; assignee: UserType | null;
@Field(() => UserType, { nullable: true }) @Field(() => UserType, { nullable: true })
reporter: UserType | null; reporter: UserType | null;
@Field(() => [UserType])
observers: UserType[];
@Field() @Field()
projectId: string; projectId: string;
@@ -29,6 +29,9 @@ export class IssueType {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
reporterId: string | null; reporterId: string | null;
@Field(() => [String])
observerIds: string[];
@Field() @Field()
projectId: string; projectId: string;
@@ -1,5 +1,5 @@
import { Field, InputType, Int } from '@nestjs/graphql'; import { Field, InputType, Int } from '@nestjs/graphql';
import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { ArrayMaxSize, IsArray, IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
@@ -42,4 +42,11 @@ export class UpdateIssueInput {
@IsOptional() @IsOptional()
@IsDate() @IsDate()
dueDate?: Date | null; dueDate?: Date | null;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
} }
@@ -1,4 +1,4 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js'; import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
@@ -27,6 +27,12 @@ import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calend
import { CalendarIssuesDto } from '../../application/dto/calendar-issues.dto.js'; import { CalendarIssuesDto } from '../../application/dto/calendar-issues.dto.js';
import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js'; import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
import { IssueEditForbiddenException } from '../../domain/exceptions/issue-edit-forbidden.exception.js';
import { IssueProjectForbiddenException } from '../../domain/exceptions/issue-project-forbidden.exception.js';
import { IssueObserverNotFoundException } from '../../domain/exceptions/issue-observer-not-found.exception.js';
import { UnobserveIssueUseCase } from '../../application/use-cases/unobserve-issue/unobserve-issue.use-case.js';
import { UnobserveIssueCommand } from '../../application/use-cases/unobserve-issue/unobserve-issue.command.js';
import { ISSUE_USER_ROLES, type IssueUserRole } from '../../domain/repositories/issue.repository.interface.js';
@Resolver(() => IssueType) @Resolver(() => IssueType)
export class IssueResolver { export class IssueResolver {
@@ -38,6 +44,7 @@ export class IssueResolver {
private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly updateIssueUseCase: UpdateIssueUseCase,
private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase,
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
private readonly unobserveIssueUseCase: UnobserveIssueUseCase,
) {} ) {}
@Query(() => IssueDetailType, { name: 'issue' }) @Query(() => IssueDetailType, { name: 'issue' })
@@ -58,7 +65,12 @@ export class IssueResolver {
@Args('projectId', { type: () => String, nullable: true }) projectId?: string, @Args('projectId', { type: () => String, nullable: true }) projectId?: string,
@Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean, @Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean,
@Args('type', { type: () => [String], nullable: true }) type?: string[], @Args('type', { type: () => [String], nullable: true }) type?: string[],
@Args('role', { type: () => String, nullable: true }) role?: string,
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> { ): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
if (role && !ISSUE_USER_ROLES.includes(role as IssueUserRole)) {
throw new BadRequestException(`role must be one of: ${ISSUE_USER_ROLES.join(', ')}`);
}
return this.listIssuesUseCase.execute( return this.listIssuesUseCase.execute(
new ListIssuesQuery( new ListIssuesQuery(
limit, limit,
@@ -70,6 +82,7 @@ export class IssueResolver {
undefined, undefined,
hasDueDate ?? undefined, hasDueDate ?? undefined,
type ?? undefined, type ?? undefined,
(role as IssueUserRole | undefined) ?? undefined,
), ),
); );
} }
@@ -105,6 +118,7 @@ export class IssueResolver {
input.assignee, input.assignee,
input.dueDate, input.dueDate,
input.type, input.type,
input.observers,
), ),
), ),
); );
@@ -128,6 +142,7 @@ export class IssueResolver {
input.assignee, input.assignee,
input.dueDate, input.dueDate,
input.type, input.type,
input.observers ?? undefined,
), ),
), ),
); );
@@ -139,6 +154,13 @@ export class IssueResolver {
return true; return true;
} }
/** Наблюдатель отписывается от задачи сам */
@Mutation(() => Boolean, { name: 'unobserveIssue' })
async unobserve(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise<boolean> {
await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id)));
return true;
}
/** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */ /** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> { private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try { try {
@@ -147,9 +169,12 @@ export class IssueResolver {
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof InvalidCalendarRangeException) { if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) {
throw new BadRequestException(error.message); throw new BadRequestException(error.message);
} }
if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error; throw error;
} }
} }
@@ -4,6 +4,12 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */ /** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY'); export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
/**
* Роль пользователя в проекте. OWNER и MEMBER создают задачи,
* OBSERVER только видит проект (он наблюдатель отдельных задач).
*/
export type ProjectRole = 'OWNER' | 'MEMBER' | 'OBSERVER';
export interface IProjectRepository { export interface IProjectRepository {
/** Проект виден владельцу и участникам проекта (ProjectMember) */ /** Проект виден владельцу и участникам проекта (ProjectMember) */
findById(id: string, userId: string): Promise<Project | null>; findById(id: string, userId: string): Promise<Project | null>;
@@ -11,6 +17,11 @@ export interface IProjectRepository {
create(project: Project): Promise<Project>; create(project: Project): Promise<Project>;
update(project: Project): Promise<void>; update(project: Project): Promise<void>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
/** Добавляет пользователя в участники проекта, если он ещё не владелец/участник */ /** Роль пользователя в проекте; null — проект ему не виден (или не существует) */
addMember(projectId: string, userId: string): Promise<void>; findRole(projectId: string, userId: string): Promise<ProjectRole | null>;
/**
* Добавляет пользователя в участники проекта. Роль только повышается:
* OBSERVER становится MEMBER, но MEMBER не понижается до OBSERVER.
*/
addMember(projectId: string, userId: string, role: Exclude<ProjectRole, 'OWNER'>): Promise<void>;
} }
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js'; import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Project } from '../../../../domain/entities/project.entity.js'; import { Project } from '../../../../domain/entities/project.entity.js';
import { IProjectRepository } from '../../../../domain/repositories/project.repository.interface.js'; import { IProjectRepository, ProjectRole } from '../../../../domain/repositories/project.repository.interface.js';
import { ProjectPrismaMapper } from '../mappers/project.mapper.js'; import { ProjectPrismaMapper } from '../mappers/project.mapper.js';
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
import { Prisma } from '../../../../../../generated/prisma/client.js'; import { Prisma } from '../../../../../../generated/prisma/client.js';
@@ -69,11 +69,21 @@ export class ProjectPrismaRepository implements IProjectRepository {
await this.prisma.project.delete({ where: { id } }); await this.prisma.project.delete({ where: { id } });
} }
async addMember(projectId: string, userId: string): Promise<void> { async findRole(projectId: string, userId: string): Promise<ProjectRole | null> {
const record = await this.prisma.project.findFirst({
where: { id: projectId, ...this.accessibleBy(userId) },
select: { ownerId: true, members: { where: { userId }, select: { role: true } } },
});
if (!record) return null;
if (record.ownerId === userId) return 'OWNER';
return record.members[0]?.role ?? null;
}
async addMember(projectId: string, userId: string, role: Exclude<ProjectRole, 'OWNER'>): Promise<void> {
await this.prisma.projectMember.upsert({ await this.prisma.projectMember.upsert({
where: { projectId_userId: { projectId, userId } }, where: { projectId_userId: { projectId, userId } },
create: { projectId, userId }, create: { projectId, userId, role },
update: {}, update: role === 'MEMBER' ? { role } : {},
}); });
} }