diff --git a/prisma/migrations/20261025190802_issue_observer/migration.sql b/prisma/migrations/20261025190802_issue_observer/migration.sql new file mode 100644 index 0000000..d98e496 --- /dev/null +++ b/prisma/migrations/20261025190802_issue_observer/migration.sql @@ -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; diff --git a/prisma/migrations/20261025192028_project_member_role/migration.sql b/prisma/migrations/20261025192028_project_member_role/migration.sql new file mode 100644 index 0000000..f6553d6 --- /dev/null +++ b/prisma/migrations/20261025192028_project_member_role/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d2ba8e..68d15c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { comments Comment[] uploadedFiles File[] @relation(name: "fileUploader") issueChanges IssueChange[] + observedIssues IssueObserver[] @@unique([email]) @@map("user") @@ -103,6 +104,7 @@ model Issue { comments Comment[] attachments IssueAttachment[] changes IssueChange[] + observers IssueObserver[] createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) @@ -118,6 +120,21 @@ model 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 { id String @id @default(uuid(7)) name String @@ -135,11 +152,19 @@ model Project { @@map("project") } +/// MEMBER создаёт задачи в проекте; OBSERVER только видит проект — +/// он наблюдает за отдельными задачами (IssueObserver). +enum ProjectRole { + MEMBER + OBSERVER +} + model ProjectMember { - id String @id @default(uuid(7)) + id String @id @default(uuid(7)) projectId String userId String - createdAt DateTime @default(now()) @db.Timestamptz(3) + role ProjectRole @default(MEMBER) + createdAt DateTime @default(now()) @db.Timestamptz(3) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/modules/history/application/dto/issue-history.dto.ts b/src/modules/history/application/dto/issue-history.dto.ts index 55eab1e..ab2bf27 100644 --- a/src/modules/history/application/dto/issue-history.dto.ts +++ b/src/modules/history/application/dto/issue-history.dto.ts @@ -5,7 +5,7 @@ export class IssueFieldChangeDto { field: IssueChangeField; oldValue: string | null; newValue: string | null; - /** Только для field = assignee: пользователи из oldValue/newValue (null — не назначен или удалён) */ + /** Только для field = assignee/observer: пользователи из oldValue/newValue (null — не назначен или удалён) */ oldUser?: UserDto | null; newUser?: UserDto | null; } diff --git a/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts index aae474e..97352f3 100644 --- a/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts +++ b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts @@ -10,7 +10,7 @@ import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js'; /** * Статистика задач по дням, считается по истории изменений (issue_change). - * Видимость — как у списка задач: только задачи, где пользователь reporter/assignee. + * Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель. */ @Injectable() export class GetDailyIssueStatsUseCase { diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts index 7c13900..718a80c 100644 --- a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts @@ -14,6 +14,10 @@ import { } from '../../../domain/repositories/issue-history.repository.interface.js'; import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.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(['assignee', 'observer']); @Injectable() export class GetIssueHistoryUseCase { @@ -55,7 +59,7 @@ export class GetIssueHistoryUseCase { oldValue: record.oldValue, newValue: record.newValue, }; - if (record.field === 'assignee') { + if (USER_FIELDS.has(record.field)) { change.oldUser = userOf(record.oldValue); change.newUser = userOf(record.newValue); } @@ -65,12 +69,12 @@ export class GetIssueHistoryUseCase { return [...entries.values()]; } - /** Авторы изменений и назначенные/снятые исполнители */ + /** Авторы изменений, назначенные/снятые исполнители и наблюдатели */ private async loadUsers(records: IssueChangeRecord[]): Promise> { const ids = new Set(); for (const record of records) { 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.newValue) ids.add(record.newValue); } diff --git a/src/modules/history/domain/repositories/issue-history.repository.interface.ts b/src/modules/history/domain/repositories/issue-history.repository.interface.ts index 849f649..16a1eb0 100644 --- a/src/modules/history/domain/repositories/issue-history.repository.interface.ts +++ b/src/modules/history/domain/repositories/issue-history.repository.interface.ts @@ -39,8 +39,8 @@ export interface DailyStatusCount { export interface IIssueHistoryRepository { /** История задачи от старых изменений к новым */ findByIssue(issueId: number): Promise; - /** Только задачи, видимые пользователю (reporter/assignee) */ + /** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */ countDailyEvents(range: DailyStatsRange): Promise; - /** Только задачи, видимые пользователю (reporter/assignee) */ + /** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */ countDailyStatuses(range: DailyStatsRange): Promise; } diff --git a/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts b/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts index 00ef295..90299fd 100644 --- a/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts +++ b/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts @@ -11,13 +11,14 @@ import type { } from '../../../../domain/repositories/issue-history.repository.interface.js'; /** - * Задачи, видимые пользователю: reporter или assignee — то же правило, что - * ownedByUser в IssuePrismaRepository. Возвращает CTE "visible" (id задач). + * Задачи, видимые пользователю: reporter, assignee или наблюдатель — то же + * правило, что visibleTo в IssuePrismaRepository. Возвращает CTE "visible" (id задач). */ function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql { return Prisma.sql`"visible" AS ( 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} )`; } diff --git a/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts index 5548924..4bdc3e7 100644 --- a/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts +++ b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts @@ -36,7 +36,7 @@ export class IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit /** * Файл виден, если видна хоть одна задача, к которой он прикреплён. * Видимость проверяем через IIssueRepository.findById, чтобы правило - * (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче. + * (reporter/assignee/наблюдатель) жило в одном месте. Обычно файл прикреплён к одной задаче. */ async isVisibleThrough(fileId: string, userId: string): Promise { const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId); diff --git a/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts index 0a4a748..14e96e1 100644 --- a/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts +++ b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts @@ -8,6 +8,7 @@ import { type IIssueRepository, } from '../../../../issue/domain/repositories/issue.repository.interface.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 { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js'; import { AttachFileCommand } from './attach-file.command.js'; @@ -15,7 +16,7 @@ import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js'; /** * Прикрепляет к задаче файл, заранее загруженный через POST /files. - * Прикреплять может любой, кому видна задача (как и править её), + * Прикреплять может тот, кто может править задачу (не наблюдатель), * но только собственный файл — см. StoredFile.assertOwnedBy. */ @Injectable() @@ -29,9 +30,13 @@ export class AttachFileUseCase { ) {} async execute(command: AttachFileCommand): Promise { - 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); } + if (!issue.canBeEditedBy(command.userId)) { + throw new IssueEditForbiddenException(command.issueId); + } const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId); const attachment = await this.attachmentRepository.create( diff --git a/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts index b79db82..7807da0 100644 --- a/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts +++ b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts @@ -8,12 +8,14 @@ import { type IIssueRepository, } from '../../../../issue/domain/repositories/issue.repository.interface.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 { DetachFileCommand } from './detach-file.command.js'; /** * Открепляет файл от задачи. Сам файл не удаляется: если он больше * ни к чему не прикреплён, его удалит GC file-модуля. + * Наблюдателям задачи недоступно — как и её правка. */ @Injectable() export class DetachFileUseCase { @@ -25,9 +27,13 @@ export class DetachFileUseCase { ) {} async execute(command: DetachFileCommand): Promise { - 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); } + if (!issue.canBeEditedBy(command.userId)) { + throw new IssueEditForbiddenException(command.issueId); + } if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) { throw new IssueAttachmentNotFoundException(command.issueId, command.fileId); diff --git a/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts b/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts index 52b0e48..1c224e1 100644 --- a/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts +++ b/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts @@ -23,6 +23,7 @@ import { IssueAttachmentNotFoundException } from '../../domain/exceptions/issue- import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-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 { IssueEditForbiddenException } from '../../../issue/domain/exceptions/issue-edit-forbidden.exception.js'; /** * Вложения задачи. Сам файл загружается отдельно (POST /files), @@ -78,7 +79,7 @@ export class IssueAttachmentController { ) { throw new NotFoundException(error.message); } - if (error instanceof FileForbiddenException) { + if (error instanceof FileForbiddenException || error instanceof IssueEditForbiddenException) { throw new ForbiddenException(error.message); } throw error; diff --git a/src/modules/issue/application/dto/issue-with-users.dto.ts b/src/modules/issue/application/dto/issue-with-users.dto.ts index 057f00c..b0de2bf 100644 --- a/src/modules/issue/application/dto/issue-with-users.dto.ts +++ b/src/modules/issue/application/dto/issue-with-users.dto.ts @@ -12,6 +12,9 @@ export class IssueWithUsersDto { reporterId: string | null; assignee: UserDto | null; reporter: UserDto | null; + observerIds: string[]; + /** Удалённые пользователи сюда не попадают */ + observers: UserDto[]; projectId: string; dueDate: Date | null; createdAt: Date; diff --git a/src/modules/issue/application/dto/issue.dto.ts b/src/modules/issue/application/dto/issue.dto.ts index f6332c4..2980582 100644 --- a/src/modules/issue/application/dto/issue.dto.ts +++ b/src/modules/issue/application/dto/issue.dto.ts @@ -8,6 +8,7 @@ export class IssueDto { type: string; assigneeId: string | null; reporterId: string | null; + observerIds: string[]; projectId: string; dueDate: Date | null; createdAt: Date; diff --git a/src/modules/issue/application/mappers/issue-application.mapper.ts b/src/modules/issue/application/mappers/issue-application.mapper.ts index 16edfc7..9b96b14 100644 --- a/src/modules/issue/application/mappers/issue-application.mapper.ts +++ b/src/modules/issue/application/mappers/issue-application.mapper.ts @@ -15,6 +15,7 @@ export class IssueApplicationMapper { type: issue.type, assigneeId: issue.assignee, reporterId: issue.reporter, + observerIds: [...issue.observers], projectId: issue.projectId, dueDate: issue.dueDate, createdAt: issue.createdAt, @@ -22,7 +23,7 @@ export class IssueApplicationMapper { }; } - /** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */ + /** Раскрывает assignee/reporter/наблюдателей в полноценных пользователей (см. GetIssueUseCase) */ static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap): IssueWithUsersDto { return { id: issue.id as number, @@ -36,6 +37,8 @@ export class IssueApplicationMapper { reporterId: issue.reporter, assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null, reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null, + observerIds: [...issue.observers], + observers: issue.observers.flatMap((id) => usersById.get(id) ?? []), projectId: issue.projectId, dueDate: issue.dueDate, createdAt: issue.createdAt, diff --git a/src/modules/issue/application/services/issue-assignment-notifier.service.ts b/src/modules/issue/application/services/issue-assignment-notifier.service.ts index ce57978..63c6a6a 100644 --- a/src/modules/issue/application/services/issue-assignment-notifier.service.ts +++ b/src/modules/issue/application/services/issue-assignment-notifier.service.ts @@ -1,17 +1,23 @@ import { Injectable } from '@nestjs/common'; import { NotificationsService } from '../../../notification/application/services/notifications.service.js'; import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.js'; +import { IssueObserverAddedEvent } from '../../domain/events/issue-observer-added.event.js'; import type { Issue } from '../../domain/entities/issue.entity.js'; /** * Уведомляет нового исполнителя задачи (в т.ч. при самоназначении) - * по всем включённым у него каналам. + * и новых наблюдателей по всем включённым у них каналам. */ @Injectable() export class IssueAssignmentNotifierService { constructor(private readonly notificationsService: NotificationsService) {} notify(issue: Issue, domainEvents: unknown[]): void { + this.notifyAssignee(issue, domainEvents); + this.notifyObservers(issue, domainEvents); + } + + private notifyAssignee(issue: Issue, domainEvents: unknown[]): void { const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent); // Последнее назначение могло быть отменено снятием исполнителя в той же операции if (!assigned || assigned.assignee !== issue.assignee) return; @@ -22,4 +28,21 @@ export class IssueAssignmentNotifierService { content: issue.title, }); } + + private notifyObservers(issue: Issue, domainEvents: unknown[]): void { + const added = new Set( + domainEvents.filter((event) => event instanceof IssueObserverAddedEvent).map((event) => event.observer), + ); + + // Наблюдатель мог быть снят или назначен исполнителем в той же операции + for (const userId of added) { + if (!issue.observers.includes(userId)) continue; + + this.notificationsService.notify({ + userId, + subject: `You are now observing ${issue.key}`, + content: issue.title, + }); + } + } } diff --git a/src/modules/issue/application/services/issue-participants.service.ts b/src/modules/issue/application/services/issue-participants.service.ts new file mode 100644 index 0000000..df40ddb --- /dev/null +++ b/src/modules/issue/application/services/issue-participants.service.ts @@ -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 { + 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 { + 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'); + } + } +} diff --git a/src/modules/issue/application/services/issue-user-expander.service.ts b/src/modules/issue/application/services/issue-user-expander.service.ts index 5ed1d3b..9f996a0 100644 --- a/src/modules/issue/application/services/issue-user-expander.service.ts +++ b/src/modules/issue/application/services/issue-user-expander.service.ts @@ -5,7 +5,7 @@ import { IssueApplicationMapper } from '../mappers/issue-application.mapper.js'; import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js'; import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js'; -/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */ +/** Раскрывает assignee/reporter/наблюдателей задачи (или списка задач) в полноценных пользователей */ @Injectable() export class IssueUserExpanderService { constructor(private readonly getUsersUseCase: GetUsersUseCase) {} @@ -19,7 +19,11 @@ export class IssueUserExpanderService { if (issues.length === 0) return []; const userIds = [ - ...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))), + ...new Set( + issues + .flatMap((issue) => [issue.reporter, issue.assignee, ...issue.observers]) + .filter((id): id is string => Boolean(id)), + ), ]; const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds)); const usersById = new Map(users.map((user) => [user.id, user])); diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts index 7ed4062..d3fccb7 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts @@ -9,5 +9,6 @@ export class CreateIssueCommand { public readonly assignee?: string, public readonly dueDate?: Date, public readonly type?: string, + public readonly observers?: string[], ) {} } diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts index 135a82b..5847032 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts @@ -9,6 +9,11 @@ import { IssueUserExpanderService } from '../../services/issue-user-expander.ser import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js'; +import { IssueParticipantsService } from '../../services/issue-participants.service.js'; +import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js'; +import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js'; +import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js'; +import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js'; describe('CreateIssueUseCase', () => { let repository: IIssueRepository; @@ -16,6 +21,7 @@ describe('CreateIssueUseCase', () => { let issueEventsService: IssueEventsService; let issueUserExpander: IssueUserExpanderService; let notificationsService: NotificationsService; + let getUsersUseCase: GetUsersUseCase; let useCase: CreateIssueUseCase; beforeEach(() => { @@ -35,6 +41,7 @@ describe('CreateIssueUseCase', () => { create: vi.fn(), update: vi.fn(), delete: vi.fn(), + findRole: vi.fn(async () => 'MEMBER' as const), addMember: vi.fn(), }; issueEventsService = new IssueEventsService(); @@ -46,12 +53,16 @@ describe('CreateIssueUseCase', () => { })), } as unknown as IssueUserExpanderService; notificationsService = { notify: vi.fn() } as unknown as NotificationsService; + getUsersUseCase = { + execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))), + } as unknown as GetUsersUseCase; useCase = new CreateIssueUseCase( repository, projectRepository, issueEventsService, issueUserExpander, new IssueAssignmentNotifierService(notificationsService), + new IssueParticipantsService(projectRepository, getUsersUseCase), ); }); @@ -186,4 +197,53 @@ describe('CreateIssueUseCase', () => { expect(task.type).toBe('TASK'); expect(bug.type).toBe('BUG'); }); + + it('checks project access on behalf of the reporter', async () => { + vi.mocked(repository.create).mockImplementation(async (issue) => issue); + + await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1', 'project-1')); + + expect(projectRepository.findRole).toHaveBeenCalledWith('project-1', 'user-1'); + }); + + it('refuses to create an issue in a project the reporter has no access to', async () => { + vi.mocked(projectRepository.findRole).mockResolvedValue(null); + + await expect( + useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-9', 'project-1')), + ).rejects.toBeInstanceOf(IssueProjectNotFoundException); + expect(repository.create).not.toHaveBeenCalled(); + expect(projectRepository.addMember).not.toHaveBeenCalled(); + }); + + it('forbids a project observer from creating issues', async () => { + vi.mocked(projectRepository.findRole).mockResolvedValue('OBSERVER'); + + await expect( + useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-5', 'project-1')), + ).rejects.toBeInstanceOf(IssueProjectForbiddenException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('adds the assignee to the project as a member and observers as observers', async () => { + vi.mocked(repository.create).mockImplementation(async (issue) => issue); + + await useCase.execute( + new CreateIssueCommand( + 'ISSUE-1', + 'Fix login bug', + 'user-1', + 'project-1', + undefined, + undefined, + 'user-2', + undefined, + undefined, + ['user-3'], + ), + ); + + expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-2', 'MEMBER'); + expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-3', 'OBSERVER'); + }); }); diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts index 24a6e7b..d91c590 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts @@ -5,11 +5,14 @@ import { PROJECT_REPOSITORY, type IProjectRepository, } from '../../../../project/domain/repositories/project.repository.interface.js'; +import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js'; +import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js'; import { CreateIssueCommand } from './create-issue.command.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; +import { IssueParticipantsService } from '../../services/issue-participants.service.js'; import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js'; @Injectable() @@ -22,6 +25,7 @@ export class CreateIssueUseCase { private readonly issueEventsService: IssueEventsService, private readonly issueUserExpander: IssueUserExpanderService, private readonly issueAssignmentNotifier: IssueAssignmentNotifierService, + private readonly issueParticipants: IssueParticipantsService, ) {} async execute(command: CreateIssueCommand): Promise { @@ -33,16 +37,26 @@ export class CreateIssueUseCase { type: command.type, assignee: command.assignee, reporter: command.reporter, + observers: command.observers, projectId: command.projectId, dueDate: command.dueDate, }); + // Создавать задачи может владелец или участник проекта, но не наблюдатель. + // Чужой проект неотличим от несуществующего — 404, как и раньше. + const role = await this.projectRepository.findRole(command.projectId, command.reporter); + if (!role) { + throw new IssueProjectNotFoundException(command.projectId); + } + if (role === 'OBSERVER') { + throw new IssueProjectForbiddenException(command.projectId); + } + + await this.issueParticipants.ensureObserversExist([...draft.observers]); const issue = await this.issueRepository.create(draft); const dto = await this.issueUserExpander.expand(issue); - if (issue.assignee) { - await this.projectRepository.addMember(issue.projectId, issue.assignee); - } + await this.issueParticipants.addToProject(issue); const domainEvents = draft.pullDomainEvents(); this.issueAssignmentNotifier.notify(issue, domainEvents); diff --git a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts index 3eb4b3d..13f21ce 100644 --- a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; import { Issue } from '../../../domain/entities/issue.entity.js'; import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js'; +import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js'; import { DeleteIssueUseCase } from './delete-issue.use-case.js'; import { DeleteIssueCommand } from './delete-issue.command.js'; import { IssueEventsService } from '../../services/issue-events.service.js'; @@ -17,6 +18,7 @@ const buildIssue = () => type: 'TASK', assignee: null, reporter: 'user-1', + observers: ['user-5'], projectId: 'project-1', dueDate: null, createdAt: new Date(), @@ -73,4 +75,13 @@ describe('DeleteIssueUseCase', () => { }), ]); }); + + it('forbids an observer from deleting the issue', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue()); + + await expect(useCase.execute(new DeleteIssueCommand(1, 'user-5'))).rejects.toBeInstanceOf( + IssueEditForbiddenException, + ); + expect(repository.delete).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.ts b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.ts index 9d1d0dd..c2a5e2c 100644 --- a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js'; +import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js'; import { DeleteIssueCommand } from './delete-issue.command.js'; import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueEventsService } from '../../services/issue-events.service.js'; @@ -20,6 +21,10 @@ export class DeleteIssueUseCase { throw new IssueNotFoundException(command.id); } + if (!issue.canBeEditedBy(command.userId)) { + throw new IssueEditForbiddenException(command.id); + } + await this.issueRepository.delete(command.id); this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue)); diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts index 0532370..268cc28 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts @@ -1,3 +1,5 @@ +import type { IssueUserRole } from '../../../domain/repositories/issue.repository.interface.js'; + export class ListIssuesQuery { constructor( public readonly limit: number, @@ -9,5 +11,6 @@ export class ListIssuesQuery { public readonly assigneeId?: string, public readonly hasDueDate?: boolean, public readonly type?: string[], + public readonly role?: IssueUserRole, ) {} } diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts index 041f6ec..879ea3d 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts @@ -24,6 +24,7 @@ export class ListIssuesUseCase { assigneeId: query.assigneeId, hasDueDate: query.hasDueDate, type: query.type, + role: query.role, }); return { list: await this.issueUserExpander.expandAll(list), meta }; diff --git a/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.command.ts b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.command.ts new file mode 100644 index 0000000..a62c868 --- /dev/null +++ b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.command.ts @@ -0,0 +1,6 @@ +export class UnobserveIssueCommand { + constructor( + public readonly id: number, + public readonly userId: string, + ) {} +} diff --git a/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.spec.ts new file mode 100644 index 0000000..5e0742d --- /dev/null +++ b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.spec.ts @@ -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, + ); + }); +}); diff --git a/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.ts b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.ts new file mode 100644 index 0000000..52cc3a7 --- /dev/null +++ b/src/modules/issue/application/use-cases/unobserve-issue/unobserve-issue.use-case.ts @@ -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 { + 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)); + } +} diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts index 4ef26ac..08e5e3a 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts @@ -9,5 +9,7 @@ export class UpdateIssueCommand { public readonly assignee?: string | null, public readonly dueDate?: Date | null, public readonly type?: string, + /** Полный новый список наблюдателей */ + public readonly observers?: string[], ) {} } diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts index 79a1261..b0d666e 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts @@ -3,6 +3,8 @@ import type { IIssueRepository } from '../../../domain/repositories/issue.reposi import type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js'; import { Issue } from '../../../domain/entities/issue.entity.js'; import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js'; +import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js'; +import { IssueObserverNotFoundException } from '../../../domain/exceptions/issue-observer-not-found.exception.js'; import { UpdateIssueUseCase } from './update-issue.use-case.js'; import { UpdateIssueCommand } from './update-issue.command.js'; import { IssueEventsService } from '../../services/issue-events.service.js'; @@ -10,8 +12,11 @@ import { IssueUserExpanderService } from '../../services/issue-user-expander.ser import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js'; +import { IssueParticipantsService } from '../../services/issue-participants.service.js'; +import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js'; +import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js'; -const buildIssue = () => +const buildIssue = (overrides: { assignee?: string | null; observers?: string[] } = {}) => Issue.reconstitute({ id: 1, key: 'ISSUE-1', @@ -26,6 +31,7 @@ const buildIssue = () => dueDate: null, createdAt: new Date('2026-01-01T00:00:00.000Z'), updatedAt: new Date('2026-01-01T00:00:00.000Z'), + ...overrides, }); describe('UpdateIssueUseCase', () => { @@ -34,6 +40,7 @@ describe('UpdateIssueUseCase', () => { let issueEventsService: IssueEventsService; let issueUserExpander: IssueUserExpanderService; let notificationsService: NotificationsService; + let getUsersUseCase: GetUsersUseCase; let useCase: UpdateIssueUseCase; beforeEach(() => { @@ -53,6 +60,7 @@ describe('UpdateIssueUseCase', () => { create: vi.fn(), update: vi.fn(), delete: vi.fn(), + findRole: vi.fn(), addMember: vi.fn(), }; issueEventsService = new IssueEventsService(); @@ -64,12 +72,15 @@ describe('UpdateIssueUseCase', () => { })), } as unknown as IssueUserExpanderService; notificationsService = { notify: vi.fn() } as unknown as NotificationsService; + getUsersUseCase = { + execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))), + } as unknown as GetUsersUseCase; useCase = new UpdateIssueUseCase( repository, - projectRepository, issueEventsService, issueUserExpander, new IssueAssignmentNotifierService(notificationsService), + new IssueParticipantsService(projectRepository, getUsersUseCase), ); }); @@ -85,7 +96,7 @@ describe('UpdateIssueUseCase', () => { }); it('passes the acting user to the repository as the author of the changes', async () => { - vi.mocked(repository.findById).mockResolvedValue(buildIssue()); + vi.mocked(repository.findById).mockResolvedValue(buildIssue({ assignee: 'user-7' })); await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE')); @@ -166,4 +177,77 @@ describe('UpdateIssueUseCase', () => { expect(dto.type).toBe('BUG'); }); + + describe('observers', () => { + it('forbids an observer from editing the issue', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] })); + + await expect(useCase.execute(new UpdateIssueCommand(1, 'user-5', 'Hijacked'))).rejects.toBeInstanceOf( + IssueEditForbiddenException, + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('replaces observers, adds them to the project and notifies only the new ones', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5', 'user-6'] })); + + const dto = await useCase.execute( + new UpdateIssueCommand( + 1, + 'user-1', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ['user-6', 'user-8'], + ), + ); + + expect(dto.observerIds).toEqual(['user-6', 'user-8']); + expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-8'] })); + expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-8', 'OBSERVER'); + expect(notificationsService.notify).toHaveBeenCalledTimes(1); + expect(notificationsService.notify).toHaveBeenCalledWith({ + userId: 'user-8', + subject: 'You are now observing ISSUE-1', + content: 'Original title', + }); + }); + + it('rejects unknown users before saving', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue()); + + await expect( + useCase.execute( + new UpdateIssueCommand( + 1, + 'user-1', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ['ghost'], + ), + ), + ).rejects.toBeInstanceOf(IssueObserverNotFoundException); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('drops the new assignee from observers', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] })); + + const dto = await useCase.execute( + new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-5'), + ); + + expect(dto.assigneeId).toBe('user-5'); + expect(dto.observerIds).toEqual([]); + }); + }); }); diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts index 5f243d2..70b30f6 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts @@ -1,15 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js'; -import { - PROJECT_REPOSITORY, - type IProjectRepository, -} from '../../../../project/domain/repositories/project.repository.interface.js'; import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js'; +import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js'; import { UpdateIssueCommand } from './update-issue.command.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js'; +import { IssueParticipantsService } from '../../services/issue-participants.service.js'; import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js'; @Injectable() @@ -17,11 +15,10 @@ export class UpdateIssueUseCase { constructor( @Inject(ISSUE_REPOSITORY) private readonly issueRepository: IIssueRepository, - @Inject(PROJECT_REPOSITORY) - private readonly projectRepository: IProjectRepository, private readonly issueEventsService: IssueEventsService, private readonly issueUserExpander: IssueUserExpanderService, private readonly issueAssignmentNotifier: IssueAssignmentNotifierService, + private readonly issueParticipants: IssueParticipantsService, ) {} async execute(command: UpdateIssueCommand): Promise { @@ -31,6 +28,12 @@ export class UpdateIssueUseCase { throw new IssueNotFoundException(command.id); } + if (!issue.canBeEditedBy(command.userId)) { + throw new IssueEditForbiddenException(command.id); + } + + const observersBefore = [...issue.observers]; + if (command.title !== undefined) { issue.updateTitle(command.title); } @@ -63,12 +66,16 @@ export class UpdateIssueUseCase { issue.updateDueDate(command.dueDate); } + // После assignee: новый исполнитель отбрасывается из наблюдателей + if (command.observers !== undefined) { + issue.setObservers(command.observers); + } + + await this.issueParticipants.ensureObserversExist(issue.observers.filter((id) => !observersBefore.includes(id))); await this.issueRepository.update(issue, command.userId); const dto = await this.issueUserExpander.expand(issue); - if (issue.assignee) { - await this.projectRepository.addMember(issue.projectId, issue.assignee); - } + await this.issueParticipants.addToProject(issue); const domainEvents = issue.pullDomainEvents(); this.issueAssignmentNotifier.notify(issue, domainEvents); diff --git a/src/modules/issue/domain/entities/issue-change.ts b/src/modules/issue/domain/entities/issue-change.ts index 02fe3f1..d202d0d 100644 --- a/src/modules/issue/domain/entities/issue-change.ts +++ b/src/modules/issue/domain/entities/issue-change.ts @@ -11,6 +11,7 @@ export const ISSUE_CHANGE_FIELDS = [ 'type', 'assignee', 'dueDate', + 'observer', ] as const; 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 пользователя. + * observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id) + * или снят (oldValue = id, newValue = null). */ export interface IssueFieldChange { field: IssueChangeField; diff --git a/src/modules/issue/domain/entities/issue.entity.spec.ts b/src/modules/issue/domain/entities/issue.entity.spec.ts index ca95bed..56bee20 100644 --- a/src/modules/issue/domain/entities/issue.entity.spec.ts +++ b/src/modules/issue/domain/entities/issue.entity.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { Issue } from './issue.entity.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueAssignedEvent } from '../events/issue-assigned.event.js'; +import { IssueObserverAddedEvent } from '../events/issue-observer-added.event.js'; describe('Issue', () => { describe('create', () => { @@ -248,4 +249,92 @@ describe('Issue', () => { 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 }, + ]); + }); + }); }); diff --git a/src/modules/issue/domain/entities/issue.entity.ts b/src/modules/issue/domain/entities/issue.entity.ts index 451420e..0208762 100644 --- a/src/modules/issue/domain/entities/issue.entity.ts +++ b/src/modules/issue/domain/entities/issue.entity.ts @@ -5,6 +5,7 @@ import { IssueType } from '../value-objects/issue-type.vo.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueUpdatedEvent } from '../events/issue-updated.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'; interface IssueProps { @@ -18,6 +19,8 @@ interface IssueProps { assignee: string | null; /** null — автор задачи удалил аккаунт */ reporter: string | null; + /** Видят задачу и комментируют, но не редактируют. Не пересекаются с reporter/assignee. */ + observers: string[]; projectId: string; dueDate: Date | null; createdAt: Date; @@ -37,8 +40,11 @@ interface IssueProps { */ export class Issue { private domainEvents: unknown[] = []; - /** Несохранённые изменения полей, по одному на поле, в порядке изменения */ - private changes = new Map(); + /** + * Несохранённые изменения полей, по одному на поле, в порядке изменения. + * Ключ — поле, для observer — поле и id наблюдателя. + */ + private changes = new Map(); private constructor(private readonly props: IssueProps) {} @@ -51,6 +57,7 @@ export class Issue { type?: string; assignee?: string | null; reporter: string; + observers?: string[]; projectId: string; dueDate?: Date | null; }): Issue { @@ -64,6 +71,7 @@ export class Issue { type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(), assignee: params.assignee ?? null, reporter: params.reporter, + observers: [], projectId: params.projectId, dueDate: params.dueDate ?? null, createdAt: new Date(), @@ -76,6 +84,11 @@ export class Issue { if (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; } @@ -91,6 +104,7 @@ export class Issue { type: string; assignee: string | null; reporter: string | null; + observers?: string[]; projectId: string; dueDate: Date | null; createdAt: Date; @@ -106,6 +120,7 @@ export class Issue { type: IssueType.create(props.type), assignee: props.assignee, reporter: props.reporter, + observers: [...(props.observers ?? [])], projectId: props.projectId, dueDate: props.dueDate, createdAt: props.createdAt, @@ -149,6 +164,15 @@ export class Issue { 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 { return this.props.projectId; } @@ -200,6 +224,8 @@ export class Issue { this.recordChange('assignee', this.assignee, userId); this.props.assignee = userId; this.addDomainEvent(new IssueAssignedEvent(this.key, userId)); + // Исполнитель и так видит задачу и может её редактировать + this.removeObserver(userId); } unassign(): void { @@ -212,6 +238,35 @@ export class Issue { 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[] { const changes = [...this.changes.values()]; @@ -224,14 +279,19 @@ export class Issue { * ни 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; - const pending = this.changes.get(field); + const pending = this.changes.get(key); if (!pending) { - this.changes.set(field, { field, oldValue, newValue }); + this.changes.set(key, { field, oldValue, newValue }); } else if (pending.oldValue === newValue) { - this.changes.delete(field); + this.changes.delete(key); } else { pending.newValue = newValue; } @@ -239,6 +299,11 @@ export class Issue { 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 { this.props.updatedAt = new Date(); this.addDomainEvent(new IssueUpdatedEvent(this.key)); diff --git a/src/modules/issue/domain/events/issue-observer-added.event.ts b/src/modules/issue/domain/events/issue-observer-added.event.ts new file mode 100644 index 0000000..b531332 --- /dev/null +++ b/src/modules/issue/domain/events/issue-observer-added.event.ts @@ -0,0 +1,7 @@ +export class IssueObserverAddedEvent { + constructor( + public readonly issueKey: string, + public readonly observer: string, + public readonly occurredAt: Date = new Date(), + ) {} +} diff --git a/src/modules/issue/domain/exceptions/issue-edit-forbidden.exception.ts b/src/modules/issue/domain/exceptions/issue-edit-forbidden.exception.ts new file mode 100644 index 0000000..8d84646 --- /dev/null +++ b/src/modules/issue/domain/exceptions/issue-edit-forbidden.exception.ts @@ -0,0 +1,7 @@ +/** Задача видна пользователю (он наблюдатель), но редактировать её он не может */ +export class IssueEditForbiddenException extends Error { + constructor(id: number) { + super(`Задача с id=${id} доступна наблюдателю только для чтения`); + this.name = 'IssueEditForbiddenException'; + } +} diff --git a/src/modules/issue/domain/exceptions/issue-observer-not-found.exception.ts b/src/modules/issue/domain/exceptions/issue-observer-not-found.exception.ts new file mode 100644 index 0000000..4ff1661 --- /dev/null +++ b/src/modules/issue/domain/exceptions/issue-observer-not-found.exception.ts @@ -0,0 +1,6 @@ +export class IssueObserverNotFoundException extends Error { + constructor(userIds: string[]) { + super(`Пользователи не найдены: ${userIds.join(', ')}`); + this.name = 'IssueObserverNotFoundException'; + } +} diff --git a/src/modules/issue/domain/exceptions/issue-project-forbidden.exception.ts b/src/modules/issue/domain/exceptions/issue-project-forbidden.exception.ts new file mode 100644 index 0000000..a421589 --- /dev/null +++ b/src/modules/issue/domain/exceptions/issue-project-forbidden.exception.ts @@ -0,0 +1,7 @@ +/** Проект виден пользователю (он наблюдатель), но создавать в нём задачи он не может */ +export class IssueProjectForbiddenException extends Error { + constructor(projectId: string) { + super(`В проекте с id=${projectId} наблюдатель не может создавать задачи`); + this.name = 'IssueProjectForbiddenException'; + } +} diff --git a/src/modules/issue/domain/repositories/issue.repository.interface.ts b/src/modules/issue/domain/repositories/issue.repository.interface.ts index 5319d7f..4b0f790 100644 --- a/src/modules/issue/domain/repositories/issue.repository.interface.ts +++ b/src/modules/issue/domain/repositories/issue.repository.interface.ts @@ -4,8 +4,15 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in /** DI-токен, т.к. интерфейсы стираются при компиляции TS */ 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 { - /** Задача видна только пользователю, который является в ней reporter или assignee */ + /** + * Задача видна своим reporter, assignee и наблюдателям. Редактировать её + * могут только reporter и assignee — см. Issue.canBeEditedBy. + */ findById(id: number, userId: string): Promise; findByCKey(ckey: string, userId: string): Promise; findAll(params: { @@ -18,6 +25,7 @@ export interface IIssueRepository { projectId?: string; hasDueDate?: boolean; type?: string[]; + role?: IssueUserRole; }): Promise<{ list: Issue[]; meta: PaginationMeta }>; /** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */ findByDueDateRange(params: { @@ -32,7 +40,7 @@ export interface IIssueRepository { countByStatus(userId: string, projectId?: string): Promise>; /** Сохраняет задачу и записи о её создании в историю (автор — reporter) */ create(issue: Issue): Promise; - /** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */ + /** Сохраняет задачу, её наблюдателей и накопленные изменения полей в историю одной транзакцией */ update(issue: Issue, actorId: string): Promise; delete(id: number): Promise; } diff --git a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts index 2904431..735f052 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts @@ -17,6 +17,7 @@ describe('IssuePrismaMapper', () => { dueDate: null, createdAt: new Date('2026-01-01T00: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', () => { @@ -29,6 +30,7 @@ describe('IssuePrismaMapper', () => { expect(issue.priority).toBe(2); expect(issue.type).toBe('BUG'); expect(issue.assignee).toBe('user-2'); + expect(issue.observers).toEqual(['user-3']); }); it('maps a new domain entity to Prisma create input', () => { diff --git a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts index bc552b7..87901a6 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts @@ -2,8 +2,11 @@ import type { Issue as PrismaIssue, Prisma } from '../../../../../../generated/p import { Issue } from '../../../../domain/entities/issue.entity.js'; import { getCKey } from '../../../../domain/helpers/get-ckey.js'; +/** Запись задачи вместе с id наблюдателей (см. WITH_OBSERVERS в репозитории) */ +export type PrismaIssueWithObservers = PrismaIssue & { observers: Array<{ userId: string }> }; + export class IssuePrismaMapper { - static toDomain(record: PrismaIssue): Issue { + static toDomain(record: PrismaIssueWithObservers): Issue { return Issue.reconstitute({ id: record.id, key: getCKey(record.id, record.key), @@ -14,6 +17,7 @@ export class IssuePrismaMapper { type: record.type, assignee: record.assigneeId, reporter: record.reporterId, + observers: record.observers.map((observer) => observer.userId), projectId: record.projectId, dueDate: record.dueDate, createdAt: record.createdAt, @@ -31,6 +35,7 @@ export class IssuePrismaMapper { type: issue.type, assigneeId: issue.assignee, reporterId: issue.reporter, + observers: { create: issue.observers.map((userId) => ({ userId })) }, projectId: issue.projectId, dueDate: issue.dueDate, createdAt: issue.createdAt, diff --git a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts index 2b1c98a..8eb58a6 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.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 { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.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'; -/** Задача видна только своему reporter/assignee */ -function ownedByUser(userId: string) { - return [{ assigneeId: userId }, { reporterId: userId }]; +/** id наблюдателей подгружаются вместе с задачей — они часть сущности */ +const WITH_OBSERVERS = { observers: { select: { userId: true } } } satisfies Prisma.IssueInclude; + +/** + * Задача видна своим 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[]; assigneeId?: string; projectId?: string; + role?: IssueUserRole; }): Prisma.IssueWhereInput[] { return [ - { OR: ownedByUser(params.userId) }, + { OR: visibleTo(params.userId) }, + ...(params.role ? [roleFilter(params.userId, params.role)] : []), ...(params.status?.length ? [{ status: { in: params.status } }] : []), ...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), ...(params.projectId ? [{ projectId: params.projectId }] : []), @@ -50,7 +69,8 @@ export class IssuePrismaRepository implements IIssueRepository { async findById(id: number, userId: string): Promise { 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; } @@ -58,7 +78,8 @@ export class IssuePrismaRepository implements IIssueRepository { async findByCKey(ckey: string, userId: string): Promise { const { id, key } = splitCKey(ckey); 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; @@ -74,6 +95,7 @@ export class IssuePrismaRepository implements IIssueRepository { projectId?: string; hasDueDate?: boolean; type?: string[]; + role?: IssueUserRole; }): Promise<{ list: Issue[]; meta: PaginationMeta }> { const page = Math.max(1, params?.page); const limit = Math.max(1, params?.limit); @@ -101,6 +123,7 @@ export class IssuePrismaRepository implements IIssueRepository { const [records, totalCount] = await this.prisma.$transaction([ this.prisma.issue.findMany({ ...queryArgs, + include: WITH_OBSERVERS, take: limit, skip: (page - 1) * limit, orderBy: { createdAt: 'desc' }, @@ -127,6 +150,7 @@ export class IssuePrismaRepository implements IIssueRepository { where: { AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }], }, + include: WITH_OBSERVERS, take: Math.max(1, params.limit), orderBy: [{ dueDate: 'asc' }, { id: 'asc' }], }); @@ -137,7 +161,7 @@ export class IssuePrismaRepository implements IIssueRepository { async countByStatus(userId: string, projectId?: string): Promise> { const groups = await this.prisma.issue.groupBy({ by: ['status'], - where: { AND: [{ OR: ownedByUser(userId) }, ...(projectId ? [{ projectId }] : [])] }, + where: { AND: [{ OR: visibleTo(userId) }, ...(projectId ? [{ projectId }] : [])] }, _count: { _all: true }, }); @@ -151,6 +175,7 @@ export class IssuePrismaRepository implements IIssueRepository { const record = await this.prisma.$transaction(async (tx) => { const created = await tx.issue.create({ data: IssuePrismaMapper.toCreateInput(issue), + include: WITH_OBSERVERS, }); await tx.issueChange.createMany({ data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt), @@ -181,8 +206,19 @@ export class IssuePrismaRepository implements IIssueRepository { return; } + const observers = [...issue.observers]; await this.prisma.$transaction([ 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({ data: toChangeRows(issueId, changes, actorId, issue.updatedAt), }), diff --git a/src/modules/issue/issue.module.ts b/src/modules/issue/issue.module.ts index e2be5b2..b0415f9 100644 --- a/src/modules/issue/issue.module.ts +++ b/src/modules/issue/issue.module.ts @@ -18,6 +18,8 @@ import { IssueUserExpanderService } from './application/services/issue-user-expa 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 { 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({ imports: [PrismaModule, ProjectModule, UserModule, NotificationModule], @@ -31,10 +33,12 @@ import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar DeleteIssueUseCase, GetIssueStatsUseCase, ListCalendarIssuesUseCase, + UnobserveIssueUseCase, IssueResolver, IssueEventsService, IssueUserExpanderService, IssueAssignmentNotifierService, + IssueParticipantsService, { // Domain видит только интерфейс IIssueRepository, // конкретную реализацию (Prisma) подставляет DI-контейнер здесь diff --git a/src/modules/issue/presentation/controllers/issue.controller.ts b/src/modules/issue/presentation/controllers/issue.controller.ts index e9ab999..afb11e2 100644 --- a/src/modules/issue/presentation/controllers/issue.controller.ts +++ b/src/modules/issue/presentation/controllers/issue.controller.ts @@ -3,6 +3,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, 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 { CalendarIssuesQueryDto } from '../dto/calendar-issues-query.dto.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') export class IssueController { @@ -54,6 +60,7 @@ export class IssueController { private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly getIssueStatsUseCase: GetIssueStatsUseCase, private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, + private readonly unobserveIssueUseCase: UnobserveIssueUseCase, private readonly issueEventsService: IssueEventsService, ) {} @@ -71,6 +78,7 @@ export class IssueController { dto.assignee, dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined, dto.type, + dto.observers, ), ), ); @@ -89,6 +97,7 @@ export class IssueController { query.assigneeId, query.hasDueDate, query.type, + query.role, ), ); } @@ -118,13 +127,15 @@ export class IssueController { /** * SSE-поток для зарегистрированных пользователей (доступ уже проверен * глобальным AuthGuard): уведомляет о каждой созданной, обновлённой - * и удалённой задаче, видимой текущему пользователю (reporter/assignee — - * то же правило видимости, что и в IssuePrismaRepository.ownedByUser). + * и удалённой задаче, видимой текущему пользователю (reporter/assignee/наблюдатель — + * то же правило видимости, что и в IssuePrismaRepository.visibleTo). */ @Sse('events') issueCreatedEvents(@Session() session: UserSession): Observable { 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( this.issueEventsService.onIssueCreated().pipe( @@ -170,6 +181,7 @@ export class IssueController { dto.assignee, dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate), dto.type, + dto.observers, ), ), ); @@ -181,6 +193,13 @@ export class IssueController { 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-слоя */ private async handleErrors(fn: () => Promise): Promise { try { @@ -189,9 +208,12 @@ export class IssueController { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { throw new NotFoundException(error.message); } - if (error instanceof InvalidCalendarRangeException) { + if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) { throw new BadRequestException(error.message); } + if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) { + throw new ForbiddenException(error.message); + } throw error; } } diff --git a/src/modules/issue/presentation/dto/create-issue.dto.ts b/src/modules/issue/presentation/dto/create-issue.dto.ts index 69d2f8f..8b95260 100644 --- a/src/modules/issue/presentation/dto/create-issue.dto.ts +++ b/src/modules/issue/presentation/dto/create-issue.dto.ts @@ -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'; export class CreateIssueRequestDto { @@ -36,4 +48,11 @@ export class CreateIssueRequestDto { @IsOptional() @IsDateString() dueDate?: string; + + /** id пользователей-наблюдателей: видят задачу и комментируют, но не редактируют */ + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + observers?: string[]; } diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.ts b/src/modules/issue/presentation/dto/filter-issue.dto.ts index a36e9f9..b0bea45 100644 --- a/src/modules/issue/presentation/dto/filter-issue.dto.ts +++ b/src/modules/issue/presentation/dto/filter-issue.dto.ts @@ -1,4 +1,6 @@ +import { IsIn, IsOptional } from 'class-validator'; 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'; export class FilterIssueDto { @@ -26,4 +28,9 @@ export class FilterIssueDto { /** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */ @BooleanDecorator({ isOptional: true }) hasDueDate?: boolean; + + /** Только задачи, где текущий пользователь исполнитель, автор или наблюдатель */ + @IsOptional() + @IsIn(ISSUE_USER_ROLES) + role?: IssueUserRole; } diff --git a/src/modules/issue/presentation/dto/update-issue.dto.ts b/src/modules/issue/presentation/dto/update-issue.dto.ts index b208ddb..47a749b 100644 --- a/src/modules/issue/presentation/dto/update-issue.dto.ts +++ b/src/modules/issue/presentation/dto/update-issue.dto.ts @@ -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_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; @@ -33,4 +44,11 @@ export class UpdateIssueRequestDto { @IsOptional() @IsDateString() dueDate?: string | null; + + /** id пользователей-наблюдателей (полный новый список): видят задачу и комментируют, но не редактируют */ + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + observers?: string[]; } diff --git a/src/modules/issue/presentation/graphql/create-issue.input.ts b/src/modules/issue/presentation/graphql/create-issue.input.ts index 51329c3..6090217 100644 --- a/src/modules/issue/presentation/graphql/create-issue.input.ts +++ b/src/modules/issue/presentation/graphql/create-issue.input.ts @@ -1,5 +1,17 @@ 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'; @InputType() @@ -46,4 +58,11 @@ export class CreateIssueInput { @IsOptional() @IsDate() dueDate?: Date; + + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + observers?: string[]; } diff --git a/src/modules/issue/presentation/graphql/issue-detail.type.ts b/src/modules/issue/presentation/graphql/issue-detail.type.ts index 09a4448..1e11ba7 100644 --- a/src/modules/issue/presentation/graphql/issue-detail.type.ts +++ b/src/modules/issue/presentation/graphql/issue-detail.type.ts @@ -30,12 +30,18 @@ export class IssueDetailType { @Field(() => String, { nullable: true }) reporterId: string | null; + @Field(() => [String]) + observerIds: string[]; + @Field(() => UserType, { nullable: true }) assignee: UserType | null; @Field(() => UserType, { nullable: true }) reporter: UserType | null; + @Field(() => [UserType]) + observers: UserType[]; + @Field() projectId: string; diff --git a/src/modules/issue/presentation/graphql/issue.type.ts b/src/modules/issue/presentation/graphql/issue.type.ts index 27ec44d..1daa0b7 100644 --- a/src/modules/issue/presentation/graphql/issue.type.ts +++ b/src/modules/issue/presentation/graphql/issue.type.ts @@ -29,6 +29,9 @@ export class IssueType { @Field(() => String, { nullable: true }) reporterId: string | null; + @Field(() => [String]) + observerIds: string[]; + @Field() projectId: string; diff --git a/src/modules/issue/presentation/graphql/update-issue.input.ts b/src/modules/issue/presentation/graphql/update-issue.input.ts index 5589999..1f5590d 100644 --- a/src/modules/issue/presentation/graphql/update-issue.input.ts +++ b/src/modules/issue/presentation/graphql/update-issue.input.ts @@ -1,5 +1,5 @@ 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_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; @@ -42,4 +42,11 @@ export class UpdateIssueInput { @IsOptional() @IsDate() dueDate?: Date | null; + + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsString({ each: true }) + observers?: string[]; } diff --git a/src/modules/issue/presentation/resolvers/issue.resolver.ts b/src/modules/issue/presentation/resolvers/issue.resolver.ts index 84ef481..4d18513 100644 --- a/src/modules/issue/presentation/resolvers/issue.resolver.ts +++ b/src/modules/issue/presentation/resolvers/issue.resolver.ts @@ -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 { Session, type UserSession } from '@thallesp/nestjs-better-auth'; 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 { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.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) export class IssueResolver { @@ -38,6 +44,7 @@ export class IssueResolver { private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase, + private readonly unobserveIssueUseCase: UnobserveIssueUseCase, ) {} @Query(() => IssueDetailType, { name: 'issue' }) @@ -58,7 +65,12 @@ export class IssueResolver { @Args('projectId', { type: () => String, nullable: true }) projectId?: string, @Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean, @Args('type', { type: () => [String], nullable: true }) type?: string[], + @Args('role', { type: () => String, nullable: true }) role?: string, ): 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( new ListIssuesQuery( limit, @@ -70,6 +82,7 @@ export class IssueResolver { undefined, hasDueDate ?? undefined, type ?? undefined, + (role as IssueUserRole | undefined) ?? undefined, ), ); } @@ -105,6 +118,7 @@ export class IssueResolver { input.assignee, input.dueDate, input.type, + input.observers, ), ), ); @@ -128,6 +142,7 @@ export class IssueResolver { input.assignee, input.dueDate, input.type, + input.observers ?? undefined, ), ), ); @@ -139,6 +154,13 @@ export class IssueResolver { return true; } + /** Наблюдатель отписывается от задачи сам */ + @Mutation(() => Boolean, { name: 'unobserveIssue' }) + async unobserve(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise { + await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id))); + return true; + } + /** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */ private async handleErrors(fn: () => Promise): Promise { try { @@ -147,9 +169,12 @@ export class IssueResolver { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { throw new NotFoundException(error.message); } - if (error instanceof InvalidCalendarRangeException) { + if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) { throw new BadRequestException(error.message); } + if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) { + throw new ForbiddenException(error.message); + } throw error; } } diff --git a/src/modules/project/domain/repositories/project.repository.interface.ts b/src/modules/project/domain/repositories/project.repository.interface.ts index 30d41e7..97a7713 100644 --- a/src/modules/project/domain/repositories/project.repository.interface.ts +++ b/src/modules/project/domain/repositories/project.repository.interface.ts @@ -4,6 +4,12 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in /** DI-токен, т.к. интерфейсы стираются при компиляции TS */ export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY'); +/** + * Роль пользователя в проекте. OWNER и MEMBER создают задачи, + * OBSERVER только видит проект (он наблюдатель отдельных задач). + */ +export type ProjectRole = 'OWNER' | 'MEMBER' | 'OBSERVER'; + export interface IProjectRepository { /** Проект виден владельцу и участникам проекта (ProjectMember) */ findById(id: string, userId: string): Promise; @@ -11,6 +17,11 @@ export interface IProjectRepository { create(project: Project): Promise; update(project: Project): Promise; delete(id: string): Promise; - /** Добавляет пользователя в участники проекта, если он ещё не владелец/участник */ - addMember(projectId: string, userId: string): Promise; + /** Роль пользователя в проекте; null — проект ему не виден (или не существует) */ + findRole(projectId: string, userId: string): Promise; + /** + * Добавляет пользователя в участники проекта. Роль только повышается: + * OBSERVER становится MEMBER, но MEMBER не понижается до OBSERVER. + */ + addMember(projectId: string, userId: string, role: Exclude): Promise; } diff --git a/src/modules/project/infrastructure/persistence/prisma/repositories/project.prisma-repository.ts b/src/modules/project/infrastructure/persistence/prisma/repositories/project.prisma-repository.ts index 3e9292b..570227c 100644 --- a/src/modules/project/infrastructure/persistence/prisma/repositories/project.prisma-repository.ts +++ b/src/modules/project/infrastructure/persistence/prisma/repositories/project.prisma-repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.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 { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js'; import { Prisma } from '../../../../../../generated/prisma/client.js'; @@ -69,11 +69,21 @@ export class ProjectPrismaRepository implements IProjectRepository { await this.prisma.project.delete({ where: { id } }); } - async addMember(projectId: string, userId: string): Promise { + async findRole(projectId: string, userId: string): Promise { + 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): Promise { await this.prisma.projectMember.upsert({ where: { projectId_userId: { projectId, userId } }, - create: { projectId, userId }, - update: {}, + create: { projectId, userId, role }, + update: role === 'MEMBER' ? { role } : {}, }); }