Compare commits
4
Commits
46903e9ec5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac4d314e4 | ||
|
|
917ea9748e | ||
|
|
d170fc8790 | ||
|
|
b1f6b61994 |
No files matched your search
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: Docker build
|
||||
run: docker build --tag ${{ env.IMAGE_NAME }} .
|
||||
- name: Login to gitea
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Prisma
|
||||
|
||||
npm install prisma@7 --save-dev
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "issue_observer" (
|
||||
"issueId" INTEGER NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "issue_observer_pkey" PRIMARY KEY ("issueId","userId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "issue_observer_userId_idx" ON "issue_observer"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "issue_observer" ADD CONSTRAINT "issue_observer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectRole" AS ENUM ('MEMBER', 'OBSERVER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "project_member" ADD COLUMN "role" "ProjectRole" NOT NULL DEFAULT 'MEMBER';
|
||||
@@ -0,0 +1,37 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"normalizedName" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL DEFAULT 'default',
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "issue_tag" (
|
||||
"issueId" INTEGER NOT NULL,
|
||||
"tagId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "issue_tag_pkey" PRIMARY KEY ("issueId","tagId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tag_projectId_normalizedName_key" ON "tag"("projectId", "normalizedName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "issue_tag_tagId_idx" ON "issue_tag"("tagId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tag" ADD CONSTRAINT "tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+64
-3
@@ -31,6 +31,7 @@ model User {
|
||||
comments Comment[]
|
||||
uploadedFiles File[] @relation(name: "fileUploader")
|
||||
issueChanges IssueChange[]
|
||||
observedIssues IssueObserver[]
|
||||
|
||||
@@unique([email])
|
||||
@@map("user")
|
||||
@@ -103,6 +104,8 @@ model Issue {
|
||||
comments Comment[]
|
||||
attachments IssueAttachment[]
|
||||
changes IssueChange[]
|
||||
observers IssueObserver[]
|
||||
tags IssueTag[]
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@ -118,6 +121,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
|
||||
@@ -128,6 +146,7 @@ model Project {
|
||||
ownerId String
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
||||
members ProjectMember[]
|
||||
tags Tag[]
|
||||
|
||||
@@unique([key])
|
||||
@@index([createdAt])
|
||||
@@ -135,11 +154,51 @@ model Project {
|
||||
@@map("project")
|
||||
}
|
||||
|
||||
/// Тег — элемент справочника проекта. Уникален в проекте без учёта регистра:
|
||||
/// normalizedName — name в нижнем регистре. color — из палитры (TAG_COLORS),
|
||||
/// валидируется в коде. Задаче тег назначают по имени — отсутствующий создаётся.
|
||||
model Tag {
|
||||
id String @id @default(uuid(7))
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
normalizedName String
|
||||
color String @default("default")
|
||||
issues IssueTag[]
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
|
||||
@@unique([projectId, normalizedName])
|
||||
@@map("tag")
|
||||
}
|
||||
|
||||
/// Тег задачи. Удаление тега из справочника снимает его со всех задач (без записи в историю).
|
||||
model IssueTag {
|
||||
issueId Int
|
||||
tagId String
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([issueId, tagId])
|
||||
@@index([tagId])
|
||||
@@map("issue_tag")
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -277,9 +336,11 @@ model CommentAttachment {
|
||||
|
||||
/// История изменений задачи — одна строка на изменённое поле.
|
||||
/// Строки одного сохранения задачи объединены общим changeId.
|
||||
/// field: created | title | content | status | priority | assignee | dueDate
|
||||
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
|
||||
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
||||
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
|
||||
/// observer — id пользователя, tag — имя тега на момент изменения.
|
||||
/// При создании задачи пишутся две строки: created и status null -> OPEN,
|
||||
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
|
||||
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CommentModule } from './modules/comment/comment.module.js';
|
||||
import { FileModule } from './modules/file/file.module.js';
|
||||
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
||||
import { HistoryModule } from './modules/history/history.module.js';
|
||||
import { TagModule } from './modules/tag/tag.module.js';
|
||||
import { AuthModule } from '@thallesp/nestjs-better-auth';
|
||||
import { auth } from './lib/auth.js';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
@@ -51,6 +52,7 @@ import { join } from 'node:path';
|
||||
FileModule,
|
||||
IssueAttachmentModule,
|
||||
HistoryModule,
|
||||
TagModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
|
||||
|
||||
/**
|
||||
* Статистика задач по дням, считается по истории изменений (issue_change).
|
||||
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee.
|
||||
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetDailyIssueStatsUseCase {
|
||||
|
||||
+7
-3
@@ -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<IssueChangeField>(['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<Map<string, UserDto>> {
|
||||
const ids = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ export interface DailyStatusCount {
|
||||
export interface IIssueHistoryRepository {
|
||||
/** История задачи от старых изменений к новым */
|
||||
findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
|
||||
/** Только задачи, видимые пользователю (reporter/assignee) */
|
||||
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
|
||||
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
|
||||
/** Только задачи, видимые пользователю (reporter/assignee) */
|
||||
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
|
||||
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
|
||||
}
|
||||
+4
-3
@@ -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}
|
||||
)`;
|
||||
}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export class IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit
|
||||
/**
|
||||
* Файл виден, если видна хоть одна задача, к которой он прикреплён.
|
||||
* Видимость проверяем через IIssueRepository.findById, чтобы правило
|
||||
* (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче.
|
||||
* (reporter/assignee/наблюдатель) жило в одном месте. Обычно файл прикреплён к одной задаче.
|
||||
*/
|
||||
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
|
||||
|
||||
+7
-2
@@ -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<IssueAttachmentDto> {
|
||||
if (!(await this.issueRepository.findById(command.issueId, command.userId))) {
|
||||
const issue = await this.issueRepository.findById(command.issueId, command.userId);
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.issueId);
|
||||
}
|
||||
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(
|
||||
|
||||
+7
-1
@@ -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<void> {
|
||||
if (!(await this.issueRepository.findById(command.issueId, command.userId))) {
|
||||
const issue = await this.issueRepository.findById(command.issueId, command.userId);
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.issueId);
|
||||
}
|
||||
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);
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Тег задачи — из справочника проекта */
|
||||
export class IssueTagDto {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IssueTagDto } from './issue-tag.dto.js';
|
||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
|
||||
export class IssueWithUsersDto {
|
||||
@@ -12,6 +13,10 @@ export class IssueWithUsersDto {
|
||||
reporterId: string | null;
|
||||
assignee: UserDto | null;
|
||||
reporter: UserDto | null;
|
||||
observerIds: string[];
|
||||
/** Удалённые пользователи сюда не попадают */
|
||||
observers: UserDto[];
|
||||
tags: IssueTagDto[];
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { IssueTagDto } from './issue-tag.dto.js';
|
||||
|
||||
export class IssueDto {
|
||||
id: number;
|
||||
key: string;
|
||||
@@ -8,6 +10,8 @@ export class IssueDto {
|
||||
type: string;
|
||||
assigneeId: string | null;
|
||||
reporterId: string | null;
|
||||
observerIds: string[];
|
||||
tags: IssueTagDto[];
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -2,8 +2,15 @@ import { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueDto } from '../dto/issue.dto.js';
|
||||
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
|
||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
import { Tag } from '../../../tag/domain/entities/tag.entity.js';
|
||||
import type { IssueTagRef } from '../../domain/value-objects/issue-tags.vo.js';
|
||||
|
||||
export class IssueApplicationMapper {
|
||||
/** Тег справочника (уже сохранённый) → тег задачи */
|
||||
static toTagRef(tag: Tag): IssueTagRef {
|
||||
return { id: tag.id as string, name: tag.name, color: tag.color };
|
||||
}
|
||||
|
||||
static toDto(issue: Issue): IssueDto {
|
||||
return {
|
||||
id: issue.id as number,
|
||||
@@ -15,6 +22,8 @@ export class IssueApplicationMapper {
|
||||
type: issue.type,
|
||||
assigneeId: issue.assignee,
|
||||
reporterId: issue.reporter,
|
||||
observerIds: [...issue.observers],
|
||||
tags: issue.tags.map((tag) => ({ ...tag })),
|
||||
projectId: issue.projectId,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
@@ -22,7 +31,7 @@ export class IssueApplicationMapper {
|
||||
};
|
||||
}
|
||||
|
||||
/** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */
|
||||
/** Раскрывает assignee/reporter/наблюдателей в полноценных пользователей (см. GetIssueUseCase) */
|
||||
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
|
||||
return {
|
||||
id: issue.id as number,
|
||||
@@ -36,6 +45,9 @@ 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) ?? []),
|
||||
tags: issue.tags.map((tag) => ({ ...tag })),
|
||||
projectId: issue.projectId,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
|
||||
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.js';
|
||||
import { IssueObserverAddedEvent } from '../../domain/events/issue-observer-added.event.js';
|
||||
import type { Issue } from '../../domain/entities/issue.entity.js';
|
||||
|
||||
/**
|
||||
* Уведомляет нового исполнителя задачи (в т.ч. при самоназначении)
|
||||
* по всем включённым у него каналам.
|
||||
* и новых наблюдателей по всем включённым у них каналам.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueAssignmentNotifierService {
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
notify(issue: Issue, domainEvents: unknown[]): void {
|
||||
this.notifyAssignee(issue, domainEvents);
|
||||
this.notifyObservers(issue, domainEvents);
|
||||
}
|
||||
|
||||
private notifyAssignee(issue: Issue, domainEvents: unknown[]): void {
|
||||
const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent);
|
||||
// Последнее назначение могло быть отменено снятием исполнителя в той же операции
|
||||
if (!assigned || assigned.assignee !== issue.assignee) return;
|
||||
@@ -22,4 +28,21 @@ export class IssueAssignmentNotifierService {
|
||||
content: issue.title,
|
||||
});
|
||||
}
|
||||
|
||||
private notifyObservers(issue: Issue, domainEvents: unknown[]): void {
|
||||
const added = new Set(
|
||||
domainEvents.filter((event) => event instanceof IssueObserverAddedEvent).map((event) => event.observer),
|
||||
);
|
||||
|
||||
// Наблюдатель мог быть снят или назначен исполнителем в той же операции
|
||||
for (const userId of added) {
|
||||
if (!issue.observers.includes(userId)) continue;
|
||||
|
||||
this.notificationsService.notify({
|
||||
userId,
|
||||
subject: `You are now observing ${issue.key}`,
|
||||
content: issue.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
import type { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueObserverNotFoundException } from '../../domain/exceptions/issue-observer-not-found.exception.js';
|
||||
|
||||
/** Участники задачи (assignee и наблюдатели) за пределами самой сущности */
|
||||
@Injectable()
|
||||
export class IssueParticipantsService {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
private readonly getUsersUseCase: GetUsersUseCase,
|
||||
) {}
|
||||
|
||||
/** Проверяет до сохранения, что новые наблюдатели существуют — иначе упадёт внешний ключ */
|
||||
async ensureObserversExist(userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const found = new Set(users.map((user) => user.id));
|
||||
const missing = userIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new IssueObserverNotFoundException(missing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Исполнитель и наблюдатели становятся участниками проекта, чтобы видеть его в списке.
|
||||
* Наблюдатель — с ролью OBSERVER: задачи в проекте он создавать не может.
|
||||
*/
|
||||
async addToProject(issue: Issue): Promise<void> {
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee, 'MEMBER');
|
||||
}
|
||||
for (const userId of issue.observers) {
|
||||
await this.projectRepository.addMember(issue.projectId, userId, 'OBSERVER');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { IssueApplicationMapper } from '../mappers/issue-application.mapper.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
|
||||
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */
|
||||
/** Раскрывает assignee/reporter/наблюдателей задачи (или списка задач) в полноценных пользователей */
|
||||
@Injectable()
|
||||
export class IssueUserExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
@@ -19,7 +19,11 @@ export class IssueUserExpanderService {
|
||||
if (issues.length === 0) return [];
|
||||
|
||||
const userIds = [
|
||||
...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))),
|
||||
...new Set(
|
||||
issues
|
||||
.flatMap((issue) => [issue.reporter, issue.assignee, ...issue.observers])
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
|
||||
@@ -9,5 +9,8 @@ export class CreateIssueCommand {
|
||||
public readonly assignee?: string,
|
||||
public readonly dueDate?: Date,
|
||||
public readonly type?: string,
|
||||
public readonly observers?: string[],
|
||||
/** Имена тегов; отсутствующие в справочнике проекта создаются */
|
||||
public readonly tags?: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import type { ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||
import { buildTag, mockTagRepository } from '../../../../tag/application/use-cases/test-helpers.js';
|
||||
import type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { CreateIssueUseCase } from './create-issue.use-case.js';
|
||||
@@ -9,6 +11,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 +23,8 @@ describe('CreateIssueUseCase', () => {
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let notificationsService: NotificationsService;
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let tagRepository: ITagRepository;
|
||||
let useCase: CreateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -35,6 +44,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 +56,18 @@ 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;
|
||||
tagRepository = mockTagRepository([buildTag({ id: 'tag-1', name: 'backend', color: 'primary' })]);
|
||||
useCase = new CreateIssueUseCase(
|
||||
repository,
|
||||
projectRepository,
|
||||
tagRepository,
|
||||
issueEventsService,
|
||||
issueUserExpander,
|
||||
new IssueAssignmentNotifierService(notificationsService),
|
||||
new IssueParticipantsService(projectRepository, getUsersUseCase),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -186,4 +202,104 @@ describe('CreateIssueUseCase', () => {
|
||||
expect(task.type).toBe('TASK');
|
||||
expect(bug.type).toBe('BUG');
|
||||
});
|
||||
|
||||
it('assigns tags by name, creating the missing ones in the project', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new CreateIssueCommand(
|
||||
'ISSUE-6',
|
||||
'Something',
|
||||
'user-1',
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['ui', 'Backend', 'UI'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(tagRepository.findOrCreateByNames).toHaveBeenCalledWith('project-1', ['ui', 'Backend', 'UI']);
|
||||
// Существующий тег берётся в написании справочника
|
||||
expect(dto.tags).toEqual([
|
||||
{ id: 'tag-1', name: 'backend', color: 'primary' },
|
||||
{ id: 'tag-ui', name: 'ui', color: 'default' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not create tags in a project the reporter cannot create issues in', async () => {
|
||||
vi.mocked(projectRepository.findRole).mockResolvedValue('OBSERVER');
|
||||
|
||||
await expect(
|
||||
useCase.execute(
|
||||
new CreateIssueCommand(
|
||||
'ISSUE-7',
|
||||
'Something',
|
||||
'user-1',
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['ui'],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(tagRepository.findOrCreateByNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks project access on behalf of the reporter', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1', 'project-1'));
|
||||
|
||||
expect(projectRepository.findRole).toHaveBeenCalledWith('project-1', 'user-1');
|
||||
});
|
||||
|
||||
it('refuses to create an issue in a project the reporter has no access to', async () => {
|
||||
vi.mocked(projectRepository.findRole).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-9', 'project-1')),
|
||||
).rejects.toBeInstanceOf(IssueProjectNotFoundException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
expect(projectRepository.addMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forbids a project observer from creating issues', async () => {
|
||||
vi.mocked(projectRepository.findRole).mockResolvedValue('OBSERVER');
|
||||
|
||||
await expect(
|
||||
useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-5', 'project-1')),
|
||||
).rejects.toBeInstanceOf(IssueProjectForbiddenException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the assignee to the project as a member and observers as observers', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
await useCase.execute(
|
||||
new CreateIssueCommand(
|
||||
'ISSUE-1',
|
||||
'Fix login bug',
|
||||
'user-1',
|
||||
'project-1',
|
||||
undefined,
|
||||
undefined,
|
||||
'user-2',
|
||||
undefined,
|
||||
undefined,
|
||||
['user-3'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-2', 'MEMBER');
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-3', 'OBSERVER');
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,17 @@ 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 { IssueApplicationMapper } from '../../mappers/issue-application.mapper.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';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateIssueUseCase {
|
||||
@@ -19,12 +24,30 @@ export class CreateIssueUseCase {
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||
private readonly issueParticipants: IssueParticipantsService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
// Создавать задачи может владелец или участник проекта, но не наблюдатель.
|
||||
// Чужой проект неотличим от несуществующего — 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);
|
||||
}
|
||||
|
||||
// Теги создаются в справочнике только после проверки доступа к проекту
|
||||
const tags = command.tags?.length
|
||||
? await this.tagRepository.findOrCreateByNames(command.projectId, command.tags)
|
||||
: [];
|
||||
|
||||
const draft = Issue.create({
|
||||
key: command.key,
|
||||
title: command.title,
|
||||
@@ -33,16 +56,17 @@ export class CreateIssueUseCase {
|
||||
type: command.type,
|
||||
assignee: command.assignee,
|
||||
reporter: command.reporter,
|
||||
observers: command.observers,
|
||||
tags: tags.map(IssueApplicationMapper.toTagRef),
|
||||
projectId: command.projectId,
|
||||
dueDate: command.dueDate,
|
||||
});
|
||||
|
||||
await this.issueParticipants.ensureObserversExist([...draft.observers]);
|
||||
const issue = await this.issueRepository.create(draft);
|
||||
const dto = await this.issueUserExpander.expand(issue);
|
||||
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
||||
}
|
||||
await this.issueParticipants.addToProject(issue);
|
||||
|
||||
const domainEvents = draft.pullDomainEvents();
|
||||
this.issueAssignmentNotifier.notify(issue, domainEvents);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { DeleteIssueUseCase } from './delete-issue.use-case.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
@@ -17,6 +18,7 @@ const buildIssue = () =>
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
observers: ['user-5'],
|
||||
projectId: 'project-1',
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
@@ -73,4 +75,13 @@ describe('DeleteIssueUseCase', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('forbids an observer from deleting the issue', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await expect(useCase.execute(new DeleteIssueCommand(1, 'user-5'))).rejects.toBeInstanceOf(
|
||||
IssueEditForbiddenException,
|
||||
);
|
||||
expect(repository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
@@ -20,6 +21,10 @@ export class DeleteIssueUseCase {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (!issue.canBeEditedBy(command.userId)) {
|
||||
throw new IssueEditForbiddenException(command.id);
|
||||
}
|
||||
|
||||
await this.issueRepository.delete(command.id);
|
||||
|
||||
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { IssueUserRole } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
|
||||
export class ListIssuesQuery {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
@@ -9,5 +11,6 @@ export class ListIssuesQuery {
|
||||
public readonly assigneeId?: string,
|
||||
public readonly hasDueDate?: boolean,
|
||||
public readonly type?: string[],
|
||||
public readonly role?: IssueUserRole,
|
||||
) {}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export class ListIssuesUseCase {
|
||||
assigneeId: query.assigneeId,
|
||||
hasDueDate: query.hasDueDate,
|
||||
type: query.type,
|
||||
role: query.role,
|
||||
});
|
||||
|
||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export class UnobserveIssueCommand {
|
||||
constructor(
|
||||
public readonly id: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { UnobserveIssueUseCase } from './unobserve-issue.use-case.js';
|
||||
import { UnobserveIssueCommand } from './unobserve-issue.command.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Title',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
observers: ['user-5', 'user-6'],
|
||||
projectId: 'project-1',
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('UnobserveIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueEventsService: IssueEventsService;
|
||||
let useCase: UnobserveIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
findByDueDateRange: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
useCase = new UnobserveIssueUseCase(repository, issueEventsService);
|
||||
});
|
||||
|
||||
it('removes the current user from observers on behalf of themselves', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
const received: unknown[] = [];
|
||||
issueEventsService.onIssueUpdated().subscribe((issue) => received.push(issue));
|
||||
|
||||
await useCase.execute(new UnobserveIssueCommand(1, 'user-5'));
|
||||
|
||||
const [saved, actorId] = vi.mocked(repository.update).mock.calls[0]!;
|
||||
expect(saved.observers).toEqual(['user-6']);
|
||||
expect(actorId).toBe('user-5');
|
||||
expect(received).toEqual([expect.objectContaining({ observerIds: ['user-6'] })]);
|
||||
});
|
||||
|
||||
it('does nothing for a user who is not an observer', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await useCase.execute(new UnobserveIssueCommand(1, 'user-1'));
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the issue is not visible', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new UnobserveIssueCommand(1, 'user-9'))).rejects.toBeInstanceOf(
|
||||
IssueNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { UnobserveIssueCommand } from './unobserve-issue.command.js';
|
||||
|
||||
/**
|
||||
* Наблюдатель сам отписывается от задачи. Редактировать задачу он не может,
|
||||
* поэтому это отдельная операция, а не часть UpdateIssueUseCase.
|
||||
* Повторный вызов (или вызов не наблюдателем) ничего не меняет.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnobserveIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
) {}
|
||||
|
||||
async execute(command: UnobserveIssueCommand): Promise<void> {
|
||||
const issue = await this.issueRepository.findById(command.id, command.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (!issue.observers.includes(command.userId)) return;
|
||||
|
||||
issue.removeObserver(command.userId);
|
||||
await this.issueRepository.update(issue, command.userId);
|
||||
|
||||
this.issueEventsService.emitIssueUpdated(IssueApplicationMapper.toDto(issue));
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,9 @@ export class UpdateIssueCommand {
|
||||
public readonly assignee?: string | null,
|
||||
public readonly dueDate?: Date | null,
|
||||
public readonly type?: string,
|
||||
/** Полный новый список наблюдателей */
|
||||
public readonly observers?: string[],
|
||||
/** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются */
|
||||
public readonly tags?: string[],
|
||||
) {}
|
||||
}
|
||||
+138
-3
@@ -1,8 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import type { ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||
import { buildTag, mockTagRepository } from '../../../../tag/application/use-cases/test-helpers.js';
|
||||
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 +14,14 @@ 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 backend = { id: 'tag-1', name: 'backend', color: 'primary' };
|
||||
const ui = { id: 'tag-2', name: 'ui', color: 'default' };
|
||||
|
||||
const buildIssue = (overrides: { assignee?: string | null; observers?: string[]; tags?: Array<typeof backend> } = {}) =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
@@ -26,6 +36,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 +45,8 @@ describe('UpdateIssueUseCase', () => {
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let notificationsService: NotificationsService;
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let tagRepository: ITagRepository;
|
||||
let useCase: UpdateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -53,6 +66,7 @@ describe('UpdateIssueUseCase', () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findRole: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
@@ -64,12 +78,20 @@ 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;
|
||||
tagRepository = mockTagRepository([
|
||||
buildTag({ id: backend.id, name: backend.name, color: backend.color }),
|
||||
buildTag({ id: ui.id, name: ui.name, color: ui.color }),
|
||||
]);
|
||||
useCase = new UpdateIssueUseCase(
|
||||
repository,
|
||||
projectRepository,
|
||||
tagRepository,
|
||||
issueEventsService,
|
||||
issueUserExpander,
|
||||
new IssueAssignmentNotifierService(notificationsService),
|
||||
new IssueParticipantsService(projectRepository, getUsersUseCase),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,7 +107,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 +188,117 @@ describe('UpdateIssueUseCase', () => {
|
||||
|
||||
expect(dto.type).toBe('BUG');
|
||||
});
|
||||
|
||||
it('replaces the tags by name and saves the change to history', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ tags: [backend, ui] }));
|
||||
let changes: unknown[] = [];
|
||||
vi.mocked(repository.update).mockImplementation(async (issue) => {
|
||||
changes = issue.pullChanges();
|
||||
});
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
1,
|
||||
'user-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['UI', 'api'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(tagRepository.findOrCreateByNames).toHaveBeenCalledWith('project-1', ['UI', 'api']);
|
||||
expect(dto.tags.map((tag) => tag.name)).toEqual(['api', 'ui']);
|
||||
expect(changes).toEqual([
|
||||
{ field: 'tag', oldValue: 'backend', newValue: null },
|
||||
{ field: 'tag', oldValue: null, newValue: 'api' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the tags when they are not passed', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ tags: [backend] }));
|
||||
|
||||
const dto = await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
|
||||
|
||||
expect(dto.tags).toEqual([backend]);
|
||||
expect(tagRepository.findOrCreateByNames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('observers', () => {
|
||||
it('forbids an observer from editing the issue', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] }));
|
||||
|
||||
await expect(useCase.execute(new UpdateIssueCommand(1, 'user-5', 'Hijacked'))).rejects.toBeInstanceOf(
|
||||
IssueEditForbiddenException,
|
||||
);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces observers, adds them to the project and notifies only the new ones', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5', 'user-6'] }));
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
1,
|
||||
'user-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['user-6', 'user-8'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(dto.observerIds).toEqual(['user-6', 'user-8']);
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-8'] }));
|
||||
expect(projectRepository.addMember).toHaveBeenCalledWith('project-1', 'user-8', 'OBSERVER');
|
||||
expect(notificationsService.notify).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsService.notify).toHaveBeenCalledWith({
|
||||
userId: 'user-8',
|
||||
subject: 'You are now observing ISSUE-1',
|
||||
content: 'Original title',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown users before saving', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await expect(
|
||||
useCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
1,
|
||||
'user-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
['ghost'],
|
||||
),
|
||||
),
|
||||
).rejects.toBeInstanceOf(IssueObserverNotFoundException);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops the new assignee from observers', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] }));
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-5'),
|
||||
);
|
||||
|
||||
expect(dto.assigneeId).toBe('user-5');
|
||||
expect(dto.observerIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,28 @@
|
||||
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 { IssueApplicationMapper } from '../../mappers/issue-application.mapper.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';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||
private readonly issueParticipants: IssueParticipantsService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
@@ -31,6 +32,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 +70,24 @@ export class UpdateIssueUseCase {
|
||||
issue.updateDueDate(command.dueDate);
|
||||
}
|
||||
|
||||
// После assignee: новый исполнитель отбрасывается из наблюдателей
|
||||
if (command.observers !== undefined) {
|
||||
issue.setObservers(command.observers);
|
||||
}
|
||||
|
||||
if (command.tags !== undefined) {
|
||||
issue.setTags(
|
||||
(await this.tagRepository.findOrCreateByNames(issue.projectId, command.tags)).map(
|
||||
IssueApplicationMapper.toTagRef,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ISSUE_CHANGE_FIELDS = [
|
||||
'type',
|
||||
'assignee',
|
||||
'dueDate',
|
||||
'observer',
|
||||
'tag',
|
||||
] as const;
|
||||
|
||||
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
||||
@@ -18,6 +20,8 @@ export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
||||
/**
|
||||
* Изменение одного поля задачи. Значения сериализованы в строки:
|
||||
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||
* observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id)
|
||||
* или снят (oldValue = id, newValue = null). tag — так же, по строке на тег.
|
||||
*/
|
||||
export interface IssueFieldChange {
|
||||
field: IssueChangeField;
|
||||
|
||||
@@ -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,175 @@ 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tags', () => {
|
||||
const backend = { id: 't1', name: 'backend', color: 'primary' };
|
||||
const ui = { id: 't2', name: 'UI', color: 'default' };
|
||||
const api = { id: 't3', name: 'api', color: 'success' };
|
||||
|
||||
const load = (tags: Array<typeof backend> = []) =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Title',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: 'user-2',
|
||||
reporter: 'user-1',
|
||||
tags,
|
||||
projectId: 'project-1',
|
||||
dueDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
it('creates an issue with tags sorted by name without writing them to history', () => {
|
||||
const issue = Issue.create({
|
||||
key: 'ISSUE-1',
|
||||
title: 'Title',
|
||||
reporter: 'user-1',
|
||||
tags: [ui, backend, ui],
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
expect(issue.tags).toEqual([backend, ui]);
|
||||
expect(issue.pullChanges().map((change) => change.field)).toEqual(['created', 'status']);
|
||||
});
|
||||
|
||||
it('has no tags by default', () => {
|
||||
expect(load().tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('records one history row per added or removed tag, with the tag name', () => {
|
||||
const issue = load([backend, ui]);
|
||||
|
||||
issue.setTags([ui, api]);
|
||||
|
||||
expect(issue.tags).toEqual([api, ui]);
|
||||
expect(issue.pullChanges()).toEqual([
|
||||
{ field: 'tag', oldValue: 'backend', newValue: null },
|
||||
{ field: 'tag', oldValue: null, newValue: 'api' },
|
||||
]);
|
||||
expect(issue.pullDomainEvents()).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not record a change when the same tags are set', () => {
|
||||
const issue = load([backend, ui]);
|
||||
const updatedAt = issue.updatedAt;
|
||||
|
||||
issue.setTags([ui, backend]);
|
||||
|
||||
expect(issue.pullChanges()).toEqual([]);
|
||||
expect(issue.pullDomainEvents()).toEqual([]);
|
||||
expect(issue.updatedAt).toBe(updatedAt);
|
||||
});
|
||||
|
||||
it('cancels out adding and removing the same tag', () => {
|
||||
const issue = load([backend]);
|
||||
|
||||
issue.setTags([backend, ui]);
|
||||
issue.setTags([backend]);
|
||||
|
||||
expect(issue.pullChanges()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes all tags', () => {
|
||||
const issue = load([backend]);
|
||||
|
||||
issue.setTags([]);
|
||||
|
||||
expect(issue.tags).toEqual([]);
|
||||
expect(issue.pullChanges()).toEqual([{ field: 'tag', oldValue: 'backend', newValue: null }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,11 @@ import { IssueTitle } from '../value-objects/issue-title.vo.js';
|
||||
import { IssueStatus } from '../value-objects/issue-status.vo.js';
|
||||
import { IssuePriority } from '../value-objects/issue-priority.vo.js';
|
||||
import { IssueType } from '../value-objects/issue-type.vo.js';
|
||||
import { IssueTags, type IssueTagRef } from '../value-objects/issue-tags.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 +20,9 @@ interface IssueProps {
|
||||
assignee: string | null;
|
||||
/** null — автор задачи удалил аккаунт */
|
||||
reporter: string | null;
|
||||
/** Видят задачу и комментируют, но не редактируют. Не пересекаются с reporter/assignee. */
|
||||
observers: string[];
|
||||
tags: IssueTags;
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -37,8 +42,11 @@ interface IssueProps {
|
||||
*/
|
||||
export class Issue {
|
||||
private domainEvents: unknown[] = [];
|
||||
/** Несохранённые изменения полей, по одному на поле, в порядке изменения */
|
||||
private changes = new Map<IssueChangeField, IssueFieldChange>();
|
||||
/**
|
||||
* Несохранённые изменения полей, по одному на поле, в порядке изменения.
|
||||
* Ключ — поле, для observer — поле и id наблюдателя, для tag — поле и id тега.
|
||||
*/
|
||||
private changes = new Map<string, IssueFieldChange>();
|
||||
|
||||
private constructor(private readonly props: IssueProps) {}
|
||||
|
||||
@@ -51,6 +59,8 @@ export class Issue {
|
||||
type?: string;
|
||||
assignee?: string | null;
|
||||
reporter: string;
|
||||
observers?: string[];
|
||||
tags?: IssueTagRef[];
|
||||
projectId: string;
|
||||
dueDate?: Date | null;
|
||||
}): Issue {
|
||||
@@ -64,6 +74,9 @@ export class Issue {
|
||||
type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(),
|
||||
assignee: params.assignee ?? null,
|
||||
reporter: params.reporter,
|
||||
observers: [],
|
||||
// Как и наблюдатели, теги при создании в историю не пишутся
|
||||
tags: IssueTags.create(params.tags ?? []),
|
||||
projectId: params.projectId,
|
||||
dueDate: params.dueDate ?? null,
|
||||
createdAt: new Date(),
|
||||
@@ -76,6 +89,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 +109,8 @@ export class Issue {
|
||||
type: string;
|
||||
assignee: string | null;
|
||||
reporter: string | null;
|
||||
observers?: string[];
|
||||
tags?: IssueTagRef[];
|
||||
projectId: string;
|
||||
dueDate: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -106,6 +126,8 @@ export class Issue {
|
||||
type: IssueType.create(props.type),
|
||||
assignee: props.assignee,
|
||||
reporter: props.reporter,
|
||||
observers: [...(props.observers ?? [])],
|
||||
tags: IssueTags.create(props.tags ?? []),
|
||||
projectId: props.projectId,
|
||||
dueDate: props.dueDate,
|
||||
createdAt: props.createdAt,
|
||||
@@ -149,6 +171,19 @@ export class Issue {
|
||||
return this.props.reporter;
|
||||
}
|
||||
|
||||
get observers(): readonly string[] {
|
||||
return this.props.observers;
|
||||
}
|
||||
|
||||
get tags(): readonly IssueTagRef[] {
|
||||
return this.props.tags.value;
|
||||
}
|
||||
|
||||
/** Редактировать задачу могут только reporter и assignee, наблюдатели — нет */
|
||||
canBeEditedBy(userId: string): boolean {
|
||||
return this.reporter === userId || this.assignee === userId;
|
||||
}
|
||||
|
||||
get projectId(): string {
|
||||
return this.props.projectId;
|
||||
}
|
||||
@@ -200,6 +235,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 +249,50 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет теги задачи — теги справочника её проекта (их находит/создаёт use-case).
|
||||
* В историю — по строке на добавленный или снятый тег, со значением-именем тега.
|
||||
*/
|
||||
setTags(tags: IssueTagRef[]): void {
|
||||
const next = IssueTags.create(tags);
|
||||
for (const tag of this.tags.filter((tag) => !next.has(tag.id))) {
|
||||
this.recordChange('tag', tag.name, null, `tag:${tag.id}`);
|
||||
}
|
||||
for (const tag of next.value.filter((tag) => !this.props.tags.has(tag.id))) {
|
||||
this.recordChange('tag', null, tag.name, `tag:${tag.id}`);
|
||||
}
|
||||
this.props.tags = next;
|
||||
}
|
||||
|
||||
/** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */
|
||||
pullChanges(): IssueFieldChange[] {
|
||||
const changes = [...this.changes.values()];
|
||||
@@ -224,14 +305,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 +325,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));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class IssueObserverAddedEvent {
|
||||
constructor(
|
||||
public readonly issueKey: string,
|
||||
public readonly observer: string,
|
||||
public readonly occurredAt: Date = new Date(),
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Задача видна пользователю (он наблюдатель), но редактировать её он не может */
|
||||
export class IssueEditForbiddenException extends Error {
|
||||
constructor(id: number) {
|
||||
super(`Задача с id=${id} доступна наблюдателю только для чтения`);
|
||||
this.name = 'IssueEditForbiddenException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class IssueObserverNotFoundException extends Error {
|
||||
constructor(userIds: string[]) {
|
||||
super(`Пользователи не найдены: ${userIds.join(', ')}`);
|
||||
this.name = 'IssueObserverNotFoundException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Проект виден пользователю (он наблюдатель), но создавать в нём задачи он не может */
|
||||
export class IssueProjectForbiddenException extends Error {
|
||||
constructor(projectId: string) {
|
||||
super(`В проекте с id=${projectId} наблюдатель не может создавать задачи`);
|
||||
this.name = 'IssueProjectForbiddenException';
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,15 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in
|
||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||
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<Issue | null>;
|
||||
findByCKey(ckey: string, userId: string): Promise<Issue | null>;
|
||||
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<Array<{ status: string; count: number }>>;
|
||||
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
||||
create(issue: Issue): Promise<Issue>;
|
||||
/** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */
|
||||
/** Сохраняет задачу, её наблюдателей и накопленные изменения полей в историю одной транзакцией */
|
||||
update(issue: Issue, actorId: string): Promise<void>;
|
||||
delete(id: number): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IssueTags, ISSUE_TAGS_MAX_COUNT } from './issue-tags.vo.js';
|
||||
|
||||
const tag = (id: string, name = id) => ({ id, name, color: 'default' });
|
||||
|
||||
describe('IssueTags', () => {
|
||||
it('sorts tags by name and drops duplicates by id', () => {
|
||||
const tags = IssueTags.create([tag('t2', 'ui'), tag('t1', 'backend'), tag('t2', 'ui')]);
|
||||
|
||||
expect(tags.value.map((t) => t.name)).toEqual(['backend', 'ui']);
|
||||
});
|
||||
|
||||
it('throws for too many tags', () => {
|
||||
const tags = Array.from({ length: ISSUE_TAGS_MAX_COUNT + 1 }, (_, i) => tag(`t${i}`));
|
||||
|
||||
expect(() => IssueTags.create(tags)).toThrow(/не может быть больше/);
|
||||
});
|
||||
|
||||
it('checks whether a tag is assigned', () => {
|
||||
const tags = IssueTags.create([tag('t1')]);
|
||||
|
||||
expect(tags.has('t1')).toBe(true);
|
||||
expect(tags.has('t2')).toBe(false);
|
||||
});
|
||||
|
||||
it('is empty by default', () => {
|
||||
expect(IssueTags.empty().value).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export const ISSUE_TAGS_MAX_COUNT = 20;
|
||||
|
||||
/** Тег из справочника проекта, назначенный задаче (снимок для отображения) */
|
||||
export interface IssueTagRef {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Теги задачи: без повторов (по id), по алфавиту, не больше ISSUE_TAGS_MAX_COUNT */
|
||||
export class IssueTags {
|
||||
private constructor(private readonly _value: readonly IssueTagRef[]) {}
|
||||
|
||||
static create(tags: IssueTagRef[]): IssueTags {
|
||||
const unique = [...new Map(tags.map((tag) => [tag.id, { ...tag }])).values()];
|
||||
|
||||
if (unique.length > ISSUE_TAGS_MAX_COUNT) {
|
||||
throw new Error(`У задачи не может быть больше ${ISSUE_TAGS_MAX_COUNT} тегов`);
|
||||
}
|
||||
|
||||
return new IssueTags(unique.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}
|
||||
|
||||
static empty(): IssueTags {
|
||||
return new IssueTags([]);
|
||||
}
|
||||
|
||||
get value(): readonly IssueTagRef[] {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
has(tagId: string): boolean {
|
||||
return this._value.some((tag) => tag.id === tagId);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ describe('IssuePrismaMapper', () => {
|
||||
status: 'IN_PROGRESS',
|
||||
priority: 2,
|
||||
type: 'BUG',
|
||||
tags: [{ tag: { id: 't1', name: 'backend', color: 'primary' } }],
|
||||
assigneeId: 'user-2',
|
||||
reporterId: 'user-1',
|
||||
projectId: 'project-1',
|
||||
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 +31,8 @@ describe('IssuePrismaMapper', () => {
|
||||
expect(issue.priority).toBe(2);
|
||||
expect(issue.type).toBe('BUG');
|
||||
expect(issue.assignee).toBe('user-2');
|
||||
expect(issue.observers).toEqual(['user-3']);
|
||||
expect(issue.tags).toEqual([{ id: 't1', name: 'backend', color: 'primary' }]);
|
||||
});
|
||||
|
||||
it('maps a new domain entity to Prisma create input', () => {
|
||||
@@ -36,6 +40,7 @@ describe('IssuePrismaMapper', () => {
|
||||
key: 'ISSUE-6',
|
||||
title: 'New issue',
|
||||
reporter: 'user-1',
|
||||
tags: [{ id: 't2', name: 'ui', color: 'default' }],
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
@@ -50,6 +55,7 @@ describe('IssuePrismaMapper', () => {
|
||||
type: 'TASK',
|
||||
assigneeId: null,
|
||||
reporterId: 'user-1',
|
||||
tags: { create: [{ tagId: 't2' }] },
|
||||
projectId: 'project-1',
|
||||
});
|
||||
});
|
||||
@@ -70,5 +76,7 @@ describe('IssuePrismaMapper', () => {
|
||||
expect(input).not.toHaveProperty('id');
|
||||
expect(input).not.toHaveProperty('key');
|
||||
expect(input).not.toHaveProperty('createdAt');
|
||||
// Теги сохраняются отдельно — в issue_tag, вместе с историей
|
||||
expect(input).not.toHaveProperty('tags');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,14 @@ 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_RELATIONS в репозитории) */
|
||||
export type PrismaIssueWithRelations = PrismaIssue & {
|
||||
observers: Array<{ userId: string }>;
|
||||
tags: Array<{ tag: { id: string; name: string; color: string } }>;
|
||||
};
|
||||
|
||||
export class IssuePrismaMapper {
|
||||
static toDomain(record: PrismaIssue): Issue {
|
||||
static toDomain(record: PrismaIssueWithRelations): Issue {
|
||||
return Issue.reconstitute({
|
||||
id: record.id,
|
||||
key: getCKey(record.id, record.key),
|
||||
@@ -14,6 +20,8 @@ export class IssuePrismaMapper {
|
||||
type: record.type,
|
||||
assignee: record.assigneeId,
|
||||
reporter: record.reporterId,
|
||||
observers: record.observers.map((observer) => observer.userId),
|
||||
tags: record.tags.map(({ tag }) => tag),
|
||||
projectId: record.projectId,
|
||||
dueDate: record.dueDate,
|
||||
createdAt: record.createdAt,
|
||||
@@ -31,6 +39,8 @@ export class IssuePrismaMapper {
|
||||
type: issue.type,
|
||||
assigneeId: issue.assignee,
|
||||
reporterId: issue.reporter,
|
||||
observers: { create: issue.observers.map((userId) => ({ userId })) },
|
||||
tags: { create: issue.tags.map((tag) => ({ tagId: tag.id })) },
|
||||
projectId: issue.projectId,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
|
||||
+57
-8
@@ -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,29 @@ 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_RELATIONS = {
|
||||
observers: { select: { userId: true } },
|
||||
tags: { select: { tag: { select: { id: true, name: true, color: 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 +44,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 +72,8 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
|
||||
async findById(id: number, userId: string): Promise<Issue | null> {
|
||||
const record = await this.prisma.issue.findFirst({
|
||||
where: { id, OR: ownedByUser(userId) },
|
||||
where: { id, OR: visibleTo(userId) },
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
return record ? IssuePrismaMapper.toDomain(record) : null;
|
||||
}
|
||||
@@ -58,7 +81,8 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
async findByCKey(ckey: string, userId: string): Promise<Issue | null> {
|
||||
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_RELATIONS,
|
||||
});
|
||||
|
||||
return record ? IssuePrismaMapper.toDomain(record) : null;
|
||||
@@ -74,6 +98,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 +126,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
const [records, totalCount] = await this.prisma.$transaction([
|
||||
this.prisma.issue.findMany({
|
||||
...queryArgs,
|
||||
include: WITH_RELATIONS,
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -127,6 +153,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
where: {
|
||||
AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }],
|
||||
},
|
||||
include: WITH_RELATIONS,
|
||||
take: Math.max(1, params.limit),
|
||||
orderBy: [{ dueDate: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -137,7 +164,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
|
||||
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 +178,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_RELATIONS,
|
||||
});
|
||||
await tx.issueChange.createMany({
|
||||
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
|
||||
@@ -181,8 +209,29 @@ export class IssuePrismaRepository implements IIssueRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
const observers = [...issue.observers];
|
||||
const tagIds = issue.tags.map((tag) => tag.id);
|
||||
await this.prisma.$transaction([
|
||||
updateIssue,
|
||||
// Наблюдатели и теги меняются только вместе с записью observer/tag в истории
|
||||
...(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,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(changes.some((change) => change.field === 'tag')
|
||||
? [
|
||||
this.prisma.issueTag.deleteMany({ where: { issueId, tagId: { notIn: tagIds } } }),
|
||||
this.prisma.issueTag.createMany({
|
||||
data: tagIds.map((tagId) => ({ issueId, tagId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
this.prisma.issueChange.createMany({
|
||||
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||
import { ProjectModule } from '../project/project.module.js';
|
||||
import { UserModule } from '../user/user.module.js';
|
||||
import { NotificationModule } from '../notification/notification.module.js';
|
||||
import { TagModule } from '../tag/tag.module.js';
|
||||
import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
|
||||
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
|
||||
import { IssueController } from './presentation/controllers/issue.controller.js';
|
||||
@@ -18,9 +19,11 @@ 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],
|
||||
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule, TagModule],
|
||||
controllers: [IssueController],
|
||||
providers: [
|
||||
CreateIssueUseCase,
|
||||
@@ -31,10 +34,12 @@ import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar
|
||||
DeleteIssueUseCase,
|
||||
GetIssueStatsUseCase,
|
||||
ListCalendarIssuesUseCase,
|
||||
UnobserveIssueUseCase,
|
||||
IssueResolver,
|
||||
IssueEventsService,
|
||||
IssueUserExpanderService,
|
||||
IssueAssignmentNotifierService,
|
||||
IssueParticipantsService,
|
||||
{
|
||||
// Domain видит только интерфейс IIssueRepository,
|
||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||
|
||||
@@ -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,8 @@ export class IssueController {
|
||||
dto.assignee,
|
||||
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
|
||||
dto.type,
|
||||
dto.observers,
|
||||
dto.tags,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -89,6 +98,7 @@ export class IssueController {
|
||||
query.assigneeId,
|
||||
query.hasDueDate,
|
||||
query.type,
|
||||
query.role,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -118,13 +128,15 @@ export class IssueController {
|
||||
/**
|
||||
* SSE-поток для зарегистрированных пользователей (доступ уже проверен
|
||||
* глобальным AuthGuard): уведомляет о каждой созданной, обновлённой
|
||||
* и удалённой задаче, видимой текущему пользователю (reporter/assignee —
|
||||
* то же правило видимости, что и в IssuePrismaRepository.ownedByUser).
|
||||
* и удалённой задаче, видимой текущему пользователю (reporter/assignee/наблюдатель —
|
||||
* то же правило видимости, что и в IssuePrismaRepository.visibleTo).
|
||||
*/
|
||||
@Sse('events')
|
||||
issueCreatedEvents(@Session() session: UserSession): Observable<MessageEvent> {
|
||||
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 +182,8 @@ export class IssueController {
|
||||
dto.assignee,
|
||||
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
|
||||
dto.type,
|
||||
dto.observers,
|
||||
dto.tags,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -181,6 +195,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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
@@ -189,9 +210,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
|
||||
|
||||
export class CreateIssueRequestDto {
|
||||
@IsString()
|
||||
@@ -36,4 +51,20 @@ export class CreateIssueRequestDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string;
|
||||
|
||||
/** id пользователей-наблюдателей: видят задачу и комментируют, но не редактируют */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
observers?: string[];
|
||||
|
||||
/** Имена тегов; отсутствующие в справочнике проекта создаются, регистр не различается */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
|
||||
@IsString({ each: true })
|
||||
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
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';
|
||||
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
|
||||
|
||||
export class UpdateIssueRequestDto {
|
||||
@IsOptional()
|
||||
@@ -33,4 +47,20 @@ export class UpdateIssueRequestDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string | null;
|
||||
|
||||
/** id пользователей-наблюдателей (полный новый список): видят задачу и комментируют, но не редактируют */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
observers?: string[];
|
||||
|
||||
/** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются, регистр не различается */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
|
||||
@IsString({ each: true })
|
||||
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
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,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
|
||||
|
||||
@InputType()
|
||||
export class CreateIssueInput {
|
||||
@@ -46,4 +61,20 @@ export class CreateIssueInput {
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
dueDate?: Date;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
observers?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
|
||||
@IsString({ each: true })
|
||||
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { IssueTagType } from './issue-tag.type.js';
|
||||
import { UserType } from '../../../user/presentation/graphql/user.type.js';
|
||||
|
||||
@ObjectType('IssueDetail')
|
||||
@@ -30,12 +31,21 @@ export class IssueDetailType {
|
||||
@Field(() => String, { nullable: true })
|
||||
reporterId: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
observerIds: string[];
|
||||
|
||||
@Field(() => [IssueTagType])
|
||||
tags: IssueTagType[];
|
||||
|
||||
@Field(() => UserType, { nullable: true })
|
||||
assignee: UserType | null;
|
||||
|
||||
@Field(() => UserType, { nullable: true })
|
||||
reporter: UserType | null;
|
||||
|
||||
@Field(() => [UserType])
|
||||
observers: UserType[];
|
||||
|
||||
@Field()
|
||||
projectId: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('IssueTag')
|
||||
export class IssueTagType {
|
||||
@Field()
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field()
|
||||
color: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { IssueTagType } from './issue-tag.type.js';
|
||||
|
||||
@ObjectType('Issue')
|
||||
export class IssueType {
|
||||
@@ -29,6 +30,12 @@ export class IssueType {
|
||||
@Field(() => String, { nullable: true })
|
||||
reporterId: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
observerIds: string[];
|
||||
|
||||
@Field(() => [IssueTagType])
|
||||
tags: IssueTagType[];
|
||||
|
||||
@Field()
|
||||
projectId: string;
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
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,
|
||||
Matches,
|
||||
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';
|
||||
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
|
||||
|
||||
@InputType()
|
||||
export class UpdateIssueInput {
|
||||
@@ -42,4 +56,20 @@ export class UpdateIssueInput {
|
||||
@IsOptional()
|
||||
@IsDate()
|
||||
dueDate?: Date | null;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
observers?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
|
||||
@IsString({ each: true })
|
||||
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { 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,8 @@ export class IssueResolver {
|
||||
input.assignee,
|
||||
input.dueDate,
|
||||
input.type,
|
||||
input.observers,
|
||||
input.tags,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -128,6 +143,8 @@ export class IssueResolver {
|
||||
input.assignee,
|
||||
input.dueDate,
|
||||
input.type,
|
||||
input.observers ?? undefined,
|
||||
input.tags ?? undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -139,6 +156,13 @@ export class IssueResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Наблюдатель отписывается от задачи сам */
|
||||
@Mutation(() => Boolean, { name: 'unobserveIssue' })
|
||||
async unobserve(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise<boolean> {
|
||||
await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */
|
||||
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
@@ -147,9 +171,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Project | null>;
|
||||
@@ -11,6 +17,11 @@ export interface IProjectRepository {
|
||||
create(project: Project): Promise<Project>;
|
||||
update(project: Project): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
/** Добавляет пользователя в участники проекта, если он ещё не владелец/участник */
|
||||
addMember(projectId: string, userId: string): Promise<void>;
|
||||
/** Роль пользователя в проекте; null — проект ему не виден (или не существует) */
|
||||
findRole(projectId: string, userId: string): Promise<ProjectRole | null>;
|
||||
/**
|
||||
* Добавляет пользователя в участники проекта. Роль только повышается:
|
||||
* OBSERVER становится MEMBER, но MEMBER не понижается до OBSERVER.
|
||||
*/
|
||||
addMember(projectId: string, userId: string, role: Exclude<ProjectRole, 'OWNER'>): Promise<void>;
|
||||
}
|
||||
+14
-4
@@ -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<void> {
|
||||
async findRole(projectId: string, userId: string): Promise<ProjectRole | null> {
|
||||
const record = await this.prisma.project.findFirst({
|
||||
where: { id: projectId, ...this.accessibleBy(userId) },
|
||||
select: { ownerId: true, members: { where: { userId }, select: { role: true } } },
|
||||
});
|
||||
if (!record) return null;
|
||||
if (record.ownerId === userId) return 'OWNER';
|
||||
return record.members[0]?.role ?? null;
|
||||
}
|
||||
|
||||
async addMember(projectId: string, userId: string, role: Exclude<ProjectRole, 'OWNER'>): Promise<void> {
|
||||
await this.prisma.projectMember.upsert({
|
||||
where: { projectId_userId: { projectId, userId } },
|
||||
create: { projectId, userId },
|
||||
update: {},
|
||||
create: { projectId, userId, role },
|
||||
update: role === 'MEMBER' ? { role } : {},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export class TagDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Tag } from '../../domain/entities/tag.entity.js';
|
||||
import { TagDto } from '../dto/tag.dto.js';
|
||||
|
||||
export class TagApplicationMapper {
|
||||
static toDto(tag: Tag): TagDto {
|
||||
return {
|
||||
id: tag.id as string,
|
||||
projectId: tag.projectId,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
createdAt: tag.createdAt,
|
||||
updatedAt: tag.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
|
||||
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
|
||||
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../domain/repositories/tag.repository.interface.js';
|
||||
import { Tag } from '../../domain/entities/tag.entity.js';
|
||||
|
||||
/**
|
||||
* Теги проекта видят все, кому виден проект. Менять справочник могут
|
||||
* владелец и участники — те же, кто создаёт задачи; наблюдатель проекта — нет.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TagAccessService {
|
||||
constructor(
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
) {}
|
||||
|
||||
/** Чужой проект неотличим от несуществующего */
|
||||
async assertCanView(projectId: string, userId: string): Promise<void> {
|
||||
const role = await this.projectRepository.findRole(projectId, userId);
|
||||
if (!role) {
|
||||
throw new TagProjectNotFoundException(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
async assertCanManage(projectId: string, userId: string): Promise<void> {
|
||||
const role = await this.projectRepository.findRole(projectId, userId);
|
||||
if (!role) {
|
||||
throw new TagProjectNotFoundException(projectId);
|
||||
}
|
||||
if (role === 'OBSERVER') {
|
||||
throw new TagEditForbiddenException(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Тег, который пользователь может менять; тег чужого проекта неотличим от несуществующего */
|
||||
async findManageable(tagId: string, userId: string): Promise<Tag> {
|
||||
const tag = await this.tagRepository.findById(tagId);
|
||||
const role = tag ? await this.projectRepository.findRole(tag.projectId, userId) : null;
|
||||
if (!tag || !role) {
|
||||
throw new TagNotFoundException(tagId);
|
||||
}
|
||||
if (role === 'OBSERVER') {
|
||||
throw new TagEditForbiddenException(tag.projectId);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class CreateTagCommand {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly userId: string,
|
||||
public readonly name: string,
|
||||
public readonly color?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Tag } from '../../../domain/entities/tag.entity.js';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
|
||||
import { TagAccessService } from '../../services/tag-access.service.js';
|
||||
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
|
||||
import { TagDto } from '../../dto/tag.dto.js';
|
||||
import { CreateTagCommand } from './create-tag.command.js';
|
||||
|
||||
/** Явное создание тега — например, сразу с цветом. Задаче теги назначаются по имени и создаются сами. */
|
||||
@Injectable()
|
||||
export class CreateTagUseCase {
|
||||
constructor(
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly tagAccess: TagAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateTagCommand): Promise<TagDto> {
|
||||
const draft = Tag.create({ projectId: command.projectId, name: command.name, color: command.color });
|
||||
|
||||
await this.tagAccess.assertCanManage(command.projectId, command.userId);
|
||||
const tag = await this.tagRepository.create(draft);
|
||||
|
||||
return TagApplicationMapper.toDto(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteTagCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
|
||||
import { TagAccessService } from '../../services/tag-access.service.js';
|
||||
import { DeleteTagCommand } from './delete-tag.command.js';
|
||||
|
||||
/** Тег снимается со всех задач проекта; в историю задач это не пишется */
|
||||
@Injectable()
|
||||
export class DeleteTagUseCase {
|
||||
constructor(
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly tagAccess: TagAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteTagCommand): Promise<void> {
|
||||
await this.tagAccess.findManageable(command.id, command.userId);
|
||||
await this.tagRepository.delete(command.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class ListProjectTagsQuery {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly userId: string,
|
||||
/** Подстрока имени — для автоподстановки */
|
||||
public readonly query?: string,
|
||||
public readonly limit: number = 100,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
|
||||
import { TagAccessService } from '../../services/tag-access.service.js';
|
||||
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
|
||||
import { TagDto } from '../../dto/tag.dto.js';
|
||||
import { ListProjectTagsQuery } from './list-project-tags.query.js';
|
||||
|
||||
@Injectable()
|
||||
export class ListProjectTagsUseCase {
|
||||
constructor(
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly tagAccess: TagAccessService,
|
||||
) {}
|
||||
|
||||
async execute(query: ListProjectTagsQuery): Promise<TagDto[]> {
|
||||
await this.tagAccess.assertCanView(query.projectId, query.userId);
|
||||
|
||||
const tags = await this.tagRepository.findByProject({
|
||||
projectId: query.projectId,
|
||||
query: query.query,
|
||||
limit: query.limit,
|
||||
});
|
||||
|
||||
return tags.map(TagApplicationMapper.toDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TagAccessService } from '../services/tag-access.service.js';
|
||||
import { ListProjectTagsUseCase } from './list-project-tags/list-project-tags.use-case.js';
|
||||
import { CreateTagUseCase } from './create-tag/create-tag.use-case.js';
|
||||
import { UpdateTagUseCase } from './update-tag/update-tag.use-case.js';
|
||||
import { DeleteTagUseCase } from './delete-tag/delete-tag.use-case.js';
|
||||
import { ListProjectTagsQuery } from './list-project-tags/list-project-tags.query.js';
|
||||
import { CreateTagCommand } from './create-tag/create-tag.command.js';
|
||||
import { UpdateTagCommand } from './update-tag/update-tag.command.js';
|
||||
import { DeleteTagCommand } from './delete-tag/delete-tag.command.js';
|
||||
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
|
||||
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
|
||||
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
|
||||
import type { ProjectRole } from '../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { buildTag, mockProjectRepository, mockTagRepository } from './test-helpers.js';
|
||||
|
||||
const setup = (role: ProjectRole | null) => {
|
||||
const tagRepository = mockTagRepository([buildTag({ id: 'tag-1', name: 'backend' })]);
|
||||
const access = new TagAccessService(mockProjectRepository(role), tagRepository);
|
||||
return {
|
||||
tagRepository,
|
||||
list: new ListProjectTagsUseCase(tagRepository, access),
|
||||
create: new CreateTagUseCase(tagRepository, access),
|
||||
update: new UpdateTagUseCase(tagRepository, access),
|
||||
remove: new DeleteTagUseCase(tagRepository, access),
|
||||
};
|
||||
};
|
||||
|
||||
describe('tag use cases', () => {
|
||||
describe('ListProjectTagsUseCase', () => {
|
||||
it('lets a project observer see the tags', async () => {
|
||||
const { list, tagRepository } = setup('OBSERVER');
|
||||
|
||||
const tags = await list.execute(new ListProjectTagsQuery('project-1', 'user-1', 'back', 20));
|
||||
|
||||
expect(tags.map((tag) => tag.name)).toEqual(['backend']);
|
||||
expect(tagRepository.findByProject).toHaveBeenCalledWith({ projectId: 'project-1', query: 'back', limit: 20 });
|
||||
});
|
||||
|
||||
it('hides the tags of a project the user cannot see', async () => {
|
||||
const { list } = setup(null);
|
||||
|
||||
await expect(list.execute(new ListProjectTagsQuery('project-1', 'user-1'))).rejects.toBeInstanceOf(
|
||||
TagProjectNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateTagUseCase', () => {
|
||||
it('creates a tag with a color', async () => {
|
||||
const { create, tagRepository } = setup('MEMBER');
|
||||
|
||||
const tag = await create.execute(new CreateTagCommand('project-1', 'user-1', 'ui', 'warning'));
|
||||
|
||||
expect(tag).toMatchObject({ projectId: 'project-1', name: 'ui', color: 'warning' });
|
||||
expect(tagRepository.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forbids a project observer from creating tags', async () => {
|
||||
const { create, tagRepository } = setup('OBSERVER');
|
||||
|
||||
await expect(create.execute(new CreateTagCommand('project-1', 'user-1', 'ui'))).rejects.toBeInstanceOf(
|
||||
TagEditForbiddenException,
|
||||
);
|
||||
expect(tagRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateTagUseCase', () => {
|
||||
it('renames and recolors a tag', async () => {
|
||||
const { update, tagRepository } = setup('OWNER');
|
||||
|
||||
const tag = await update.execute(new UpdateTagCommand('tag-1', 'user-1', 'Backend API', 'info'));
|
||||
|
||||
expect(tag).toMatchObject({ id: 'tag-1', name: 'Backend API', color: 'info' });
|
||||
expect(tagRepository.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a tag of a foreign project as missing', async () => {
|
||||
const { update } = setup(null);
|
||||
|
||||
await expect(update.execute(new UpdateTagCommand('tag-1', 'user-1', 'x'))).rejects.toBeInstanceOf(
|
||||
TagNotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for an unknown tag', async () => {
|
||||
const { update } = setup('OWNER');
|
||||
|
||||
await expect(update.execute(new UpdateTagCommand('nope', 'user-1', 'x'))).rejects.toBeInstanceOf(
|
||||
TagNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteTagUseCase', () => {
|
||||
it('deletes a tag', async () => {
|
||||
const { remove, tagRepository } = setup('MEMBER');
|
||||
|
||||
await remove.execute(new DeleteTagCommand('tag-1', 'user-1'));
|
||||
|
||||
expect(tagRepository.delete).toHaveBeenCalledWith('tag-1');
|
||||
});
|
||||
|
||||
it('forbids a project observer from deleting tags', async () => {
|
||||
const { remove, tagRepository } = setup('OBSERVER');
|
||||
|
||||
await expect(remove.execute(new DeleteTagCommand('tag-1', 'user-1'))).rejects.toBeInstanceOf(
|
||||
TagEditForbiddenException,
|
||||
);
|
||||
expect(tagRepository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { vi } from 'vitest';
|
||||
import type { ITagRepository } from '../../domain/repositories/tag.repository.interface.js';
|
||||
import type {
|
||||
IProjectRepository,
|
||||
ProjectRole,
|
||||
} from '../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { Tag } from '../../domain/entities/tag.entity.js';
|
||||
|
||||
export const buildTag = (overrides: Partial<{ id: string; projectId: string; name: string; color: string }> = {}) =>
|
||||
Tag.reconstitute({
|
||||
id: overrides.id ?? 'tag-1',
|
||||
projectId: overrides.projectId ?? 'project-1',
|
||||
name: overrides.name ?? 'backend',
|
||||
color: overrides.color ?? 'default',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Репозиторий тегов в памяти: findOrCreateByNames создаёт недостающие теги
|
||||
* с id `tag-<имя в нижнем регистре>` и запоминает их.
|
||||
*/
|
||||
export const mockTagRepository = (initial: Tag[] = []) => {
|
||||
const tags = [...initial];
|
||||
const repository: ITagRepository = {
|
||||
findById: vi.fn(async (id: string) => tags.find((tag) => tag.id === id) ?? null),
|
||||
findByProject: vi.fn(async ({ projectId }: { projectId: string }) =>
|
||||
tags.filter((tag) => tag.projectId === projectId),
|
||||
),
|
||||
findOrCreateByNames: vi.fn(async (projectId: string, names: string[]) => {
|
||||
const result = new Map<string, Tag>();
|
||||
for (const name of names) {
|
||||
const draft = Tag.create({ projectId, name });
|
||||
let tag = tags.find((t) => t.projectId === projectId && t.normalizedName === draft.normalizedName);
|
||||
if (!tag) {
|
||||
tag = buildTag({ id: `tag-${draft.normalizedName}`, projectId, name: draft.name });
|
||||
tags.push(tag);
|
||||
}
|
||||
result.set(tag.id as string, tag);
|
||||
}
|
||||
return [...result.values()];
|
||||
}),
|
||||
create: vi.fn(async (tag: Tag) =>
|
||||
buildTag({ id: 'tag-new', projectId: tag.projectId, name: tag.name, color: tag.color }),
|
||||
),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
return repository;
|
||||
};
|
||||
|
||||
export const mockProjectRepository = (role: ProjectRole | null = 'MEMBER'): IProjectRepository => ({
|
||||
findById: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findRole: vi.fn(async () => role),
|
||||
addMember: vi.fn(),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export class UpdateTagCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
public readonly name?: string,
|
||||
public readonly color?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
|
||||
import { TagAccessService } from '../../services/tag-access.service.js';
|
||||
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
|
||||
import { TagDto } from '../../dto/tag.dto.js';
|
||||
import { UpdateTagCommand } from './update-tag.command.js';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateTagUseCase {
|
||||
constructor(
|
||||
@Inject(TAG_REPOSITORY)
|
||||
private readonly tagRepository: ITagRepository,
|
||||
private readonly tagAccess: TagAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateTagCommand): Promise<TagDto> {
|
||||
const tag = await this.tagAccess.findManageable(command.id, command.userId);
|
||||
|
||||
if (command.name !== undefined) {
|
||||
tag.rename(command.name);
|
||||
}
|
||||
|
||||
if (command.color !== undefined) {
|
||||
tag.changeColor(command.color);
|
||||
}
|
||||
|
||||
await this.tagRepository.update(tag);
|
||||
|
||||
return TagApplicationMapper.toDto(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Tag } from './tag.entity.js';
|
||||
|
||||
describe('Tag', () => {
|
||||
it('creates a new tag with the default color', () => {
|
||||
const tag = Tag.create({ projectId: 'project-1', name: ' Backend ' });
|
||||
|
||||
expect(tag.id).toBeNull();
|
||||
expect(tag.name).toBe('Backend');
|
||||
expect(tag.normalizedName).toBe('backend');
|
||||
expect(tag.color).toBe('default');
|
||||
});
|
||||
|
||||
it('renames and recolors the tag, touching updatedAt', () => {
|
||||
const tag = Tag.reconstitute({
|
||||
id: 'tag-1',
|
||||
projectId: 'project-1',
|
||||
name: 'backend',
|
||||
color: 'default',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
tag.rename('API');
|
||||
tag.changeColor('error');
|
||||
|
||||
expect(tag.name).toBe('API');
|
||||
expect(tag.normalizedName).toBe('api');
|
||||
expect(tag.color).toBe('error');
|
||||
expect(tag.updatedAt.getTime()).toBeGreaterThan(tag.createdAt.getTime());
|
||||
});
|
||||
|
||||
it('rejects an invalid color', () => {
|
||||
expect(() => Tag.create({ projectId: 'project-1', name: 'ui', color: 'pink' })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TagName } from '../value-objects/tag-name.vo.js';
|
||||
import { TagColor } from '../value-objects/tag-color.vo.js';
|
||||
|
||||
interface TagProps {
|
||||
id: string | null;
|
||||
projectId: string;
|
||||
name: TagName;
|
||||
color: TagColor;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тег из справочника проекта. Имя уникально в проекте без учёта регистра —
|
||||
* это гарантирует БД (см. TagNameAlreadyExistsException).
|
||||
*
|
||||
* `id` — UUID, генерируется на уровне Prisma при сохранении, поэтому
|
||||
* у нового тега он равен null до сохранения в репозитории.
|
||||
*/
|
||||
export class Tag {
|
||||
private constructor(private readonly props: TagProps) {}
|
||||
|
||||
static create(params: { projectId: string; name: string; color?: string }): Tag {
|
||||
return new Tag({
|
||||
id: null,
|
||||
projectId: params.projectId,
|
||||
name: TagName.create(params.name),
|
||||
color: params.color !== undefined ? TagColor.create(params.color) : TagColor.default(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
static reconstitute(props: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Tag {
|
||||
return new Tag({
|
||||
id: props.id,
|
||||
projectId: props.projectId,
|
||||
name: TagName.create(props.name),
|
||||
color: TagColor.create(props.color),
|
||||
createdAt: props.createdAt,
|
||||
updatedAt: props.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.props.id;
|
||||
}
|
||||
|
||||
get projectId(): string {
|
||||
return this.props.projectId;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.props.name.value;
|
||||
}
|
||||
|
||||
get normalizedName(): string {
|
||||
return this.props.name.normalized;
|
||||
}
|
||||
|
||||
get color(): string {
|
||||
return this.props.color.value;
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
/** Переименование меняет имя на всех задачах; в истории задач остаётся прежнее */
|
||||
rename(name: string): void {
|
||||
this.props.name = TagName.create(name);
|
||||
this.touch();
|
||||
}
|
||||
|
||||
changeColor(color: string): void {
|
||||
this.props.color = TagColor.create(color);
|
||||
this.touch();
|
||||
}
|
||||
|
||||
private touch(): void {
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class TagEditForbiddenException extends Error {
|
||||
constructor(projectId: string) {
|
||||
super(`Наблюдатель проекта "${projectId}" не может менять его теги`);
|
||||
this.name = 'TagEditForbiddenException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class TagNameAlreadyExistsException extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Тег "${name}" в проекте уже есть`);
|
||||
this.name = 'TagNameAlreadyExistsException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class TagNotFoundException extends Error {
|
||||
constructor(id: string) {
|
||||
super(`Тег с id "${id}" не найден`);
|
||||
this.name = 'TagNotFoundException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class TagProjectNotFoundException extends Error {
|
||||
constructor(projectId: string) {
|
||||
super(`Проект с id "${projectId}" не найден`);
|
||||
this.name = 'TagProjectNotFoundException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Tag } from '../entities/tag.entity.js';
|
||||
|
||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||
export const TAG_REPOSITORY = Symbol('TAG_REPOSITORY');
|
||||
|
||||
/** Права на тег проверяет вызывающий use-case — репозиторий о пользователях не знает */
|
||||
export interface ITagRepository {
|
||||
findById(id: string): Promise<Tag | null>;
|
||||
/** Теги проекта по имени; query — подстрока имени без учёта регистра (автоподстановка) */
|
||||
findByProject(params: { projectId: string; query?: string; limit: number }): Promise<Tag[]>;
|
||||
/**
|
||||
* Теги проекта с данными именами (без учёта регистра); отсутствующие создаются.
|
||||
* Порядок — как у имён, повторы схлопываются.
|
||||
*/
|
||||
findOrCreateByNames(projectId: string, names: string[]): Promise<Tag[]>;
|
||||
/** @throws TagNameAlreadyExistsException */
|
||||
create(tag: Tag): Promise<Tag>;
|
||||
/** @throws TagNameAlreadyExistsException */
|
||||
update(tag: Tag): Promise<void>;
|
||||
/** Снимает тег со всех задач */
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TagColor, TAG_COLORS } from './tag-color.vo.js';
|
||||
|
||||
describe('TagColor', () => {
|
||||
it.each(TAG_COLORS)('accepts the palette color "%s"', (color) => {
|
||||
expect(TagColor.create(color).value).toBe(color);
|
||||
});
|
||||
|
||||
it('throws for a color outside the palette', () => {
|
||||
expect(() => TagColor.create('#ff0000')).toThrow(/Недопустимый цвет тега/);
|
||||
});
|
||||
|
||||
it('defaults to "default"', () => {
|
||||
expect(TagColor.default().value).toBe('default');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Палитра — те же имена цветов, что у статусов и типов задач; как их рисовать, решает клиент */
|
||||
export const TAG_COLORS = ['default', 'primary', 'secondary', 'info', 'success', 'warning', 'error'] as const;
|
||||
|
||||
export type TagColorValue = (typeof TAG_COLORS)[number];
|
||||
|
||||
export class TagColor {
|
||||
private constructor(private readonly _value: TagColorValue) {}
|
||||
|
||||
static create(value: string): TagColor {
|
||||
if (!TAG_COLORS.includes(value as TagColorValue)) {
|
||||
throw new Error(`Недопустимый цвет тега: "${value}". Разрешены: ${TAG_COLORS.join(', ')}`);
|
||||
}
|
||||
|
||||
return new TagColor(value as TagColorValue);
|
||||
}
|
||||
|
||||
static default(): TagColor {
|
||||
return new TagColor('default');
|
||||
}
|
||||
|
||||
get value(): TagColorValue {
|
||||
return this._value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TagName, TAG_NAME_MAX_LENGTH } from './tag-name.vo.js';
|
||||
|
||||
describe('TagName', () => {
|
||||
it('trims the name and keeps its case', () => {
|
||||
expect(TagName.create(' Backend ').value).toBe('Backend');
|
||||
});
|
||||
|
||||
it('normalizes to lower case for uniqueness', () => {
|
||||
expect(TagName.create('Backend').normalized).toBe('backend');
|
||||
expect(TagName.create('UI').equals(TagName.create('ui'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throws for an empty name', () => {
|
||||
expect(() => TagName.create(' ')).toThrow(/не может быть пустым/);
|
||||
});
|
||||
|
||||
it('throws for a too long name', () => {
|
||||
expect(() => TagName.create('a'.repeat(TAG_NAME_MAX_LENGTH + 1))).toThrow(/не может превышать/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export const TAG_NAME_MAX_LENGTH = 50;
|
||||
|
||||
export class TagName {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): TagName {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error('Имя тега не может быть пустым');
|
||||
}
|
||||
|
||||
if (trimmed.length > TAG_NAME_MAX_LENGTH) {
|
||||
throw new Error(`Имя тега не может превышать ${TAG_NAME_MAX_LENGTH} символов`);
|
||||
}
|
||||
|
||||
return new TagName(trimmed);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
/** Ключ уникальности в проекте: теги «UI» и «ui» — один тег */
|
||||
get normalized(): string {
|
||||
return this._value.toLowerCase();
|
||||
}
|
||||
|
||||
equals(other: TagName): boolean {
|
||||
return this.normalized === other.normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Tag as PrismaTag, Prisma } from '../../../../../../generated/prisma/client.js';
|
||||
import { Tag } from '../../../../domain/entities/tag.entity.js';
|
||||
|
||||
export class TagPrismaMapper {
|
||||
static toDomain(record: PrismaTag): Tag {
|
||||
return Tag.reconstitute({
|
||||
id: record.id,
|
||||
projectId: record.projectId,
|
||||
name: record.name,
|
||||
color: record.color,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
static toCreateInput(tag: Tag): Prisma.TagUncheckedCreateInput {
|
||||
return {
|
||||
projectId: tag.projectId,
|
||||
name: tag.name,
|
||||
normalizedName: tag.normalizedName,
|
||||
color: tag.color,
|
||||
createdAt: tag.createdAt,
|
||||
updatedAt: tag.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
static toUpdateInput(tag: Tag): Prisma.TagUncheckedUpdateInput {
|
||||
return {
|
||||
name: tag.name,
|
||||
normalizedName: tag.normalizedName,
|
||||
color: tag.color,
|
||||
updatedAt: tag.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
||||
import { Tag } from '../../../../domain/entities/tag.entity.js';
|
||||
import { ITagRepository } from '../../../../domain/repositories/tag.repository.interface.js';
|
||||
import { TagNameAlreadyExistsException } from '../../../../domain/exceptions/tag-name-already-exists.exception.js';
|
||||
import { TagPrismaMapper } from '../mappers/tag.mapper.js';
|
||||
|
||||
const UNIQUE_CONSTRAINT_VIOLATION = 'P2002';
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_CONSTRAINT_VIOLATION;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TagPrismaRepository implements ITagRepository {
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
async findById(id: string): Promise<Tag | null> {
|
||||
const record = await this.prisma.tag.findUnique({ where: { id } });
|
||||
return record ? TagPrismaMapper.toDomain(record) : null;
|
||||
}
|
||||
|
||||
async findByProject(params: { projectId: string; query?: string; limit: number }): Promise<Tag[]> {
|
||||
const query = params.query?.trim().toLowerCase();
|
||||
const records = await this.prisma.tag.findMany({
|
||||
where: { projectId: params.projectId, ...(query ? { normalizedName: { contains: query } } : {}) },
|
||||
orderBy: { normalizedName: 'asc' },
|
||||
take: Math.max(1, params.limit),
|
||||
});
|
||||
|
||||
return records.map(TagPrismaMapper.toDomain);
|
||||
}
|
||||
|
||||
async findOrCreateByNames(projectId: string, names: string[]): Promise<Tag[]> {
|
||||
const drafts = new Map<string, Tag>();
|
||||
for (const name of names) {
|
||||
const draft = Tag.create({ projectId, name });
|
||||
if (!drafts.has(draft.normalizedName)) drafts.set(draft.normalizedName, draft);
|
||||
}
|
||||
if (drafts.size === 0) return [];
|
||||
|
||||
// Уже существующие теги (в том числе созданные параллельным запросом) пропускаются
|
||||
await this.prisma.tag.createMany({
|
||||
data: [...drafts.values()].map(TagPrismaMapper.toCreateInput),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
const records = await this.prisma.tag.findMany({
|
||||
where: { projectId, normalizedName: { in: [...drafts.keys()] } },
|
||||
});
|
||||
const byName = new Map(records.map((record) => [record.normalizedName, record]));
|
||||
|
||||
return [...drafts.keys()].flatMap((normalizedName) => {
|
||||
const record = byName.get(normalizedName);
|
||||
return record ? [TagPrismaMapper.toDomain(record)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async create(tag: Tag): Promise<Tag> {
|
||||
try {
|
||||
const record = await this.prisma.tag.create({ data: TagPrismaMapper.toCreateInput(tag) });
|
||||
return TagPrismaMapper.toDomain(record);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new TagNameAlreadyExistsException(tag.name);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(tag: Tag): Promise<void> {
|
||||
try {
|
||||
await this.prisma.tag.update({
|
||||
where: { id: tag.id as string },
|
||||
data: TagPrismaMapper.toUpdateInput(tag),
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new TagNameAlreadyExistsException(tag.name);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.tag.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
|
||||
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
|
||||
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
|
||||
import { TagNameAlreadyExistsException } from '../../domain/exceptions/tag-name-already-exists.exception.js';
|
||||
|
||||
/** Транслирует доменные исключения в HTTP/GraphQL-ошибку — это забота presentation-слоя */
|
||||
export async function handleTagErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof TagNotFoundException || error instanceof TagProjectNotFoundException) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
if (error instanceof TagEditForbiddenException) {
|
||||
throw new ForbiddenException(error.message);
|
||||
}
|
||||
if (error instanceof TagNameAlreadyExistsException) {
|
||||
throw new ConflictException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||
import { ListProjectTagsUseCase } from '../../application/use-cases/list-project-tags/list-project-tags.use-case.js';
|
||||
import { CreateTagUseCase } from '../../application/use-cases/create-tag/create-tag.use-case.js';
|
||||
import { UpdateTagUseCase } from '../../application/use-cases/update-tag/update-tag.use-case.js';
|
||||
import { DeleteTagUseCase } from '../../application/use-cases/delete-tag/delete-tag.use-case.js';
|
||||
import { ListProjectTagsQuery } from '../../application/use-cases/list-project-tags/list-project-tags.query.js';
|
||||
import { CreateTagCommand } from '../../application/use-cases/create-tag/create-tag.command.js';
|
||||
import { UpdateTagCommand } from '../../application/use-cases/update-tag/update-tag.command.js';
|
||||
import { DeleteTagCommand } from '../../application/use-cases/delete-tag/delete-tag.command.js';
|
||||
import { CreateTagRequestDto } from '../dto/create-tag.dto.js';
|
||||
import { UpdateTagRequestDto } from '../dto/update-tag.dto.js';
|
||||
import { ListTagsQueryDto } from '../dto/list-tags-query.dto.js';
|
||||
import { handleTagErrors } from './tag-error-handler.js';
|
||||
|
||||
/** Справочник тегов проекта. Задаче теги назначаются через PATCH /issues/:id (tags — имена). */
|
||||
@Controller()
|
||||
export class TagController {
|
||||
constructor(
|
||||
private readonly listProjectTagsUseCase: ListProjectTagsUseCase,
|
||||
private readonly createTagUseCase: CreateTagUseCase,
|
||||
private readonly updateTagUseCase: UpdateTagUseCase,
|
||||
private readonly deleteTagUseCase: DeleteTagUseCase,
|
||||
) {}
|
||||
|
||||
@Get('projects/:projectId/tags')
|
||||
async list(@Param('projectId') projectId: string, @Query() query: ListTagsQueryDto, @Session() session: UserSession) {
|
||||
return handleTagErrors(() =>
|
||||
this.listProjectTagsUseCase.execute(new ListProjectTagsQuery(projectId, session.user.id, query.q, query.limit)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('projects/:projectId/tags')
|
||||
async create(
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: CreateTagRequestDto,
|
||||
@Session() session: UserSession,
|
||||
) {
|
||||
return handleTagErrors(() =>
|
||||
this.createTagUseCase.execute(new CreateTagCommand(projectId, session.user.id, dto.name, dto.color)),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('tags/:id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateTagRequestDto, @Session() session: UserSession) {
|
||||
return handleTagErrors(() =>
|
||||
this.updateTagUseCase.execute(new UpdateTagCommand(id, session.user.id, dto.name, dto.color)),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('tags/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Param('id') id: string, @Session() session: UserSession) {
|
||||
await handleTagErrors(() => this.deleteTagUseCase.execute(new DeleteTagCommand(id, session.user.id)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
|
||||
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
|
||||
|
||||
export class CreateTagRequestDto {
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'name не может быть пустым' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TAG_COLORS)
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IntDecorator } from '../../../../common/decorators/validators.js';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
|
||||
|
||||
export class ListTagsQueryDto {
|
||||
/** Подстрока имени без учёта регистра — для автоподстановки */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH)
|
||||
q?: string;
|
||||
|
||||
@IntDecorator({ isOptional: true, min: 1, max: 500 })
|
||||
limit: number = 100;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
|
||||
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
|
||||
|
||||
export class UpdateTagRequestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'name не может быть пустым' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TAG_COLORS)
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
|
||||
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
|
||||
|
||||
@InputType()
|
||||
export class CreateTagInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'name не может быть пустым' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH)
|
||||
name: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsIn(TAG_COLORS)
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('Tag')
|
||||
export class TagType {
|
||||
@Field()
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
projectId: string;
|
||||
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field()
|
||||
color: string;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
|
||||
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
|
||||
|
||||
@InputType()
|
||||
export class UpdateTagInput {
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/\S/, { message: 'name не может быть пустым' })
|
||||
@MaxLength(TAG_NAME_MAX_LENGTH)
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsIn(TAG_COLORS)
|
||||
color?: string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||
import { ListProjectTagsUseCase } from '../../application/use-cases/list-project-tags/list-project-tags.use-case.js';
|
||||
import { CreateTagUseCase } from '../../application/use-cases/create-tag/create-tag.use-case.js';
|
||||
import { UpdateTagUseCase } from '../../application/use-cases/update-tag/update-tag.use-case.js';
|
||||
import { DeleteTagUseCase } from '../../application/use-cases/delete-tag/delete-tag.use-case.js';
|
||||
import { ListProjectTagsQuery } from '../../application/use-cases/list-project-tags/list-project-tags.query.js';
|
||||
import { CreateTagCommand } from '../../application/use-cases/create-tag/create-tag.command.js';
|
||||
import { UpdateTagCommand } from '../../application/use-cases/update-tag/update-tag.command.js';
|
||||
import { DeleteTagCommand } from '../../application/use-cases/delete-tag/delete-tag.command.js';
|
||||
import { TagDto } from '../../application/dto/tag.dto.js';
|
||||
import { TagType } from '../graphql/tag.type.js';
|
||||
import { CreateTagInput } from '../graphql/create-tag.input.js';
|
||||
import { UpdateTagInput } from '../graphql/update-tag.input.js';
|
||||
import { handleTagErrors } from '../controllers/tag-error-handler.js';
|
||||
|
||||
@Resolver(() => TagType)
|
||||
export class TagResolver {
|
||||
constructor(
|
||||
private readonly listProjectTagsUseCase: ListProjectTagsUseCase,
|
||||
private readonly createTagUseCase: CreateTagUseCase,
|
||||
private readonly updateTagUseCase: UpdateTagUseCase,
|
||||
private readonly deleteTagUseCase: DeleteTagUseCase,
|
||||
) {}
|
||||
|
||||
@Query(() => [TagType], { name: 'projectTags' })
|
||||
list(
|
||||
@Args('projectId') projectId: string,
|
||||
@Args('q', { type: () => String, nullable: true }) q: string | undefined,
|
||||
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
|
||||
@Session() session: UserSession,
|
||||
): Promise<TagDto[]> {
|
||||
return handleTagErrors(() =>
|
||||
this.listProjectTagsUseCase.execute(new ListProjectTagsQuery(projectId, session.user.id, q, limit)),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => TagType, { name: 'createTag' })
|
||||
create(
|
||||
@Args('projectId') projectId: string,
|
||||
@Args('input') input: CreateTagInput,
|
||||
@Session() session: UserSession,
|
||||
): Promise<TagDto> {
|
||||
return handleTagErrors(() =>
|
||||
this.createTagUseCase.execute(new CreateTagCommand(projectId, session.user.id, input.name, input.color)),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => TagType, { name: 'updateTag' })
|
||||
update(
|
||||
@Args('id') id: string,
|
||||
@Args('input') input: UpdateTagInput,
|
||||
@Session() session: UserSession,
|
||||
): Promise<TagDto> {
|
||||
return handleTagErrors(() =>
|
||||
this.updateTagUseCase.execute(new UpdateTagCommand(id, session.user.id, input.name, input.color)),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { name: 'deleteTag' })
|
||||
async delete(@Args('id') id: string, @Session() session: UserSession): Promise<boolean> {
|
||||
await handleTagErrors(() => this.deleteTagUseCase.execute(new DeleteTagCommand(id, session.user.id)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||
import { ProjectModule } from '../project/project.module.js';
|
||||
import { TagPrismaRepository } from './infrastructure/persistence/prisma/repositories/tag.prisma-repository.js';
|
||||
import { TAG_REPOSITORY } from './domain/repositories/tag.repository.interface.js';
|
||||
import { TagAccessService } from './application/services/tag-access.service.js';
|
||||
import { ListProjectTagsUseCase } from './application/use-cases/list-project-tags/list-project-tags.use-case.js';
|
||||
import { CreateTagUseCase } from './application/use-cases/create-tag/create-tag.use-case.js';
|
||||
import { UpdateTagUseCase } from './application/use-cases/update-tag/update-tag.use-case.js';
|
||||
import { DeleteTagUseCase } from './application/use-cases/delete-tag/delete-tag.use-case.js';
|
||||
import { TagController } from './presentation/controllers/tag.controller.js';
|
||||
import { TagResolver } from './presentation/resolvers/tag.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProjectModule],
|
||||
controllers: [TagController],
|
||||
providers: [
|
||||
TagAccessService,
|
||||
ListProjectTagsUseCase,
|
||||
CreateTagUseCase,
|
||||
UpdateTagUseCase,
|
||||
DeleteTagUseCase,
|
||||
TagResolver,
|
||||
{
|
||||
// Domain видит только интерфейс ITagRepository,
|
||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||
provide: TAG_REPOSITORY,
|
||||
useClass: TagPrismaRepository,
|
||||
},
|
||||
],
|
||||
// Задачи назначают теги по имени (findOrCreateByNames)
|
||||
exports: [TAG_REPOSITORY],
|
||||
})
|
||||
export class TagModule {}
|
||||
Reference in new issue
Block a user