Compare commits
12
Commits
feature/history
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac4d314e4 | ||
|
|
917ea9748e | ||
|
|
d170fc8790 | ||
|
|
b1f6b61994 | ||
|
|
46903e9ec5 | ||
|
|
f085517732 | ||
|
|
de1b3754df | ||
|
|
c5691da2b8 | ||
|
|
afc37f8fdb | ||
|
|
8d02d25da0 | ||
|
|
5ad0ecdf4c | ||
|
|
7aecc6f96e |
No files matched your search
@@ -11,3 +11,5 @@ coverage
|
|||||||
tsconfig.build.tsbuildinfo
|
tsconfig.build.tsbuildinfo
|
||||||
src/generated
|
src/generated
|
||||||
storage
|
storage
|
||||||
|
configs
|
||||||
|
service-account.json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Gitea Actions Demo
|
name: Gitea Actions
|
||||||
run-name: Deploy 🚀
|
run-name: Deploy 🚀
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: Docker build
|
- name: Docker build
|
||||||
run: docker build --tag ${{ env.IMAGE_NAME }} .
|
run: docker build --tag ${{ env.IMAGE_NAME }} .
|
||||||
- name: Login to gitea
|
- name: Login to gitea
|
||||||
|
|||||||
@@ -61,3 +61,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
|
|
||||||
# Local file storage (FILE_STORAGE=local)
|
# Local file storage (FILE_STORAGE=local)
|
||||||
/storage
|
/storage
|
||||||
|
/configs
|
||||||
|
configs/service-account.json
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# Prisma
|
||||||
|
|
||||||
npm install prisma@7 --save-dev
|
npm install prisma@7 --save-dev
|
||||||
|
|
||||||
|
|||||||
Generated
+2412
-1054
File diff suppressed because it is too large.
Load diff
+18
-19
@@ -24,48 +24,47 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^5.5.1",
|
"@apollo/server": "^5.5.1",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
"@as-integrations/express5": "^1.1.2",
|
||||||
"@aws-sdk/client-s3": "^3.1145.0",
|
"@aws-sdk/client-s3": "^3.1152.0",
|
||||||
"@better-auth/prisma-adapter": "^1.7.2",
|
"@better-auth/prisma-adapter": "^1.7.11",
|
||||||
"@nestjs/apollo": "^14.0.0",
|
"@nestjs/apollo": "^14.0.0",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.2.0",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.2.0",
|
||||||
"@nestjs/event-emitter": "^12.0.1",
|
"@nestjs/event-emitter": "^12.0.1",
|
||||||
"@nestjs/graphql": "^14.0.0",
|
"@nestjs/graphql": "^14.0.0",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.2.0",
|
||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/typeorm": "^12.0.1",
|
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"@thallesp/nestjs-better-auth": "^2.7.0",
|
"@thallesp/nestjs-better-auth": "^2.8.0",
|
||||||
"better-auth": "^1.7.2",
|
"better-auth": "^1.7.11",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"file-type": "^22.1.1",
|
"file-type": "^22.2.0",
|
||||||
|
"firebase-admin": "^14.5.0",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
"nodemailer": "^10.0.0",
|
"nodemailer": "^10.1.7",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^1.1.1",
|
"valibot": "^1.5.0"
|
||||||
"valibot": "^1.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^12.0.0",
|
"@nestjs/cli": "^12.0.0",
|
||||||
"@nestjs/mau": "^0.2.6",
|
"@nestjs/mau": "^0.2.8",
|
||||||
"@nestjs/schematics": "^12.0.0",
|
"@nestjs/schematics": "^12.0.0",
|
||||||
"@nestjs/testing": "^12.0.1",
|
"@nestjs/testing": "^12.2.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/multer": "^2.3.0",
|
"@types/multer": "^2.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.2.1",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"oxlint": "^1.58.0",
|
"oxlint": "^1.88.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.9.10",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.3.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "issue" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'TASK';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_projectId_type_idx" ON "issue"("projectId", "type");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user" ADD COLUMN "avatarFileId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_avatarFileId_key" ON "user"("avatarFileId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "file"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
+72
-3
@@ -13,6 +13,10 @@ model User {
|
|||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
|
/// Файл аватара; image хранит его URL для better-auth и клиента.
|
||||||
|
/// Меняется только через PUT /users/me/avatar — там проверяется, что файл свой.
|
||||||
|
avatarFileId String? @unique
|
||||||
|
avatarFile File? @relation(name: "userAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
@@ -25,8 +29,9 @@ model User {
|
|||||||
notificationPreferences NotificationPreference[]
|
notificationPreferences NotificationPreference[]
|
||||||
notificationMessages NotificationMessage[]
|
notificationMessages NotificationMessage[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
uploadedFiles File[]
|
uploadedFiles File[] @relation(name: "fileUploader")
|
||||||
issueChanges IssueChange[]
|
issueChanges IssueChange[]
|
||||||
|
observedIssues IssueObserver[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -88,6 +93,7 @@ model Issue {
|
|||||||
content String?
|
content String?
|
||||||
status String
|
status String
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
|
type String @default("TASK")
|
||||||
assigneeId String?
|
assigneeId String?
|
||||||
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
|
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||||
reporterId String?
|
reporterId String?
|
||||||
@@ -98,6 +104,8 @@ model Issue {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
attachments IssueAttachment[]
|
attachments IssueAttachment[]
|
||||||
changes IssueChange[]
|
changes IssueChange[]
|
||||||
|
observers IssueObserver[]
|
||||||
|
tags IssueTag[]
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -108,9 +116,26 @@ model Issue {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([projectId, createdAt])
|
@@index([projectId, createdAt])
|
||||||
@@index([dueDate])
|
@@index([dueDate])
|
||||||
|
@@index([projectId, dueDate])
|
||||||
|
@@index([projectId, type])
|
||||||
@@map("issue")
|
@@map("issue")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Наблюдатель задачи: видит её и может комментировать, но не редактировать.
|
||||||
|
/// Редактируют задачу только reporter и assignee.
|
||||||
|
model IssueObserver {
|
||||||
|
issueId Int
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([issueId, userId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("issue_observer")
|
||||||
|
}
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(uuid(7))
|
id String @id @default(uuid(7))
|
||||||
name String
|
name String
|
||||||
@@ -121,6 +146,7 @@ model Project {
|
|||||||
ownerId String
|
ownerId String
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
||||||
members ProjectMember[]
|
members ProjectMember[]
|
||||||
|
tags Tag[]
|
||||||
|
|
||||||
@@unique([key])
|
@@unique([key])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@ -128,10 +154,50 @@ model Project {
|
|||||||
@@map("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 {
|
model ProjectMember {
|
||||||
id String @id @default(uuid(7))
|
id String @id @default(uuid(7))
|
||||||
projectId String
|
projectId String
|
||||||
userId String
|
userId String
|
||||||
|
role ProjectRole @default(MEMBER)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
@@ -226,11 +292,12 @@ model File {
|
|||||||
mimeType String
|
mimeType String
|
||||||
size Int
|
size Int
|
||||||
uploaderId String?
|
uploaderId String?
|
||||||
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
|
uploader User? @relation(name: "fileUploader", fields: [uploaderId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
issueAttachments IssueAttachment[]
|
issueAttachments IssueAttachment[]
|
||||||
commentAttachments CommentAttachment[]
|
commentAttachments CommentAttachment[]
|
||||||
|
avatarOf User? @relation(name: "userAvatar")
|
||||||
|
|
||||||
@@unique([storageKey])
|
@@unique([storageKey])
|
||||||
@@index([uploaderId])
|
@@index([uploaderId])
|
||||||
@@ -269,9 +336,11 @@ model CommentAttachment {
|
|||||||
|
|
||||||
/// История изменений задачи — одна строка на изменённое поле.
|
/// История изменений задачи — одна строка на изменённое поле.
|
||||||
/// Строки одного сохранения задачи объединены общим changeId.
|
/// Строки одного сохранения задачи объединены общим changeId.
|
||||||
/// field: created | title | content | status | priority | assignee | dueDate
|
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
|
||||||
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
||||||
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
|
||||||
|
/// observer — id пользователя, tag — имя тега на момент изменения.
|
||||||
/// При создании задачи пишутся две строки: created и status null -> OPEN,
|
/// При создании задачи пишутся две строки: created и status null -> OPEN,
|
||||||
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
|
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
|
||||||
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
|
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
|
||||||
|
|||||||
+4
-8
@@ -5,11 +5,13 @@ import { IssueModule } from './modules/issue/issue.module.js';
|
|||||||
import { ProjectModule } from './modules/project/project.module.js';
|
import { ProjectModule } from './modules/project/project.module.js';
|
||||||
import { UserModule } from './modules/user/user.module.js';
|
import { UserModule } from './modules/user/user.module.js';
|
||||||
import { StatusModule } from './modules/status/status.module.js';
|
import { StatusModule } from './modules/status/status.module.js';
|
||||||
|
import { IssueTypeModule } from './modules/issue-type/issue-type.module.js';
|
||||||
import { NotificationModule } from './modules/notification/notification.module.js';
|
import { NotificationModule } from './modules/notification/notification.module.js';
|
||||||
import { CommentModule } from './modules/comment/comment.module.js';
|
import { CommentModule } from './modules/comment/comment.module.js';
|
||||||
import { FileModule } from './modules/file/file.module.js';
|
import { FileModule } from './modules/file/file.module.js';
|
||||||
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
||||||
import { HistoryModule } from './modules/history/history.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 { AuthModule } from '@thallesp/nestjs-better-auth';
|
||||||
import { auth } from './lib/auth.js';
|
import { auth } from './lib/auth.js';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
@@ -25,21 +27,13 @@ import { join } from 'node:path';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
cache: true,
|
cache: true,
|
||||||
}),
|
}),
|
||||||
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
|
|
||||||
// события через неё, не завязываясь на модули, которые их порождают.
|
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
// Периодические задачи (@Cron), например сборка файлов-сирот в FileModule
|
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||||
driver: ApolloDriver,
|
driver: ApolloDriver,
|
||||||
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
|
||||||
sortSchema: true,
|
sortSchema: true,
|
||||||
}),
|
}),
|
||||||
// TypeOrmModule.forRootAsync({
|
|
||||||
// imports: [ConfigModule],
|
|
||||||
// inject: [ConfigService],
|
|
||||||
// useFactory: pgConfig,
|
|
||||||
// }),
|
|
||||||
AuthModule.forRoot({
|
AuthModule.forRoot({
|
||||||
auth: auth,
|
auth: auth,
|
||||||
bodyParser: {
|
bodyParser: {
|
||||||
@@ -52,11 +46,13 @@ import { join } from 'node:path';
|
|||||||
ProjectModule,
|
ProjectModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
StatusModule,
|
StatusModule,
|
||||||
|
IssueTypeModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
IssueAttachmentModule,
|
IssueAttachmentModule,
|
||||||
HistoryModule,
|
HistoryModule,
|
||||||
|
TagModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
import { applyDecorators } from '@nestjs/common';
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
|
||||||
interface ToStringOptions {
|
interface ToStringOptions {
|
||||||
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
||||||
|
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
|
||||||
export interface IntDecoratorParams extends BaseDecoratorParams {
|
export interface IntDecoratorParams extends BaseDecoratorParams {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
@@ -24,6 +25,15 @@ const ToNumber = () =>
|
|||||||
return Number.isNaN(parsed) ? undefined : parsed;
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */
|
||||||
|
const ToBoolean = () =>
|
||||||
|
Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
const ToString = (options?: ToStringOptions) =>
|
const ToString = (options?: ToStringOptions) =>
|
||||||
Transform(({ value }: TransformFnParams) => {
|
Transform(({ value }: TransformFnParams) => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
|
|||||||
|
|
||||||
return applyDecorators(...args);
|
return applyDecorators(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BooleanDecorator(params?: BooleanDecoratorParams) {
|
||||||
|
const args = [IsBoolean(), ToBoolean()];
|
||||||
|
|
||||||
|
if (params?.isOptional) args.push(IsOptional());
|
||||||
|
|
||||||
|
return applyDecorators(...args);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
export function pgConfig(config: ConfigService): TypeOrmModuleOptions {
|
export function pgConfig(config: ConfigService): unknown {
|
||||||
return {
|
return {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: config.getOrThrow<string>('DATABASE_URL'),
|
url: config.getOrThrow<string>('DATABASE_URL'),
|
||||||
|
|||||||
+28
-11
@@ -2,12 +2,35 @@ import { betterAuth } from 'better-auth';
|
|||||||
import { prismaAdapter } from '@better-auth/prisma-adapter';
|
import { prismaAdapter } from '@better-auth/prisma-adapter';
|
||||||
import prisma from './prisma.js';
|
import prisma from './prisma.js';
|
||||||
import EmailSender from './email-sender.js';
|
import EmailSender from './email-sender.js';
|
||||||
|
import { emailOTP } from 'better-auth/plugins';
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: process.env.APP_NAME ?? 'app',
|
appName: process.env.APP_NAME ?? 'app',
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
plugins: [
|
plugins: [
|
||||||
// authTransformResponse()
|
// authTransformResponse()
|
||||||
|
emailOTP({
|
||||||
|
async sendVerificationOTP({ email, otp, type }) {
|
||||||
|
if (type === 'sign-in') {
|
||||||
|
EmailSender.sendInBackground({
|
||||||
|
to: email,
|
||||||
|
subject: `Your OTP code is: ${otp}`,
|
||||||
|
text: `Your OTP code is: ${otp}`,
|
||||||
|
});
|
||||||
|
} else if (type === 'email-verification') {
|
||||||
|
// Send the OTP for email verification
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
} else if (type === 'forget-password') {
|
||||||
|
EmailSender.sendInBackground({
|
||||||
|
to: email,
|
||||||
|
subject: `Your password reset code is: ${otp}`,
|
||||||
|
text: `Your password reset code is: ${otp}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: 'postgresql',
|
provider: 'postgresql',
|
||||||
@@ -24,28 +47,23 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
autoSignIn: false,
|
autoSignIn: false,
|
||||||
requireEmailVerification: false,
|
requireEmailVerification: true,
|
||||||
onExistingUserSignUp: async ({ user }, request) => {
|
onExistingUserSignUp: async ({ user }, request) => {
|
||||||
console.log('onExistingUserSignUp', { user });
|
EmailSender.sendInBackground({
|
||||||
|
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Sign-up attempt with your email',
|
subject: 'Sign-up attempt with your email',
|
||||||
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
|
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sendResetPassword: async ({ user, url, token }, request) => {
|
sendResetPassword: async ({ user, url, token }, request) => {
|
||||||
console.log('sendResetPassword', { user, url, token });
|
EmailSender.sendInBackground({
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Reset your password',
|
subject: 'Reset your password',
|
||||||
text: `Click the link to reset your password: ${url}`,
|
text: `Click the link to reset your password: ${url}`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onPasswordReset: async ({ user }, request) => {
|
onPasswordReset: async ({ user }, request) => {
|
||||||
// your logic here
|
EmailSender.sendInBackground({
|
||||||
console.log('onPasswordReset', { user });
|
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Reset your password result',
|
subject: 'Reset your password result',
|
||||||
text: `Password for user ${user.email} has been reset.`,
|
text: `Password for user ${user.email} has been reset.`,
|
||||||
@@ -56,8 +74,7 @@ export const auth = betterAuth({
|
|||||||
sendOnSignIn: true,
|
sendOnSignIn: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
sendVerificationEmail: async ({ user, url }) => {
|
sendVerificationEmail: async ({ user, url }) => {
|
||||||
console.log('emailVerification', { user, url });
|
EmailSender.sendInBackground({
|
||||||
void EmailSender.getTransporter().sendMail({
|
|
||||||
to: user.email,
|
to: user.email,
|
||||||
subject: 'Verify your email address',
|
subject: 'Verify your email address',
|
||||||
text: `Click the link to verify your email: ${url}`,
|
text: `Click the link to verify your email: ${url}`,
|
||||||
|
|||||||
+33
-2
@@ -1,13 +1,44 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
|
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
|
||||||
|
|
||||||
export default class EmailSender {
|
export default class EmailSender {
|
||||||
static getTransporter() {
|
private static readonly logger = new Logger(EmailSender.name);
|
||||||
return nodemailer.createTransport({
|
private static transporter?: Transporter;
|
||||||
|
|
||||||
|
private static isLocal() {
|
||||||
|
return process.env.NODE_ENV === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getTransporter() {
|
||||||
|
this.transporter ??= nodemailer.createTransport({
|
||||||
service: 'gmail',
|
service: 'gmail',
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.GOOGLE_APP_USER,
|
user: process.env.GOOGLE_APP_USER,
|
||||||
pass: process.env.GOOGLE_APP_PASSWORD,
|
pass: process.env.GOOGLE_APP_PASSWORD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return this.transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends an email; throws on failure. In local env only logs the message. */
|
||||||
|
static async send(options: SendMailOptions): Promise<void> {
|
||||||
|
if (this.isLocal()) {
|
||||||
|
this.logger.log(
|
||||||
|
`[local] email not sent\n to: ${String(options.to)}\n subject: ${options.subject}\n text: ${String(options.text)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getTransporter().sendMail(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget variant: never throws, errors are logged. */
|
||||||
|
static sendInBackground(options: SendMailOptions): void {
|
||||||
|
this.send(options).catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send email "${options.subject}" to ${String(options.to)}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,12 +16,7 @@ async function bootstrap() {
|
|||||||
logger: new CustomLogger(),
|
logger: new CustomLogger(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// app.useGlobalPipes(new StandardSchemaValidationPipe());
|
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Глобальный body-parser отключён (better-auth сам парсит своё тело),
|
|
||||||
// поэтому для GraphQL включаем JSON-парсинг точечно на его пути.
|
|
||||||
app.use('/graphql', json());
|
app.use('/graphql', json());
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
app.useGlobalInterceptors(new TransformResponseInterceptor());
|
app.useGlobalInterceptors(new TransformResponseInterceptor());
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -50,6 +51,7 @@ export const mockIssueRepository = (): IIssueRepository => ({
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export class IssueFieldChangeDto {
|
|||||||
field: IssueChangeField;
|
field: IssueChangeField;
|
||||||
oldValue: string | null;
|
oldValue: string | null;
|
||||||
newValue: string | null;
|
newValue: string | null;
|
||||||
/** Только для field = assignee: пользователи из oldValue/newValue (null — не назначен или удалён) */
|
/** Только для field = assignee/observer: пользователи из oldValue/newValue (null — не назначен или удалён) */
|
||||||
oldUser?: UserDto | null;
|
oldUser?: UserDto | null;
|
||||||
newUser?: UserDto | null;
|
newUser?: UserDto | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Статистика задач по дням, считается по истории изменений (issue_change).
|
* Статистика задач по дням, считается по истории изменений (issue_change).
|
||||||
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee.
|
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetDailyIssueStatsUseCase {
|
export class GetDailyIssueStatsUseCase {
|
||||||
|
|||||||
+2
@@ -19,6 +19,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -54,6 +55,7 @@ describe('GetIssueHistoryUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+7
-3
@@ -14,6 +14,10 @@ import {
|
|||||||
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js';
|
import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js';
|
||||||
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
|
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
|
||||||
|
import type { IssueChangeField } from '../../../../issue/domain/entities/issue-change.js';
|
||||||
|
|
||||||
|
/** Поля, значения которых — id пользователей */
|
||||||
|
const USER_FIELDS = new Set<IssueChangeField>(['assignee', 'observer']);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetIssueHistoryUseCase {
|
export class GetIssueHistoryUseCase {
|
||||||
@@ -55,7 +59,7 @@ export class GetIssueHistoryUseCase {
|
|||||||
oldValue: record.oldValue,
|
oldValue: record.oldValue,
|
||||||
newValue: record.newValue,
|
newValue: record.newValue,
|
||||||
};
|
};
|
||||||
if (record.field === 'assignee') {
|
if (USER_FIELDS.has(record.field)) {
|
||||||
change.oldUser = userOf(record.oldValue);
|
change.oldUser = userOf(record.oldValue);
|
||||||
change.newUser = userOf(record.newValue);
|
change.newUser = userOf(record.newValue);
|
||||||
}
|
}
|
||||||
@@ -65,12 +69,12 @@ export class GetIssueHistoryUseCase {
|
|||||||
return [...entries.values()];
|
return [...entries.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Авторы изменений и назначенные/снятые исполнители */
|
/** Авторы изменений, назначенные/снятые исполнители и наблюдатели */
|
||||||
private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> {
|
private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (record.actorId) ids.add(record.actorId);
|
if (record.actorId) ids.add(record.actorId);
|
||||||
if (record.field === 'assignee') {
|
if (USER_FIELDS.has(record.field)) {
|
||||||
if (record.oldValue) ids.add(record.oldValue);
|
if (record.oldValue) ids.add(record.oldValue);
|
||||||
if (record.newValue) ids.add(record.newValue);
|
if (record.newValue) ids.add(record.newValue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export interface DailyStatusCount {
|
|||||||
export interface IIssueHistoryRepository {
|
export interface IIssueHistoryRepository {
|
||||||
/** История задачи от старых изменений к новым */
|
/** История задачи от старых изменений к новым */
|
||||||
findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
|
findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
|
||||||
/** Только задачи, видимые пользователю (reporter/assignee) */
|
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
|
||||||
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
|
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
|
||||||
/** Только задачи, видимые пользователю (reporter/assignee) */
|
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
|
||||||
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
|
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
|
||||||
}
|
}
|
||||||
+4
-3
@@ -11,13 +11,14 @@ import type {
|
|||||||
} from '../../../../domain/repositories/issue-history.repository.interface.js';
|
} from '../../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Задачи, видимые пользователю: reporter или assignee — то же правило, что
|
* Задачи, видимые пользователю: reporter, assignee или наблюдатель — то же
|
||||||
* ownedByUser в IssuePrismaRepository. Возвращает CTE "visible" (id задач).
|
* правило, что visibleTo в IssuePrismaRepository. Возвращает CTE "visible" (id задач).
|
||||||
*/
|
*/
|
||||||
function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql {
|
function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql {
|
||||||
return Prisma.sql`"visible" AS (
|
return Prisma.sql`"visible" AS (
|
||||||
SELECT "id" FROM "issue"
|
SELECT "id" FROM "issue"
|
||||||
WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId})
|
WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId}
|
||||||
|
OR EXISTS (SELECT 1 FROM "issue_observer" o WHERE o."issueId" = "issue"."id" AND o."userId" = ${range.userId}))
|
||||||
${range.projectId ? Prisma.sql`AND "projectId" = ${range.projectId}` : Prisma.empty}
|
${range.projectId ? Prisma.sql`AND "projectId" = ${range.projectId}` : Prisma.empty}
|
||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ export class IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit
|
|||||||
/**
|
/**
|
||||||
* Файл виден, если видна хоть одна задача, к которой он прикреплён.
|
* Файл виден, если видна хоть одна задача, к которой он прикреплён.
|
||||||
* Видимость проверяем через IIssueRepository.findById, чтобы правило
|
* Видимость проверяем через IIssueRepository.findById, чтобы правило
|
||||||
* (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче.
|
* (reporter/assignee/наблюдатель) жило в одном месте. Обычно файл прикреплён к одной задаче.
|
||||||
*/
|
*/
|
||||||
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||||
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
|
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
|
||||||
|
|||||||
+7
-2
@@ -8,6 +8,7 @@ import {
|
|||||||
type IIssueRepository,
|
type IIssueRepository,
|
||||||
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||||
import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js';
|
import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js';
|
||||||
import { AttachFileCommand } from './attach-file.command.js';
|
import { AttachFileCommand } from './attach-file.command.js';
|
||||||
@@ -15,7 +16,7 @@ import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Прикрепляет к задаче файл, заранее загруженный через POST /files.
|
* Прикрепляет к задаче файл, заранее загруженный через POST /files.
|
||||||
* Прикреплять может любой, кому видна задача (как и править её),
|
* Прикреплять может тот, кто может править задачу (не наблюдатель),
|
||||||
* но только собственный файл — см. StoredFile.assertOwnedBy.
|
* но только собственный файл — см. StoredFile.assertOwnedBy.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -29,9 +30,13 @@ export class AttachFileUseCase {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> {
|
async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> {
|
||||||
if (!(await this.issueRepository.findById(command.issueId, command.userId))) {
|
const issue = await this.issueRepository.findById(command.issueId, command.userId);
|
||||||
|
if (!issue) {
|
||||||
throw new IssueNotFoundException(command.issueId);
|
throw new IssueNotFoundException(command.issueId);
|
||||||
}
|
}
|
||||||
|
if (!issue.canBeEditedBy(command.userId)) {
|
||||||
|
throw new IssueEditForbiddenException(command.issueId);
|
||||||
|
}
|
||||||
|
|
||||||
const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId);
|
const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId);
|
||||||
const attachment = await this.attachmentRepository.create(
|
const attachment = await this.attachmentRepository.create(
|
||||||
|
|||||||
+7
-1
@@ -8,12 +8,14 @@ import {
|
|||||||
type IIssueRepository,
|
type IIssueRepository,
|
||||||
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js';
|
import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js';
|
||||||
import { DetachFileCommand } from './detach-file.command.js';
|
import { DetachFileCommand } from './detach-file.command.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Открепляет файл от задачи. Сам файл не удаляется: если он больше
|
* Открепляет файл от задачи. Сам файл не удаляется: если он больше
|
||||||
* ни к чему не прикреплён, его удалит GC file-модуля.
|
* ни к чему не прикреплён, его удалит GC file-модуля.
|
||||||
|
* Наблюдателям задачи недоступно — как и её правка.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DetachFileUseCase {
|
export class DetachFileUseCase {
|
||||||
@@ -25,9 +27,13 @@ export class DetachFileUseCase {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: DetachFileCommand): Promise<void> {
|
async execute(command: DetachFileCommand): Promise<void> {
|
||||||
if (!(await this.issueRepository.findById(command.issueId, command.userId))) {
|
const issue = await this.issueRepository.findById(command.issueId, command.userId);
|
||||||
|
if (!issue) {
|
||||||
throw new IssueNotFoundException(command.issueId);
|
throw new IssueNotFoundException(command.issueId);
|
||||||
}
|
}
|
||||||
|
if (!issue.canBeEditedBy(command.userId)) {
|
||||||
|
throw new IssueEditForbiddenException(command.issueId);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) {
|
if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) {
|
||||||
throw new IssueAttachmentNotFoundException(command.issueId, command.fileId);
|
throw new IssueAttachmentNotFoundException(command.issueId, command.fileId);
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ import { IssueAttachmentNotFoundException } from '../../domain/exceptions/issue-
|
|||||||
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
|
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
|
||||||
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
|
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вложения задачи. Сам файл загружается отдельно (POST /files),
|
* Вложения задачи. Сам файл загружается отдельно (POST /files),
|
||||||
@@ -78,7 +79,7 @@ export class IssueAttachmentController {
|
|||||||
) {
|
) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
if (error instanceof FileForbiddenException) {
|
if (error instanceof FileForbiddenException || error instanceof IssueEditForbiddenException) {
|
||||||
throw new ForbiddenException(error.message);
|
throw new ForbiddenException(error.message);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ISSUE_TYPES } from '../../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListIssueTypesUseCase {
|
||||||
|
execute(): typeof ISSUE_TYPES {
|
||||||
|
return ISSUE_TYPES;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IssueTypeController } from './presentation/controllers/issue-type.controller.js';
|
||||||
|
import { IssueTypeResolver } from './presentation/resolvers/issue-type.resolver.js';
|
||||||
|
import { ListIssueTypesUseCase } from './application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [IssueTypeController],
|
||||||
|
providers: [ListIssueTypesUseCase, IssueTypeResolver],
|
||||||
|
})
|
||||||
|
export class IssueTypeModule {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
|
@Controller('issue-types')
|
||||||
|
export class IssueTypeController {
|
||||||
|
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(): typeof ISSUE_TYPES {
|
||||||
|
return this.listIssueTypesUseCase.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
/** Не `IssueType` — это имя занято value-объектом типа задачи */
|
||||||
|
@ObjectType('IssueTypeDefinition')
|
||||||
|
export class IssueTypeDefinitionType {
|
||||||
|
@Field()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
alias: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
import { IssueTypeDefinitionType } from '../graphql/issue-type-definition.type.js';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class IssueTypeResolver {
|
||||||
|
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
|
||||||
|
|
||||||
|
@Query(() => [IssueTypeDefinitionType], { name: 'issueTypes' })
|
||||||
|
list(): typeof ISSUE_TYPES {
|
||||||
|
return this.listIssueTypesUseCase.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IssueWithUsersDto } from './issue-with-users.dto.js';
|
||||||
|
|
||||||
|
export class CalendarIssuesDto {
|
||||||
|
list: IssueWithUsersDto[];
|
||||||
|
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -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';
|
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||||
|
|
||||||
export class IssueWithUsersDto {
|
export class IssueWithUsersDto {
|
||||||
@@ -7,10 +8,15 @@ export class IssueWithUsersDto {
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
type: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
reporterId: string | null;
|
reporterId: string | null;
|
||||||
assignee: UserDto | null;
|
assignee: UserDto | null;
|
||||||
reporter: UserDto | null;
|
reporter: UserDto | null;
|
||||||
|
observerIds: string[];
|
||||||
|
/** Удалённые пользователи сюда не попадают */
|
||||||
|
observers: UserDto[];
|
||||||
|
tags: IssueTagDto[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { IssueTagDto } from './issue-tag.dto.js';
|
||||||
|
|
||||||
export class IssueDto {
|
export class IssueDto {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -5,8 +7,11 @@ export class IssueDto {
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
type: string;
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
reporterId: string | null;
|
reporterId: string | null;
|
||||||
|
observerIds: string[];
|
||||||
|
tags: IssueTagDto[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import { Issue } from '../../domain/entities/issue.entity.js';
|
|||||||
import { IssueDto } from '../dto/issue.dto.js';
|
import { IssueDto } from '../dto/issue.dto.js';
|
||||||
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
|
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
|
||||||
import { UserDto } from '../../../user/application/dto/user.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 {
|
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 {
|
static toDto(issue: Issue): IssueDto {
|
||||||
return {
|
return {
|
||||||
id: issue.id as number,
|
id: issue.id as number,
|
||||||
@@ -12,8 +19,11 @@ export class IssueApplicationMapper {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assigneeId: issue.assignee,
|
assigneeId: issue.assignee,
|
||||||
reporterId: issue.reporter,
|
reporterId: issue.reporter,
|
||||||
|
observerIds: [...issue.observers],
|
||||||
|
tags: issue.tags.map((tag) => ({ ...tag })),
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
createdAt: issue.createdAt,
|
createdAt: issue.createdAt,
|
||||||
@@ -21,7 +31,7 @@ export class IssueApplicationMapper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */
|
/** Раскрывает assignee/reporter/наблюдателей в полноценных пользователей (см. GetIssueUseCase) */
|
||||||
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
|
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
|
||||||
return {
|
return {
|
||||||
id: issue.id as number,
|
id: issue.id as number,
|
||||||
@@ -30,10 +40,14 @@ export class IssueApplicationMapper {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assigneeId: issue.assignee,
|
assigneeId: issue.assignee,
|
||||||
reporterId: issue.reporter,
|
reporterId: issue.reporter,
|
||||||
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
|
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
|
||||||
reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null,
|
reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null,
|
||||||
|
observerIds: [...issue.observers],
|
||||||
|
observers: issue.observers.flatMap((id) => usersById.get(id) ?? []),
|
||||||
|
tags: issue.tags.map((tag) => ({ ...tag })),
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
createdAt: issue.createdAt,
|
createdAt: issue.createdAt,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
this.notificationsService.notify({
|
||||||
|
userId: assigned.assignee,
|
||||||
|
subject: `You have been assigned to ${issue.key}`,
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const buildIssue = (overrides: Partial<{ assignee: string | null; reporter: stri
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2',
|
assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2',
|
||||||
reporter: overrides.reporter ?? 'user-1',
|
reporter: overrides.reporter ?? 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { IssueApplicationMapper } from '../mappers/issue-application.mapper.js';
|
|||||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||||
|
|
||||||
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */
|
/** Раскрывает assignee/reporter/наблюдателей задачи (или списка задач) в полноценных пользователей */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IssueUserExpanderService {
|
export class IssueUserExpanderService {
|
||||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||||
@@ -19,7 +19,11 @@ export class IssueUserExpanderService {
|
|||||||
if (issues.length === 0) return [];
|
if (issues.length === 0) return [];
|
||||||
|
|
||||||
const userIds = [
|
const userIds = [
|
||||||
...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))),
|
...new Set(
|
||||||
|
issues
|
||||||
|
.flatMap((issue) => [issue.reporter, issue.assignee, ...issue.observers])
|
||||||
|
.filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ export class CreateIssueCommand {
|
|||||||
public readonly priority?: number,
|
public readonly priority?: number,
|
||||||
public readonly assignee?: string,
|
public readonly assignee?: string,
|
||||||
public readonly dueDate?: Date,
|
public readonly dueDate?: Date,
|
||||||
|
public readonly type?: string,
|
||||||
|
public readonly observers?: string[],
|
||||||
|
/** Имена тегов; отсутствующие в справочнике проекта создаются */
|
||||||
|
public readonly tags?: string[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+185
-1
@@ -1,5 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
import 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 type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||||
import { CreateIssueUseCase } from './create-issue.use-case.js';
|
import { CreateIssueUseCase } from './create-issue.use-case.js';
|
||||||
@@ -7,12 +9,22 @@ import { CreateIssueCommand } from './create-issue.command.js';
|
|||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
|
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
|
||||||
|
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||||
|
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
|
import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js';
|
||||||
|
import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js';
|
||||||
|
import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js';
|
||||||
|
|
||||||
describe('CreateIssueUseCase', () => {
|
describe('CreateIssueUseCase', () => {
|
||||||
let repository: IIssueRepository;
|
let repository: IIssueRepository;
|
||||||
let projectRepository: IProjectRepository;
|
let projectRepository: IProjectRepository;
|
||||||
let issueEventsService: IssueEventsService;
|
let issueEventsService: IssueEventsService;
|
||||||
let issueUserExpander: IssueUserExpanderService;
|
let issueUserExpander: IssueUserExpanderService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
let getUsersUseCase: GetUsersUseCase;
|
||||||
|
let tagRepository: ITagRepository;
|
||||||
let useCase: CreateIssueUseCase;
|
let useCase: CreateIssueUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -20,6 +32,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -31,6 +44,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
findRole: vi.fn(async () => 'MEMBER' as const),
|
||||||
addMember: vi.fn(),
|
addMember: vi.fn(),
|
||||||
};
|
};
|
||||||
issueEventsService = new IssueEventsService();
|
issueEventsService = new IssueEventsService();
|
||||||
@@ -41,7 +55,20 @@ describe('CreateIssueUseCase', () => {
|
|||||||
reporter: null,
|
reporter: null,
|
||||||
})),
|
})),
|
||||||
} as unknown as IssueUserExpanderService;
|
} as unknown as IssueUserExpanderService;
|
||||||
useCase = new CreateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
|
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),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => {
|
it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => {
|
||||||
@@ -53,6 +80,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assignee: issue.assignee,
|
assignee: issue.assignee,
|
||||||
reporter: issue.reporter,
|
reporter: issue.reporter,
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
@@ -71,6 +99,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
title: 'Fix login bug',
|
title: 'Fix login bug',
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
reporterId: 'user-1',
|
reporterId: 'user-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -102,6 +131,7 @@ describe('CreateIssueUseCase', () => {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assignee: issue.assignee,
|
assignee: issue.assignee,
|
||||||
reporter: issue.reporter,
|
reporter: issue.reporter,
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
@@ -118,4 +148,158 @@ describe('CreateIssueUseCase', () => {
|
|||||||
|
|
||||||
expect(received).toEqual([dto]);
|
expect(received).toEqual([dto]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('notifies the assignee of a new issue', async () => {
|
||||||
|
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||||
|
|
||||||
|
await useCase.execute(
|
||||||
|
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-2'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notificationsService.notify).toHaveBeenCalledWith({
|
||||||
|
userId: 'user-2',
|
||||||
|
subject: 'You have been assigned to ISSUE-2',
|
||||||
|
content: 'Improve onboarding',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies the reporter who assigns the issue to themselves', async () => {
|
||||||
|
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||||
|
|
||||||
|
await useCase.execute(
|
||||||
|
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify when the issue is created unassigned', async () => {
|
||||||
|
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||||
|
|
||||||
|
await useCase.execute(new CreateIssueCommand('ISSUE-3', 'Improve onboarding', 'user-1', 'project-1'));
|
||||||
|
|
||||||
|
expect(notificationsService.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a TASK by default and accepts an explicit type', async () => {
|
||||||
|
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||||
|
|
||||||
|
const task = await useCase.execute(new CreateIssueCommand('ISSUE-4', 'Something', 'user-1', 'project-1'));
|
||||||
|
const bug = await useCase.execute(
|
||||||
|
new CreateIssueCommand(
|
||||||
|
'ISSUE-5',
|
||||||
|
'Crash on start',
|
||||||
|
'user-1',
|
||||||
|
'project-1',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'BUG',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,11 +5,17 @@ import {
|
|||||||
PROJECT_REPOSITORY,
|
PROJECT_REPOSITORY,
|
||||||
type IProjectRepository,
|
type IProjectRepository,
|
||||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||||
|
import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js';
|
||||||
|
import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js';
|
||||||
import { CreateIssueCommand } from './create-issue.command.js';
|
import { CreateIssueCommand } from './create-issue.command.js';
|
||||||
|
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
|
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||||
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
||||||
|
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateIssueUseCase {
|
export class CreateIssueUseCase {
|
||||||
@@ -18,30 +24,52 @@ export class CreateIssueUseCase {
|
|||||||
private readonly issueRepository: IIssueRepository,
|
private readonly issueRepository: IIssueRepository,
|
||||||
@Inject(PROJECT_REPOSITORY)
|
@Inject(PROJECT_REPOSITORY)
|
||||||
private readonly projectRepository: IProjectRepository,
|
private readonly projectRepository: IProjectRepository,
|
||||||
|
@Inject(TAG_REPOSITORY)
|
||||||
|
private readonly tagRepository: ITagRepository,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
private readonly issueUserExpander: IssueUserExpanderService,
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||||
|
private readonly issueParticipants: IssueParticipantsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
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({
|
const draft = Issue.create({
|
||||||
key: command.key,
|
key: command.key,
|
||||||
title: command.title,
|
title: command.title,
|
||||||
content: command.content,
|
content: command.content,
|
||||||
priority: command.priority,
|
priority: command.priority,
|
||||||
|
type: command.type,
|
||||||
assignee: command.assignee,
|
assignee: command.assignee,
|
||||||
reporter: command.reporter,
|
reporter: command.reporter,
|
||||||
|
observers: command.observers,
|
||||||
|
tags: tags.map(IssueApplicationMapper.toTagRef),
|
||||||
projectId: command.projectId,
|
projectId: command.projectId,
|
||||||
dueDate: command.dueDate,
|
dueDate: command.dueDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.issueParticipants.ensureObserversExist([...draft.observers]);
|
||||||
const issue = await this.issueRepository.create(draft);
|
const issue = await this.issueRepository.create(draft);
|
||||||
const dto = await this.issueUserExpander.expand(issue);
|
const dto = await this.issueUserExpander.expand(issue);
|
||||||
|
|
||||||
if (issue.assignee) {
|
await this.issueParticipants.addToProject(issue);
|
||||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
|
||||||
}
|
|
||||||
|
|
||||||
const domainEvents = draft.pullDomainEvents();
|
const domainEvents = draft.pullDomainEvents();
|
||||||
|
this.issueAssignmentNotifier.notify(issue, domainEvents);
|
||||||
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
|
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
|
||||||
this.issueEventsService.emitIssueCreated(dto);
|
this.issueEventsService.emitIssueCreated(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
import { DeleteIssueUseCase } from './delete-issue.use-case.js';
|
import { DeleteIssueUseCase } from './delete-issue.use-case.js';
|
||||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
@@ -14,8 +15,10 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
|
observers: ['user-5'],
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -32,6 +35,7 @@ describe('DeleteIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -71,4 +75,13 @@ describe('DeleteIssueUseCase', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forbids an observer from deleting the issue', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await expect(useCase.execute(new DeleteIssueCommand(1, 'user-5'))).rejects.toBeInstanceOf(
|
||||||
|
IssueEditForbiddenException,
|
||||||
|
);
|
||||||
|
expect(repository.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
@@ -20,6 +21,10 @@ export class DeleteIssueUseCase {
|
|||||||
throw new IssueNotFoundException(command.id);
|
throw new IssueNotFoundException(command.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!issue.canBeEditedBy(command.userId)) {
|
||||||
|
throw new IssueEditForbiddenException(command.id);
|
||||||
|
}
|
||||||
|
|
||||||
await this.issueRepository.delete(command.id);
|
await this.issueRepository.delete(command.id);
|
||||||
|
|
||||||
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
|
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
|
||||||
|
|||||||
+2
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'user-2',
|
assignee: 'user-2',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -33,6 +34,7 @@ describe('GetIssueByCkeyUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'user-2',
|
assignee: 'user-2',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -33,6 +34,7 @@ describe('GetIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
export class ListCalendarIssuesQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly from: Date,
|
||||||
|
public readonly to: Date,
|
||||||
|
public readonly projectId?: string,
|
||||||
|
public readonly status?: string[],
|
||||||
|
public readonly assigneeId?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
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 { InvalidCalendarRangeException } from '../../../domain/exceptions/invalid-calendar-range.exception.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from './list-calendar-issues.use-case.js';
|
||||||
|
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
|
||||||
|
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
|
|
||||||
|
const FROM = new Date('2026-09-28T00:00:00+03:00');
|
||||||
|
const TO = new Date('2026-11-09T00:00:00+03:00');
|
||||||
|
|
||||||
|
const buildIssue = (id: number) =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id,
|
||||||
|
key: `ISSUE-${id}`,
|
||||||
|
title: `Issue ${id}`,
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
|
assignee: null,
|
||||||
|
reporter: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: new Date('2026-10-15T12:00:00Z'),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListCalendarIssuesUseCase', () => {
|
||||||
|
let repository: IIssueRepository;
|
||||||
|
let issueUserExpander: IssueUserExpanderService;
|
||||||
|
let useCase: ListCalendarIssuesUseCase;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
issueUserExpander = {
|
||||||
|
expandAll: vi.fn(async (issues: Issue[]) => issues.map((issue) => ({ id: issue.id }) as IssueWithUsersDto)),
|
||||||
|
} as unknown as IssueUserExpanderService;
|
||||||
|
useCase = new ListCalendarIssuesUseCase(repository, issueUserExpander);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the range and filters, asking for one issue more than the limit', async () => {
|
||||||
|
vi.mocked(repository.findByDueDateRange).mockResolvedValue([buildIssue(1), buildIssue(2)]);
|
||||||
|
|
||||||
|
const result = await useCase.execute(
|
||||||
|
new ListCalendarIssuesQuery('user-1', FROM, TO, 'project-1', ['OPEN'], 'user-2'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repository.findByDueDateRange).toHaveBeenCalledWith({
|
||||||
|
from: FROM,
|
||||||
|
to: TO,
|
||||||
|
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: ['OPEN'],
|
||||||
|
assigneeId: 'user-2',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ list: [{ id: 1 }, { id: 2 }], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts the list to the limit and marks it as truncated', async () => {
|
||||||
|
const issues = Array.from({ length: ListCalendarIssuesUseCase.MAX_ISSUES + 1 }, (_, index) =>
|
||||||
|
buildIssue(index + 1),
|
||||||
|
);
|
||||||
|
vi.mocked(repository.findByDueDateRange).mockResolvedValue(issues);
|
||||||
|
|
||||||
|
const result = await useCase.execute(new ListCalendarIssuesQuery('user-1', FROM, TO));
|
||||||
|
|
||||||
|
expect(result.truncated).toBe(true);
|
||||||
|
expect(result.list).toHaveLength(ListCalendarIssuesUseCase.MAX_ISSUES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid range without querying the repository', async () => {
|
||||||
|
await expect(useCase.execute(new ListCalendarIssuesQuery('user-1', TO, FROM))).rejects.toThrow(
|
||||||
|
InvalidCalendarRangeException,
|
||||||
|
);
|
||||||
|
expect(repository.findByDueDateRange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
import { CalendarRange } from '../../../domain/value-objects/calendar-range.vo.js';
|
||||||
|
import { CalendarIssuesDto } from '../../dto/calendar-issues.dto.js';
|
||||||
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Задачи для календаря: все задачи с dueDate в [from, to) без пагинации,
|
||||||
|
* но не больше MAX_ISSUES — иначе ответ помечается как truncated.
|
||||||
|
* Задачи без dueDate в календарь не попадают.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ListCalendarIssuesUseCase {
|
||||||
|
static readonly MAX_ISSUES = 500;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUE_REPOSITORY)
|
||||||
|
private readonly issueRepository: IIssueRepository,
|
||||||
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ListCalendarIssuesQuery): Promise<CalendarIssuesDto> {
|
||||||
|
const range = CalendarRange.create({ from: query.from, to: query.to });
|
||||||
|
|
||||||
|
// Берём на одну больше лимита, чтобы узнать, есть ли что-то за ним
|
||||||
|
const issues = await this.issueRepository.findByDueDateRange({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
|
||||||
|
userId: query.userId,
|
||||||
|
projectId: query.projectId,
|
||||||
|
status: query.status,
|
||||||
|
assigneeId: query.assigneeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncated = issues.length > ListCalendarIssuesUseCase.MAX_ISSUES;
|
||||||
|
const list = truncated ? issues.slice(0, ListCalendarIssuesUseCase.MAX_ISSUES) : issues;
|
||||||
|
|
||||||
|
return { list: await this.issueUserExpander.expandAll(list), truncated };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { IssueUserRole } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
|
||||||
export class ListIssuesQuery {
|
export class ListIssuesQuery {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly limit: number,
|
public readonly limit: number,
|
||||||
@@ -7,5 +9,8 @@ export class ListIssuesQuery {
|
|||||||
public readonly status?: string[],
|
public readonly status?: string[],
|
||||||
public readonly text?: string,
|
public readonly text?: string,
|
||||||
public readonly assigneeId?: string,
|
public readonly assigneeId?: string,
|
||||||
|
public readonly hasDueDate?: boolean,
|
||||||
|
public readonly type?: string[],
|
||||||
|
public readonly role?: IssueUserRole,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ const buildIssue = (id: number) =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -32,6 +33,7 @@ describe('ListIssuesUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -61,6 +63,20 @@ describe('ListIssuesUseCase', () => {
|
|||||||
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
|
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards the hasDueDate filter', async () => {
|
||||||
|
vi.mocked(repository.findAll).mockResolvedValue({
|
||||||
|
list: [],
|
||||||
|
meta: { totalCount: 0, page: 1, totalPages: 0 },
|
||||||
|
});
|
||||||
|
vi.mocked(issueUserExpander.expandAll).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await useCase.execute(new ListIssuesQuery(20, 1, 'user-1', 'project-1', undefined, undefined, undefined, false));
|
||||||
|
|
||||||
|
expect(repository.findAll).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ projectId: 'project-1', hasDueDate: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an empty array when there are no issues', async () => {
|
it('returns an empty array when there are no issues', async () => {
|
||||||
vi.mocked(repository.findAll).mockResolvedValue({
|
vi.mocked(repository.findAll).mockResolvedValue({
|
||||||
list: [],
|
list: [],
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export class ListIssuesUseCase {
|
|||||||
status: query.status,
|
status: query.status,
|
||||||
text: query.text,
|
text: query.text,
|
||||||
assigneeId: query.assigneeId,
|
assigneeId: query.assigneeId,
|
||||||
|
hasDueDate: query.hasDueDate,
|
||||||
|
type: query.type,
|
||||||
|
role: query.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class UnobserveIssueCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly id: number,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,10 @@ export class UpdateIssueCommand {
|
|||||||
public readonly priority?: number,
|
public readonly priority?: number,
|
||||||
public readonly assignee?: string | null,
|
public readonly assignee?: string | null,
|
||||||
public readonly dueDate?: Date | null,
|
public readonly dueDate?: Date | null,
|
||||||
|
public readonly type?: string,
|
||||||
|
/** Полный новый список наблюдателей */
|
||||||
|
public readonly observers?: string[],
|
||||||
|
/** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются */
|
||||||
|
public readonly tags?: string[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+190
-3
@@ -1,15 +1,27 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
import 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 type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
|
import { IssueObserverNotFoundException } from '../../../domain/exceptions/issue-observer-not-found.exception.js';
|
||||||
import { UpdateIssueUseCase } from './update-issue.use-case.js';
|
import { UpdateIssueUseCase } from './update-issue.use-case.js';
|
||||||
import { UpdateIssueCommand } from './update-issue.command.js';
|
import { UpdateIssueCommand } from './update-issue.command.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
|
import 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({
|
Issue.reconstitute({
|
||||||
id: 1,
|
id: 1,
|
||||||
key: 'ISSUE-1',
|
key: 'ISSUE-1',
|
||||||
@@ -17,12 +29,14 @@ const buildIssue = () =>
|
|||||||
content: 'Original content',
|
content: 'Original content',
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('UpdateIssueUseCase', () => {
|
describe('UpdateIssueUseCase', () => {
|
||||||
@@ -30,6 +44,9 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
let projectRepository: IProjectRepository;
|
let projectRepository: IProjectRepository;
|
||||||
let issueEventsService: IssueEventsService;
|
let issueEventsService: IssueEventsService;
|
||||||
let issueUserExpander: IssueUserExpanderService;
|
let issueUserExpander: IssueUserExpanderService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
let getUsersUseCase: GetUsersUseCase;
|
||||||
|
let tagRepository: ITagRepository;
|
||||||
let useCase: UpdateIssueUseCase;
|
let useCase: UpdateIssueUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,6 +54,7 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
@@ -48,6 +66,7 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
|
findRole: vi.fn(),
|
||||||
addMember: vi.fn(),
|
addMember: vi.fn(),
|
||||||
};
|
};
|
||||||
issueEventsService = new IssueEventsService();
|
issueEventsService = new IssueEventsService();
|
||||||
@@ -58,7 +77,22 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
reporter: null,
|
reporter: null,
|
||||||
})),
|
})),
|
||||||
} as unknown as IssueUserExpanderService;
|
} as unknown as IssueUserExpanderService;
|
||||||
useCase = new UpdateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
|
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,
|
||||||
|
tagRepository,
|
||||||
|
issueEventsService,
|
||||||
|
issueUserExpander,
|
||||||
|
new IssueAssignmentNotifierService(notificationsService),
|
||||||
|
new IssueParticipantsService(projectRepository, getUsersUseCase),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies only the fields provided in the command', async () => {
|
it('applies only the fields provided in the command', async () => {
|
||||||
@@ -73,7 +107,7 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('passes the acting user to the repository as the author of the changes', async () => {
|
it('passes the acting user to the repository as the author of the changes', async () => {
|
||||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ assignee: 'user-7' }));
|
||||||
|
|
||||||
await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
|
await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
|
||||||
|
|
||||||
@@ -114,4 +148,157 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
|
|
||||||
expect(received).toEqual([dto]);
|
expect(received).toEqual([dto]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('notifies the new assignee', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-9'));
|
||||||
|
|
||||||
|
expect(notificationsService.notify).toHaveBeenCalledWith({
|
||||||
|
userId: 'user-9',
|
||||||
|
subject: 'You have been assigned to ISSUE-1',
|
||||||
|
content: 'Original title',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies the user who assigns the issue to themselves', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-1'));
|
||||||
|
|
||||||
|
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify on unassignment or other field changes', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, null));
|
||||||
|
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
|
||||||
|
|
||||||
|
expect(notificationsService.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the issue type', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
const dto = await useCase.execute(
|
||||||
|
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, undefined, undefined, 'BUG'),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,25 +1,28 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
import {
|
|
||||||
PROJECT_REPOSITORY,
|
|
||||||
type IProjectRepository,
|
|
||||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
|
||||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
import { UpdateIssueCommand } from './update-issue.command.js';
|
import { UpdateIssueCommand } from './update-issue.command.js';
|
||||||
|
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
|
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
|
||||||
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
||||||
|
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UpdateIssueUseCase {
|
export class UpdateIssueUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ISSUE_REPOSITORY)
|
@Inject(ISSUE_REPOSITORY)
|
||||||
private readonly issueRepository: IIssueRepository,
|
private readonly issueRepository: IIssueRepository,
|
||||||
@Inject(PROJECT_REPOSITORY)
|
@Inject(TAG_REPOSITORY)
|
||||||
private readonly projectRepository: IProjectRepository,
|
private readonly tagRepository: ITagRepository,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
private readonly issueUserExpander: IssueUserExpanderService,
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||||
|
private readonly issueParticipants: IssueParticipantsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
||||||
@@ -29,6 +32,12 @@ export class UpdateIssueUseCase {
|
|||||||
throw new IssueNotFoundException(command.id);
|
throw new IssueNotFoundException(command.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!issue.canBeEditedBy(command.userId)) {
|
||||||
|
throw new IssueEditForbiddenException(command.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observersBefore = [...issue.observers];
|
||||||
|
|
||||||
if (command.title !== undefined) {
|
if (command.title !== undefined) {
|
||||||
issue.updateTitle(command.title);
|
issue.updateTitle(command.title);
|
||||||
}
|
}
|
||||||
@@ -45,6 +54,10 @@ export class UpdateIssueUseCase {
|
|||||||
issue.changePriority(command.priority);
|
issue.changePriority(command.priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (command.type !== undefined) {
|
||||||
|
issue.changeType(command.type);
|
||||||
|
}
|
||||||
|
|
||||||
if (command.assignee !== undefined) {
|
if (command.assignee !== undefined) {
|
||||||
if (command.assignee === null || command.assignee === '') {
|
if (command.assignee === null || command.assignee === '') {
|
||||||
issue.unassign();
|
issue.unassign();
|
||||||
@@ -57,14 +70,27 @@ export class UpdateIssueUseCase {
|
|||||||
issue.updateDueDate(command.dueDate);
|
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);
|
await this.issueRepository.update(issue, command.userId);
|
||||||
const dto = await this.issueUserExpander.expand(issue);
|
const dto = await this.issueUserExpander.expand(issue);
|
||||||
|
|
||||||
if (issue.assignee) {
|
await this.issueParticipants.addToProject(issue);
|
||||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
|
||||||
}
|
|
||||||
|
|
||||||
const domainEvents = issue.pullDomainEvents();
|
const domainEvents = issue.pullDomainEvents();
|
||||||
|
this.issueAssignmentNotifier.notify(issue, domainEvents);
|
||||||
if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) {
|
if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) {
|
||||||
this.issueEventsService.emitIssueUpdated(dto);
|
this.issueEventsService.emitIssueUpdated(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ export const ISSUE_CHANGE_FIELDS = [
|
|||||||
'content',
|
'content',
|
||||||
'status',
|
'status',
|
||||||
'priority',
|
'priority',
|
||||||
|
'type',
|
||||||
'assignee',
|
'assignee',
|
||||||
'dueDate',
|
'dueDate',
|
||||||
|
'observer',
|
||||||
|
'tag',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
||||||
@@ -17,6 +20,8 @@ export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
|||||||
/**
|
/**
|
||||||
* Изменение одного поля задачи. Значения сериализованы в строки:
|
* Изменение одного поля задачи. Значения сериализованы в строки:
|
||||||
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
* observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id)
|
||||||
|
* или снят (oldValue = id, newValue = null). tag — так же, по строке на тег.
|
||||||
*/
|
*/
|
||||||
export interface IssueFieldChange {
|
export interface IssueFieldChange {
|
||||||
field: IssueChangeField;
|
field: IssueChangeField;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { Issue } from './issue.entity.js';
|
import { Issue } from './issue.entity.js';
|
||||||
import { IssueCreatedEvent } from '../events/issue-created.event.js';
|
import { IssueCreatedEvent } from '../events/issue-created.event.js';
|
||||||
|
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
|
||||||
|
import { IssueObserverAddedEvent } from '../events/issue-observer-added.event.js';
|
||||||
|
|
||||||
describe('Issue', () => {
|
describe('Issue', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
@@ -18,6 +20,7 @@ describe('Issue', () => {
|
|||||||
expect(issue.content).toBeNull();
|
expect(issue.content).toBeNull();
|
||||||
expect(issue.status).toBe('OPEN');
|
expect(issue.status).toBe('OPEN');
|
||||||
expect(issue.priority).toBe(0);
|
expect(issue.priority).toBe(0);
|
||||||
|
expect(issue.type).toBe('TASK');
|
||||||
expect(issue.assignee).toBeNull();
|
expect(issue.assignee).toBeNull();
|
||||||
expect(issue.reporter).toBe('user-1');
|
expect(issue.reporter).toBe('user-1');
|
||||||
});
|
});
|
||||||
@@ -30,11 +33,13 @@ describe('Issue', () => {
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
content: 'Details here',
|
content: 'Details here',
|
||||||
priority: 3,
|
priority: 3,
|
||||||
|
type: 'BUG',
|
||||||
assignee: 'user-2',
|
assignee: 'user-2',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(issue.content).toBe('Details here');
|
expect(issue.content).toBe('Details here');
|
||||||
expect(issue.priority).toBe(3);
|
expect(issue.priority).toBe(3);
|
||||||
|
expect(issue.type).toBe('BUG');
|
||||||
expect(issue.assignee).toBe('user-2');
|
expect(issue.assignee).toBe('user-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@ describe('Issue', () => {
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'IN_PROGRESS',
|
status: 'IN_PROGRESS',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'user-3',
|
assignee: 'user-3',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -92,6 +98,7 @@ describe('Issue', () => {
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -138,6 +145,15 @@ describe('Issue', () => {
|
|||||||
expect(() => issue.changePriority(10)).toThrow();
|
expect(() => issue.changePriority(10)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('changes the type through the value object rules', () => {
|
||||||
|
const issue = build();
|
||||||
|
|
||||||
|
issue.changeType('FEATURE');
|
||||||
|
expect(issue.type).toBe('FEATURE');
|
||||||
|
|
||||||
|
expect(() => issue.changeType('EPIC')).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('assigns and unassigns an issue', () => {
|
it('assigns and unassigns an issue', () => {
|
||||||
const issue = build();
|
const issue = build();
|
||||||
|
|
||||||
@@ -147,6 +163,16 @@ describe('Issue', () => {
|
|||||||
issue.unassign();
|
issue.unassign();
|
||||||
expect(issue.assignee).toBeNull();
|
expect(issue.assignee).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('raises IssueAssignedEvent only when the assignee actually changes', () => {
|
||||||
|
const issue = build();
|
||||||
|
|
||||||
|
issue.assignTo('user-9');
|
||||||
|
issue.assignTo('user-9');
|
||||||
|
|
||||||
|
const assigned = issue.pullDomainEvents().filter((event) => event instanceof IssueAssignedEvent);
|
||||||
|
expect(assigned).toEqual([expect.objectContaining({ issueKey: issue.key, assignee: 'user-9' })]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('change tracking', () => {
|
describe('change tracking', () => {
|
||||||
@@ -158,6 +184,7 @@ describe('Issue', () => {
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -182,6 +209,7 @@ describe('Issue', () => {
|
|||||||
issue.updateContent('Text');
|
issue.updateContent('Text');
|
||||||
issue.changeStatus('IN_PROGRESS');
|
issue.changeStatus('IN_PROGRESS');
|
||||||
issue.changePriority(3);
|
issue.changePriority(3);
|
||||||
|
issue.changeType('BUG');
|
||||||
issue.assignTo('user-2');
|
issue.assignTo('user-2');
|
||||||
issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z'));
|
issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z'));
|
||||||
|
|
||||||
@@ -190,6 +218,7 @@ describe('Issue', () => {
|
|||||||
{ field: 'content', oldValue: null, newValue: 'Text' },
|
{ field: 'content', oldValue: null, newValue: 'Text' },
|
||||||
{ field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' },
|
{ field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' },
|
||||||
{ field: 'priority', oldValue: '0', newValue: '3' },
|
{ field: 'priority', oldValue: '0', newValue: '3' },
|
||||||
|
{ field: 'type', oldValue: 'TASK', newValue: 'BUG' },
|
||||||
{ field: 'assignee', oldValue: null, newValue: 'user-2' },
|
{ field: 'assignee', oldValue: null, newValue: 'user-2' },
|
||||||
{ field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' },
|
{ field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
@@ -220,4 +249,175 @@ describe('Issue', () => {
|
|||||||
expect(issue.pullChanges()).toEqual([{ field: 'status', oldValue: 'OPEN', newValue: 'DONE' }]);
|
expect(issue.pullChanges()).toEqual([{ field: 'status', oldValue: 'OPEN', newValue: 'DONE' }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('observers', () => {
|
||||||
|
const load = (observers: string[] = []) =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id: 1,
|
||||||
|
key: 'ISSUE-1',
|
||||||
|
title: 'Title',
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
|
assignee: 'user-2',
|
||||||
|
reporter: 'user-1',
|
||||||
|
observers,
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only the reporter and the assignee can edit the issue', () => {
|
||||||
|
const issue = load(['user-3']);
|
||||||
|
|
||||||
|
expect(issue.canBeEditedBy('user-1')).toBe(true);
|
||||||
|
expect(issue.canBeEditedBy('user-2')).toBe(true);
|
||||||
|
expect(issue.canBeEditedBy('user-3')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates an issue with observers, skipping duplicates, the reporter and the assignee', () => {
|
||||||
|
const issue = Issue.create({
|
||||||
|
key: 'ISSUE-1',
|
||||||
|
title: 'Title',
|
||||||
|
reporter: 'user-1',
|
||||||
|
assignee: 'user-2',
|
||||||
|
observers: ['user-3', 'user-1', 'user-2', 'user-3', 'user-4'],
|
||||||
|
projectId: 'project-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issue.observers).toEqual(['user-3', 'user-4']);
|
||||||
|
expect(issue.pullDomainEvents().filter((event) => event instanceof IssueObserverAddedEvent)).toEqual([
|
||||||
|
expect.objectContaining({ observer: 'user-3' }),
|
||||||
|
expect.objectContaining({ observer: 'user-4' }),
|
||||||
|
]);
|
||||||
|
expect(issue.pullChanges().map((change) => change.field)).toEqual(['created', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records one history row per added or removed observer', () => {
|
||||||
|
const issue = load(['user-3', 'user-4']);
|
||||||
|
|
||||||
|
issue.setObservers(['user-4', 'user-5']);
|
||||||
|
|
||||||
|
expect(issue.observers).toEqual(['user-4', 'user-5']);
|
||||||
|
expect(issue.pullChanges()).toEqual([
|
||||||
|
{ field: 'observer', oldValue: 'user-3', newValue: null },
|
||||||
|
{ field: 'observer', oldValue: null, newValue: 'user-5' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the list is unchanged', () => {
|
||||||
|
const issue = load(['user-3']);
|
||||||
|
|
||||||
|
issue.setObservers(['user-3', 'user-1']);
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([]);
|
||||||
|
expect(issue.pullDomainEvents()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels out adding and removing the same observer', () => {
|
||||||
|
const issue = load();
|
||||||
|
|
||||||
|
issue.addObserver('user-3');
|
||||||
|
issue.removeObserver('user-3');
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the observer who becomes the assignee', () => {
|
||||||
|
const issue = load(['user-3']);
|
||||||
|
|
||||||
|
issue.assignTo('user-3');
|
||||||
|
|
||||||
|
expect(issue.observers).toEqual([]);
|
||||||
|
expect(issue.pullChanges()).toEqual([
|
||||||
|
{ field: 'assignee', oldValue: 'user-2', newValue: 'user-3' },
|
||||||
|
{ field: 'observer', oldValue: 'user-3', newValue: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { IssueTitle } from '../value-objects/issue-title.vo.js';
|
import { IssueTitle } from '../value-objects/issue-title.vo.js';
|
||||||
import { IssueStatus } from '../value-objects/issue-status.vo.js';
|
import { IssueStatus } from '../value-objects/issue-status.vo.js';
|
||||||
import { IssuePriority } from '../value-objects/issue-priority.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 { IssueCreatedEvent } from '../events/issue-created.event.js';
|
||||||
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
|
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
|
||||||
|
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
|
||||||
|
import { IssueObserverAddedEvent } from '../events/issue-observer-added.event.js';
|
||||||
import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
|
import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
|
||||||
|
|
||||||
interface IssueProps {
|
interface IssueProps {
|
||||||
@@ -12,9 +16,13 @@ interface IssueProps {
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
status: IssueStatus;
|
status: IssueStatus;
|
||||||
priority: IssuePriority;
|
priority: IssuePriority;
|
||||||
|
type: IssueType;
|
||||||
assignee: string | null;
|
assignee: string | null;
|
||||||
/** null — автор задачи удалил аккаунт */
|
/** null — автор задачи удалил аккаунт */
|
||||||
reporter: string | null;
|
reporter: string | null;
|
||||||
|
/** Видят задачу и комментируют, но не редактируют. Не пересекаются с reporter/assignee. */
|
||||||
|
observers: string[];
|
||||||
|
tags: IssueTags;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -34,8 +42,11 @@ interface IssueProps {
|
|||||||
*/
|
*/
|
||||||
export class Issue {
|
export class Issue {
|
||||||
private domainEvents: unknown[] = [];
|
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) {}
|
private constructor(private readonly props: IssueProps) {}
|
||||||
|
|
||||||
@@ -45,8 +56,11 @@ export class Issue {
|
|||||||
title: string;
|
title: string;
|
||||||
content?: string | null;
|
content?: string | null;
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
type?: string;
|
||||||
assignee?: string | null;
|
assignee?: string | null;
|
||||||
reporter: string;
|
reporter: string;
|
||||||
|
observers?: string[];
|
||||||
|
tags?: IssueTagRef[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
dueDate?: Date | null;
|
dueDate?: Date | null;
|
||||||
}): Issue {
|
}): Issue {
|
||||||
@@ -57,8 +71,12 @@ export class Issue {
|
|||||||
content: params.content ?? null,
|
content: params.content ?? null,
|
||||||
status: IssueStatus.open(),
|
status: IssueStatus.open(),
|
||||||
priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(),
|
priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(),
|
||||||
|
type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(),
|
||||||
assignee: params.assignee ?? null,
|
assignee: params.assignee ?? null,
|
||||||
reporter: params.reporter,
|
reporter: params.reporter,
|
||||||
|
observers: [],
|
||||||
|
// Как и наблюдатели, теги при создании в историю не пишутся
|
||||||
|
tags: IssueTags.create(params.tags ?? []),
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
dueDate: params.dueDate ?? null,
|
dueDate: params.dueDate ?? null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -68,6 +86,14 @@ export class Issue {
|
|||||||
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
|
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
|
||||||
issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status });
|
issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status });
|
||||||
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
|
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
|
||||||
|
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;
|
return issue;
|
||||||
}
|
}
|
||||||
@@ -80,8 +106,11 @@ export class Issue {
|
|||||||
content: string | null;
|
content: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
type: string;
|
||||||
assignee: string | null;
|
assignee: string | null;
|
||||||
reporter: string | null;
|
reporter: string | null;
|
||||||
|
observers?: string[];
|
||||||
|
tags?: IssueTagRef[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
dueDate: Date | null;
|
dueDate: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -94,8 +123,11 @@ export class Issue {
|
|||||||
content: props.content,
|
content: props.content,
|
||||||
status: IssueStatus.create(props.status),
|
status: IssueStatus.create(props.status),
|
||||||
priority: IssuePriority.create(props.priority),
|
priority: IssuePriority.create(props.priority),
|
||||||
|
type: IssueType.create(props.type),
|
||||||
assignee: props.assignee,
|
assignee: props.assignee,
|
||||||
reporter: props.reporter,
|
reporter: props.reporter,
|
||||||
|
observers: [...(props.observers ?? [])],
|
||||||
|
tags: IssueTags.create(props.tags ?? []),
|
||||||
projectId: props.projectId,
|
projectId: props.projectId,
|
||||||
dueDate: props.dueDate,
|
dueDate: props.dueDate,
|
||||||
createdAt: props.createdAt,
|
createdAt: props.createdAt,
|
||||||
@@ -127,6 +159,10 @@ export class Issue {
|
|||||||
return this.props.priority.value;
|
return this.props.priority.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get type(): string {
|
||||||
|
return this.props.type.value;
|
||||||
|
}
|
||||||
|
|
||||||
get assignee(): string | null {
|
get assignee(): string | null {
|
||||||
return this.props.assignee;
|
return this.props.assignee;
|
||||||
}
|
}
|
||||||
@@ -135,6 +171,19 @@ export class Issue {
|
|||||||
return this.props.reporter;
|
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 {
|
get projectId(): string {
|
||||||
return this.props.projectId;
|
return this.props.projectId;
|
||||||
}
|
}
|
||||||
@@ -174,9 +223,20 @@ export class Issue {
|
|||||||
this.props.priority = next;
|
this.props.priority = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeType(type: string): void {
|
||||||
|
const next = IssueType.create(type);
|
||||||
|
this.recordChange('type', this.type, next.value);
|
||||||
|
this.props.type = next;
|
||||||
|
}
|
||||||
|
|
||||||
assignTo(userId: string): void {
|
assignTo(userId: string): void {
|
||||||
|
if (this.assignee === userId) return;
|
||||||
|
|
||||||
this.recordChange('assignee', this.assignee, userId);
|
this.recordChange('assignee', this.assignee, userId);
|
||||||
this.props.assignee = userId;
|
this.props.assignee = userId;
|
||||||
|
this.addDomainEvent(new IssueAssignedEvent(this.key, userId));
|
||||||
|
// Исполнитель и так видит задачу и может её редактировать
|
||||||
|
this.removeObserver(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
unassign(): void {
|
unassign(): void {
|
||||||
@@ -189,6 +249,50 @@ export class Issue {
|
|||||||
this.props.dueDate = dueDate;
|
this.props.dueDate = dueDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заменяет список наблюдателей. reporter и assignee отбрасываются:
|
||||||
|
* у них и так больше прав, чем у наблюдателя.
|
||||||
|
*/
|
||||||
|
setObservers(userIds: string[]): void {
|
||||||
|
const next = this.normalizeObservers(userIds);
|
||||||
|
for (const userId of this.observers.filter((id) => !next.includes(id))) {
|
||||||
|
this.removeObserver(userId);
|
||||||
|
}
|
||||||
|
for (const userId of next) {
|
||||||
|
this.addObserver(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addObserver(userId: string): void {
|
||||||
|
if (this.observers.includes(userId) || this.normalizeObservers([userId]).length === 0) return;
|
||||||
|
|
||||||
|
this.recordChange('observer', null, userId, `observer:${userId}`);
|
||||||
|
this.props.observers.push(userId);
|
||||||
|
this.addDomainEvent(new IssueObserverAddedEvent(this.key, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeObserver(userId: string): void {
|
||||||
|
if (!this.observers.includes(userId)) return;
|
||||||
|
|
||||||
|
this.recordChange('observer', userId, null, `observer:${userId}`);
|
||||||
|
this.props.observers = this.props.observers.filter((id) => id !== userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заменяет теги задачи — теги справочника её проекта (их находит/создаёт 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[] {
|
pullChanges(): IssueFieldChange[] {
|
||||||
const changes = [...this.changes.values()];
|
const changes = [...this.changes.values()];
|
||||||
@@ -201,14 +305,19 @@ export class Issue {
|
|||||||
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
|
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
|
||||||
* сливается с предыдущей, а возврат к исходному значению её отменяет.
|
* сливается с предыдущей, а возврат к исходному значению её отменяет.
|
||||||
*/
|
*/
|
||||||
private recordChange(field: IssueChangeField, oldValue: string | null, newValue: string | null): void {
|
private recordChange(
|
||||||
|
field: IssueChangeField,
|
||||||
|
oldValue: string | null,
|
||||||
|
newValue: string | null,
|
||||||
|
key: string = field,
|
||||||
|
): void {
|
||||||
if (oldValue === newValue) return;
|
if (oldValue === newValue) return;
|
||||||
|
|
||||||
const pending = this.changes.get(field);
|
const pending = this.changes.get(key);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
this.changes.set(field, { field, oldValue, newValue });
|
this.changes.set(key, { field, oldValue, newValue });
|
||||||
} else if (pending.oldValue === newValue) {
|
} else if (pending.oldValue === newValue) {
|
||||||
this.changes.delete(field);
|
this.changes.delete(key);
|
||||||
} else {
|
} else {
|
||||||
pending.newValue = newValue;
|
pending.newValue = newValue;
|
||||||
}
|
}
|
||||||
@@ -216,6 +325,11 @@ export class Issue {
|
|||||||
this.touch();
|
this.touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Уникальные id без reporter/assignee, в исходном порядке */
|
||||||
|
private normalizeObservers(userIds: string[]): string[] {
|
||||||
|
return [...new Set(userIds)].filter((id) => id !== '' && id !== this.reporter && id !== this.assignee);
|
||||||
|
}
|
||||||
|
|
||||||
private touch(): void {
|
private touch(): void {
|
||||||
this.props.updatedAt = new Date();
|
this.props.updatedAt = new Date();
|
||||||
this.addDomainEvent(new IssueUpdatedEvent(this.key));
|
this.addDomainEvent(new IssueUpdatedEvent(this.key));
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class IssueAssignedEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly issueKey: string,
|
||||||
|
public readonly assignee: string,
|
||||||
|
public readonly occurredAt: Date = new Date(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
export class InvalidCalendarRangeException extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InvalidCalendarRangeException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Задача видна пользователю (он наблюдатель), но редактировать её он не может */
|
||||||
|
export class IssueEditForbiddenException extends Error {
|
||||||
|
constructor(id: number) {
|
||||||
|
super(`Задача с id=${id} доступна наблюдателю только для чтения`);
|
||||||
|
this.name = 'IssueEditForbiddenException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class IssueObserverNotFoundException extends Error {
|
||||||
|
constructor(userIds: string[]) {
|
||||||
|
super(`Пользователи не найдены: ${userIds.join(', ')}`);
|
||||||
|
this.name = 'IssueObserverNotFoundException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** Проект виден пользователю (он наблюдатель), но создавать в нём задачи он не может */
|
||||||
|
export class IssueProjectForbiddenException extends Error {
|
||||||
|
constructor(projectId: string) {
|
||||||
|
super(`В проекте с id=${projectId} наблюдатель не может создавать задачи`);
|
||||||
|
this.name = 'IssueProjectForbiddenException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,15 @@ import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.in
|
|||||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
export const ISSUE_REPOSITORY = Symbol('ISSUE_REPOSITORY');
|
export const ISSUE_REPOSITORY = Symbol('ISSUE_REPOSITORY');
|
||||||
|
|
||||||
|
/** Роль пользователя в задаче — фильтр списка «мои задачи» */
|
||||||
|
export const ISSUE_USER_ROLES = ['assigned', 'reported', 'observing'] as const;
|
||||||
|
export type IssueUserRole = (typeof ISSUE_USER_ROLES)[number];
|
||||||
|
|
||||||
export interface IIssueRepository {
|
export interface IIssueRepository {
|
||||||
/** Задача видна только пользователю, который является в ней reporter или assignee */
|
/**
|
||||||
|
* Задача видна своим reporter, assignee и наблюдателям. Редактировать её
|
||||||
|
* могут только reporter и assignee — см. Issue.canBeEditedBy.
|
||||||
|
*/
|
||||||
findById(id: number, userId: string): Promise<Issue | null>;
|
findById(id: number, userId: string): Promise<Issue | null>;
|
||||||
findByCKey(ckey: string, userId: string): Promise<Issue | null>;
|
findByCKey(ckey: string, userId: string): Promise<Issue | null>;
|
||||||
findAll(params: {
|
findAll(params: {
|
||||||
@@ -16,11 +23,24 @@ export interface IIssueRepository {
|
|||||||
text?: string;
|
text?: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
hasDueDate?: boolean;
|
||||||
|
type?: string[];
|
||||||
|
role?: IssueUserRole;
|
||||||
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
|
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
|
||||||
|
/** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */
|
||||||
|
findByDueDateRange(params: {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
limit: number;
|
||||||
|
userId: string;
|
||||||
|
status?: string[];
|
||||||
|
assigneeId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<Issue[]>;
|
||||||
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
||||||
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
||||||
create(issue: Issue): Promise<Issue>;
|
create(issue: Issue): Promise<Issue>;
|
||||||
/** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */
|
/** Сохраняет задачу, её наблюдателей и накопленные изменения полей в историю одной транзакцией */
|
||||||
update(issue: Issue, actorId: string): Promise<void>;
|
update(issue: Issue, actorId: string): Promise<void>;
|
||||||
delete(id: number): Promise<void>;
|
delete(id: number): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CalendarRange } from './calendar-range.vo.js';
|
||||||
|
import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js';
|
||||||
|
|
||||||
|
describe('CalendarRange', () => {
|
||||||
|
it('accepts a 6-week month grid', () => {
|
||||||
|
const from = new Date('2026-09-28T00:00:00+03:00');
|
||||||
|
const to = new Date('2026-11-09T00:00:00+03:00');
|
||||||
|
|
||||||
|
const range = CalendarRange.create({ from, to });
|
||||||
|
|
||||||
|
expect(range.from).toBe(from);
|
||||||
|
expect(range.to).toBe(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid date', () => {
|
||||||
|
expect(() => CalendarRange.create({ from: new Date('nope'), to: new Date() })).toThrow(
|
||||||
|
InvalidCalendarRangeException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects from equal to or after to', () => {
|
||||||
|
const date = new Date('2026-10-01T00:00:00Z');
|
||||||
|
|
||||||
|
expect(() => CalendarRange.create({ from: date, to: date })).toThrow(InvalidCalendarRangeException);
|
||||||
|
expect(() => CalendarRange.create({ from: new Date('2026-10-02T00:00:00Z'), to: date })).toThrow(
|
||||||
|
InvalidCalendarRangeException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`rejects a range longer than ${CalendarRange.MAX_DAYS} days`, () => {
|
||||||
|
const from = new Date('2026-10-01T00:00:00Z');
|
||||||
|
const to = new Date(from.getTime() + (CalendarRange.MAX_DAYS * 24 * 60 * 60 * 1000 + 1));
|
||||||
|
|
||||||
|
expect(() => CalendarRange.create({ from, to })).toThrow(InvalidCalendarRangeException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Диапазон календаря по dueDate — полуоткрытый интервал [from, to).
|
||||||
|
* Границы — моменты времени: клиент сам считает начало/конец видимой сетки
|
||||||
|
* в своём часовом поясе, сервер про месяцы и часовые пояса не знает.
|
||||||
|
*/
|
||||||
|
export class CalendarRange {
|
||||||
|
/** Сетка месяца — 6 недель (42 дня), плюс запас на переход на летнее/зимнее время */
|
||||||
|
static readonly MAX_DAYS = 45;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
public readonly from: Date,
|
||||||
|
public readonly to: Date,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static create(params: { from: Date; to: Date }): CalendarRange {
|
||||||
|
const { from, to } = params;
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||||
|
throw new InvalidCalendarRangeException('Некорректная дата from или to');
|
||||||
|
}
|
||||||
|
if (from >= to) {
|
||||||
|
throw new InvalidCalendarRangeException('Дата from должна быть раньше даты to');
|
||||||
|
}
|
||||||
|
if (to.getTime() - from.getTime() > CalendarRange.MAX_DAYS * DAY_MS) {
|
||||||
|
throw new InvalidCalendarRangeException(`Диапазон больше ${CalendarRange.MAX_DAYS} дней`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CalendarRange(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { IssueType, ISSUE_TYPES } from './issue-type.vo.js';
|
||||||
|
|
||||||
|
describe('IssueType', () => {
|
||||||
|
it.each(ISSUE_TYPES.map((type) => type.alias))('accepts the valid type "%s"', (alias) => {
|
||||||
|
expect(IssueType.create(alias).value).toBe(alias);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for an unknown type', () => {
|
||||||
|
expect(() => IssueType.create('EPIC')).toThrow(/Недопустимый тип задачи/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to TASK', () => {
|
||||||
|
expect(IssueType.default().value).toBe('TASK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares two types by value', () => {
|
||||||
|
expect(IssueType.create('BUG').equals(IssueType.create('BUG'))).toBe(true);
|
||||||
|
expect(IssueType.create('BUG').equals(IssueType.create('TASK'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export const ISSUE_TYPES = [
|
||||||
|
{ name: 'Task', alias: 'TASK', color: 'primary' },
|
||||||
|
{ name: 'Bug', alias: 'BUG', color: 'error' },
|
||||||
|
{ name: 'Feature', alias: 'FEATURE', color: 'success' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IssueTypeValue = (typeof ISSUE_TYPES)[number]['alias'];
|
||||||
|
|
||||||
|
export const ISSUE_TYPE_ALIASES: readonly IssueTypeValue[] = ISSUE_TYPES.map((type) => type.alias);
|
||||||
|
|
||||||
|
export class IssueType {
|
||||||
|
private constructor(private readonly _value: IssueTypeValue) {}
|
||||||
|
|
||||||
|
static create(value: string): IssueType {
|
||||||
|
if (!ISSUE_TYPE_ALIASES.includes(value as IssueTypeValue)) {
|
||||||
|
throw new Error(`Недопустимый тип задачи: "${value}". Разрешены: ${ISSUE_TYPE_ALIASES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IssueType(value as IssueTypeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
static default(): IssueType {
|
||||||
|
return new IssueType('TASK');
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): IssueTypeValue {
|
||||||
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IssueType): boolean {
|
||||||
|
return this._value === other.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,15 @@ describe('IssuePrismaMapper', () => {
|
|||||||
content: 'Details',
|
content: 'Details',
|
||||||
status: 'IN_PROGRESS',
|
status: 'IN_PROGRESS',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
type: 'BUG',
|
||||||
|
tags: [{ tag: { id: 't1', name: 'backend', color: 'primary' } }],
|
||||||
assigneeId: 'user-2',
|
assigneeId: 'user-2',
|
||||||
reporterId: 'user-1',
|
reporterId: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||||
|
observers: [{ userId: 'user-3' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('maps a Prisma record to a domain entity', () => {
|
it('maps a Prisma record to a domain entity', () => {
|
||||||
@@ -26,7 +29,10 @@ describe('IssuePrismaMapper', () => {
|
|||||||
expect(issue.title).toBe('Fix login bug');
|
expect(issue.title).toBe('Fix login bug');
|
||||||
expect(issue.status).toBe('IN_PROGRESS');
|
expect(issue.status).toBe('IN_PROGRESS');
|
||||||
expect(issue.priority).toBe(2);
|
expect(issue.priority).toBe(2);
|
||||||
|
expect(issue.type).toBe('BUG');
|
||||||
expect(issue.assignee).toBe('user-2');
|
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', () => {
|
it('maps a new domain entity to Prisma create input', () => {
|
||||||
@@ -34,6 +40,7 @@ describe('IssuePrismaMapper', () => {
|
|||||||
key: 'ISSUE-6',
|
key: 'ISSUE-6',
|
||||||
title: 'New issue',
|
title: 'New issue',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
|
tags: [{ id: 't2', name: 'ui', color: 'default' }],
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,8 +52,10 @@ describe('IssuePrismaMapper', () => {
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
reporterId: 'user-1',
|
reporterId: 'user-1',
|
||||||
|
tags: { create: [{ tagId: 't2' }] },
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -61,10 +70,13 @@ describe('IssuePrismaMapper', () => {
|
|||||||
title: 'Updated title',
|
title: 'Updated title',
|
||||||
status: 'IN_PROGRESS',
|
status: 'IN_PROGRESS',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
type: 'BUG',
|
||||||
assigneeId: 'user-2',
|
assigneeId: 'user-2',
|
||||||
});
|
});
|
||||||
expect(input).not.toHaveProperty('id');
|
expect(input).not.toHaveProperty('id');
|
||||||
expect(input).not.toHaveProperty('key');
|
expect(input).not.toHaveProperty('key');
|
||||||
expect(input).not.toHaveProperty('createdAt');
|
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 { Issue } from '../../../../domain/entities/issue.entity.js';
|
||||||
import { getCKey } from '../../../../domain/helpers/get-ckey.js';
|
import { getCKey } from '../../../../domain/helpers/get-ckey.js';
|
||||||
|
|
||||||
|
/** Запись задачи вместе с id наблюдателей и тегами (см. WITH_RELATIONS в репозитории) */
|
||||||
|
export type PrismaIssueWithRelations = PrismaIssue & {
|
||||||
|
observers: Array<{ userId: string }>;
|
||||||
|
tags: Array<{ tag: { id: string; name: string; color: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
export class IssuePrismaMapper {
|
export class IssuePrismaMapper {
|
||||||
static toDomain(record: PrismaIssue): Issue {
|
static toDomain(record: PrismaIssueWithRelations): Issue {
|
||||||
return Issue.reconstitute({
|
return Issue.reconstitute({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
key: getCKey(record.id, record.key),
|
key: getCKey(record.id, record.key),
|
||||||
@@ -11,8 +17,11 @@ export class IssuePrismaMapper {
|
|||||||
content: record.content,
|
content: record.content,
|
||||||
status: record.status,
|
status: record.status,
|
||||||
priority: record.priority,
|
priority: record.priority,
|
||||||
|
type: record.type,
|
||||||
assignee: record.assigneeId,
|
assignee: record.assigneeId,
|
||||||
reporter: record.reporterId,
|
reporter: record.reporterId,
|
||||||
|
observers: record.observers.map((observer) => observer.userId),
|
||||||
|
tags: record.tags.map(({ tag }) => tag),
|
||||||
projectId: record.projectId,
|
projectId: record.projectId,
|
||||||
dueDate: record.dueDate,
|
dueDate: record.dueDate,
|
||||||
createdAt: record.createdAt,
|
createdAt: record.createdAt,
|
||||||
@@ -27,8 +36,11 @@ export class IssuePrismaMapper {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assigneeId: issue.assignee,
|
assigneeId: issue.assignee,
|
||||||
reporterId: issue.reporter,
|
reporterId: issue.reporter,
|
||||||
|
observers: { create: issue.observers.map((userId) => ({ userId })) },
|
||||||
|
tags: { create: issue.tags.map((tag) => ({ tagId: tag.id })) },
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
createdAt: issue.createdAt,
|
createdAt: issue.createdAt,
|
||||||
@@ -42,6 +54,7 @@ export class IssuePrismaMapper {
|
|||||||
content: issue.content,
|
content: issue.content,
|
||||||
status: issue.status,
|
status: issue.status,
|
||||||
priority: issue.priority,
|
priority: issue.priority,
|
||||||
|
type: issue.type,
|
||||||
assigneeId: issue.assignee,
|
assigneeId: issue.assignee,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
|
|||||||
+96
-11
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||||
import { Issue } from '../../../../domain/entities/issue.entity.js';
|
import { Issue } from '../../../../domain/entities/issue.entity.js';
|
||||||
import { IIssueRepository } from '../../../../domain/repositories/issue.repository.interface.js';
|
import { IIssueRepository, IssueUserRole } from '../../../../domain/repositories/issue.repository.interface.js';
|
||||||
import { IssuePrismaMapper } from '../mappers/issue.mapper.js';
|
import { IssuePrismaMapper } from '../mappers/issue.mapper.js';
|
||||||
import { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.js';
|
import { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.js';
|
||||||
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
|
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
|
||||||
@@ -13,9 +13,46 @@ import type { IssueFieldChange } from '../../../../domain/entities/issue-change.
|
|||||||
|
|
||||||
const FOREIGN_KEY_VIOLATION = 'P2003';
|
const FOREIGN_KEY_VIOLATION = 'P2003';
|
||||||
|
|
||||||
/** Задача видна только своему reporter/assignee */
|
/** id наблюдателей и теги подгружаются вместе с задачей — они часть сущности */
|
||||||
function ownedByUser(userId: string) {
|
const WITH_RELATIONS = {
|
||||||
return [{ assigneeId: userId }, { reporterId: userId }];
|
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 } } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Видимость и фильтры, общие для списка и календаря */
|
||||||
|
function commonFilters(params: {
|
||||||
|
userId: string;
|
||||||
|
status?: string[];
|
||||||
|
assigneeId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
role?: IssueUserRole;
|
||||||
|
}): Prisma.IssueWhereInput[] {
|
||||||
|
return [
|
||||||
|
{ 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 }] : []),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Строки истории одного сохранения задачи — с общим changeId */
|
/** Строки истории одного сохранения задачи — с общим changeId */
|
||||||
@@ -35,7 +72,8 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
|
|
||||||
async findById(id: number, userId: string): Promise<Issue | null> {
|
async findById(id: number, userId: string): Promise<Issue | null> {
|
||||||
const record = await this.prisma.issue.findFirst({
|
const record = await this.prisma.issue.findFirst({
|
||||||
where: { id, OR: ownedByUser(userId) },
|
where: { id, OR: visibleTo(userId) },
|
||||||
|
include: WITH_RELATIONS,
|
||||||
});
|
});
|
||||||
return record ? IssuePrismaMapper.toDomain(record) : null;
|
return record ? IssuePrismaMapper.toDomain(record) : null;
|
||||||
}
|
}
|
||||||
@@ -43,7 +81,8 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
async findByCKey(ckey: string, userId: string): Promise<Issue | null> {
|
async findByCKey(ckey: string, userId: string): Promise<Issue | null> {
|
||||||
const { id, key } = splitCKey(ckey);
|
const { id, key } = splitCKey(ckey);
|
||||||
const record = await this.prisma.issue.findFirst({
|
const record = await this.prisma.issue.findFirst({
|
||||||
where: { id, key, OR: ownedByUser(userId) },
|
where: { id, key, OR: visibleTo(userId) },
|
||||||
|
include: WITH_RELATIONS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return record ? IssuePrismaMapper.toDomain(record) : null;
|
return record ? IssuePrismaMapper.toDomain(record) : null;
|
||||||
@@ -57,6 +96,9 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
text?: string;
|
text?: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
hasDueDate?: boolean;
|
||||||
|
type?: string[];
|
||||||
|
role?: IssueUserRole;
|
||||||
}): Promise<{ list: Issue[]; meta: PaginationMeta }> {
|
}): Promise<{ list: Issue[]; meta: PaginationMeta }> {
|
||||||
const page = Math.max(1, params?.page);
|
const page = Math.max(1, params?.page);
|
||||||
const limit = Math.max(1, params?.limit);
|
const limit = Math.max(1, params?.limit);
|
||||||
@@ -64,10 +106,9 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
const queryArgs: IssueFindManyArgs = {
|
const queryArgs: IssueFindManyArgs = {
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
AND: [
|
||||||
{ OR: ownedByUser(params.userId) },
|
...commonFilters(params),
|
||||||
...(params.status?.length ? [{ status: { in: params.status } }] : []),
|
...(params.type?.length ? [{ type: { in: params.type } }] : []),
|
||||||
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []),
|
...(params.hasDueDate !== undefined ? [{ dueDate: params.hasDueDate ? { not: null } : null }] : []),
|
||||||
...(params.projectId ? [{ projectId: params.projectId }] : []),
|
|
||||||
...(params.text
|
...(params.text
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -85,6 +126,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
const [records, totalCount] = await this.prisma.$transaction([
|
const [records, totalCount] = await this.prisma.$transaction([
|
||||||
this.prisma.issue.findMany({
|
this.prisma.issue.findMany({
|
||||||
...queryArgs,
|
...queryArgs,
|
||||||
|
include: WITH_RELATIONS,
|
||||||
take: limit,
|
take: limit,
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -98,10 +140,31 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByDueDateRange(params: {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
limit: number;
|
||||||
|
userId: string;
|
||||||
|
status?: string[];
|
||||||
|
assigneeId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Promise<Issue[]> {
|
||||||
|
const records = await this.prisma.issue.findMany({
|
||||||
|
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' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map(IssuePrismaMapper.toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
|
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
|
||||||
const groups = await this.prisma.issue.groupBy({
|
const groups = await this.prisma.issue.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
where: { AND: [{ OR: ownedByUser(userId) }, ...(projectId ? [{ projectId }] : [])] },
|
where: { AND: [{ OR: visibleTo(userId) }, ...(projectId ? [{ projectId }] : [])] },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +178,7 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
const record = await this.prisma.$transaction(async (tx) => {
|
const record = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.issue.create({
|
const created = await tx.issue.create({
|
||||||
data: IssuePrismaMapper.toCreateInput(issue),
|
data: IssuePrismaMapper.toCreateInput(issue),
|
||||||
|
include: WITH_RELATIONS,
|
||||||
});
|
});
|
||||||
await tx.issueChange.createMany({
|
await tx.issueChange.createMany({
|
||||||
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
|
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
|
||||||
@@ -145,8 +209,29 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const observers = [...issue.observers];
|
||||||
|
const tagIds = issue.tags.map((tag) => tag.id);
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
updateIssue,
|
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({
|
this.prisma.issueChange.createMany({
|
||||||
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
|
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||||
import { ProjectModule } from '../project/project.module.js';
|
import { ProjectModule } from '../project/project.module.js';
|
||||||
import { UserModule } from '../user/user.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 { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
|
||||||
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
|
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
|
||||||
import { IssueController } from './presentation/controllers/issue.controller.js';
|
import { IssueController } from './presentation/controllers/issue.controller.js';
|
||||||
@@ -14,10 +16,14 @@ import { IssueResolver } from './presentation/resolvers/issue.resolver.js';
|
|||||||
import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
|
import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
|
||||||
import { IssueEventsService } from './application/services/issue-events.service.js';
|
import { IssueEventsService } from './application/services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js';
|
||||||
|
import { IssueAssignmentNotifierService } from './application/services/issue-assignment-notifier.service.js';
|
||||||
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
|
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
|
||||||
|
import { UnobserveIssueUseCase } from './application/use-cases/unobserve-issue/unobserve-issue.use-case.js';
|
||||||
|
import { IssueParticipantsService } from './application/services/issue-participants.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ProjectModule, UserModule],
|
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule, TagModule],
|
||||||
controllers: [IssueController],
|
controllers: [IssueController],
|
||||||
providers: [
|
providers: [
|
||||||
CreateIssueUseCase,
|
CreateIssueUseCase,
|
||||||
@@ -27,9 +33,13 @@ import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/ge
|
|||||||
UpdateIssueUseCase,
|
UpdateIssueUseCase,
|
||||||
DeleteIssueUseCase,
|
DeleteIssueUseCase,
|
||||||
GetIssueStatsUseCase,
|
GetIssueStatsUseCase,
|
||||||
|
ListCalendarIssuesUseCase,
|
||||||
|
UnobserveIssueUseCase,
|
||||||
IssueResolver,
|
IssueResolver,
|
||||||
IssueEventsService,
|
IssueEventsService,
|
||||||
IssueUserExpanderService,
|
IssueUserExpanderService,
|
||||||
|
IssueAssignmentNotifierService,
|
||||||
|
IssueParticipantsService,
|
||||||
{
|
{
|
||||||
// Domain видит только интерфейс IIssueRepository,
|
// Domain видит только интерфейс IIssueRepository,
|
||||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -37,6 +39,15 @@ import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-
|
|||||||
import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js';
|
import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js';
|
||||||
import { IssueEventsService } from '../../application/services/issue-events.service.js';
|
import { IssueEventsService } from '../../application/services/issue-events.service.js';
|
||||||
import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
|
import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
|
||||||
|
import { 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')
|
@Controller('issues')
|
||||||
export class IssueController {
|
export class IssueController {
|
||||||
@@ -48,6 +59,8 @@ export class IssueController {
|
|||||||
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
||||||
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
||||||
private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
|
private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
|
||||||
|
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
|
||||||
|
private readonly unobserveIssueUseCase: UnobserveIssueUseCase,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -64,6 +77,9 @@ export class IssueController {
|
|||||||
dto.priority,
|
dto.priority,
|
||||||
dto.assignee,
|
dto.assignee,
|
||||||
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
|
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
|
||||||
|
dto.type,
|
||||||
|
dto.observers,
|
||||||
|
dto.tags,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -80,6 +96,26 @@ export class IssueController {
|
|||||||
query.status,
|
query.status,
|
||||||
query.text,
|
query.text,
|
||||||
query.assigneeId,
|
query.assigneeId,
|
||||||
|
query.hasDueDate,
|
||||||
|
query.type,
|
||||||
|
query.role,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задачи с dueDate в [from, to) — для календаря, без пагинации */
|
||||||
|
@Get('calendar')
|
||||||
|
calendar(@Query() query: CalendarIssuesQueryDto, @Session() session: UserSession) {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.listCalendarIssuesUseCase.execute(
|
||||||
|
new ListCalendarIssuesQuery(
|
||||||
|
session.user.id,
|
||||||
|
new Date(query.from),
|
||||||
|
new Date(query.to),
|
||||||
|
query.projectId,
|
||||||
|
query.status,
|
||||||
|
query.assigneeId,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,13 +128,15 @@ export class IssueController {
|
|||||||
/**
|
/**
|
||||||
* SSE-поток для зарегистрированных пользователей (доступ уже проверен
|
* SSE-поток для зарегистрированных пользователей (доступ уже проверен
|
||||||
* глобальным AuthGuard): уведомляет о каждой созданной, обновлённой
|
* глобальным AuthGuard): уведомляет о каждой созданной, обновлённой
|
||||||
* и удалённой задаче, видимой текущему пользователю (reporter/assignee —
|
* и удалённой задаче, видимой текущему пользователю (reporter/assignee/наблюдатель —
|
||||||
* то же правило видимости, что и в IssuePrismaRepository.ownedByUser).
|
* то же правило видимости, что и в IssuePrismaRepository.visibleTo).
|
||||||
*/
|
*/
|
||||||
@Sse('events')
|
@Sse('events')
|
||||||
issueCreatedEvents(@Session() session: UserSession): Observable<MessageEvent> {
|
issueCreatedEvents(@Session() session: UserSession): Observable<MessageEvent> {
|
||||||
const isVisibleToUser = (issue: IssueDto) =>
|
const isVisibleToUser = (issue: IssueDto) =>
|
||||||
issue.reporterId === session.user.id || issue.assigneeId === session.user.id;
|
issue.reporterId === session.user.id ||
|
||||||
|
issue.assigneeId === session.user.id ||
|
||||||
|
issue.observerIds.includes(session.user.id);
|
||||||
|
|
||||||
return merge(
|
return merge(
|
||||||
this.issueEventsService.onIssueCreated().pipe(
|
this.issueEventsService.onIssueCreated().pipe(
|
||||||
@@ -143,6 +181,9 @@ export class IssueController {
|
|||||||
dto.priority,
|
dto.priority,
|
||||||
dto.assignee,
|
dto.assignee,
|
||||||
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
|
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
|
||||||
|
dto.type,
|
||||||
|
dto.observers,
|
||||||
|
dto.tags,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -154,6 +195,13 @@ export class IssueController {
|
|||||||
await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id)));
|
await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Наблюдатель отписывается от задачи сам (снять других может только reporter/assignee через PATCH) */
|
||||||
|
@Delete(':id/observers/me')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async unobserve(@Param('id', ParseIntPipe) id: number, @Session() session: UserSession) {
|
||||||
|
await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id)));
|
||||||
|
}
|
||||||
|
|
||||||
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
|
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
|
||||||
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +210,12 @@ export class IssueController {
|
|||||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
|
if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) {
|
||||||
|
throw new ForbiddenException(error.message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validateSync } from 'class-validator';
|
||||||
|
import { CalendarIssuesQueryDto } from './calendar-issues-query.dto.js';
|
||||||
|
|
||||||
|
const parse = (query: Record<string, unknown>) => {
|
||||||
|
const dto = plainToInstance(CalendarIssuesQueryDto, query);
|
||||||
|
return { dto, errors: validateSync(dto) };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CalendarIssuesQueryDto', () => {
|
||||||
|
it('accepts moments with an offset or Z', () => {
|
||||||
|
const { errors } = parse({ from: '2026-09-28T00:00:00+03:00', to: '2026-11-09T00:00:00.000Z' });
|
||||||
|
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires from and to', () => {
|
||||||
|
const { errors } = parse({});
|
||||||
|
|
||||||
|
expect(errors.map((el) => el.property)).toEqual(['from', 'to']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a moment without an offset', () => {
|
||||||
|
const { errors } = parse({ from: '2026-09-28T00:00:00', to: '2026-11-09T00:00:00Z' });
|
||||||
|
|
||||||
|
expect(errors.map((el) => el.property)).toEqual(['from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a plain date', () => {
|
||||||
|
const { errors } = parse({ from: '2026-09-28', to: '2026-11-09T00:00:00Z' });
|
||||||
|
|
||||||
|
expect(errors.map((el) => el.property)).toEqual(['from']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the status list', () => {
|
||||||
|
const { dto, errors } = parse({
|
||||||
|
from: '2026-09-28T00:00:00Z',
|
||||||
|
to: '2026-11-09T00:00:00Z',
|
||||||
|
status: 'OPEN,IN_PROGRESS',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IsISO8601, Matches } from 'class-validator';
|
||||||
|
import { StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
|
import { IssueStatusListDecorator } from './issue-alias-list.decorator.js';
|
||||||
|
|
||||||
|
/** Без смещения дата трактовалась бы в часовом поясе сервера */
|
||||||
|
const WITH_OFFSET = /(Z|[+-]\d{2}:\d{2})$/;
|
||||||
|
const MOMENT_MESSAGE = '$property: ожидается ISO 8601 со смещением, например 2026-10-01T00:00:00+03:00';
|
||||||
|
|
||||||
|
export class CalendarIssuesQueryDto {
|
||||||
|
/** Начало видимой сетки календаря, включительно */
|
||||||
|
@IsISO8601({ strict: true }, { message: MOMENT_MESSAGE })
|
||||||
|
@Matches(WITH_OFFSET, { message: MOMENT_MESSAGE })
|
||||||
|
from: string;
|
||||||
|
|
||||||
|
/** Конец видимой сетки календаря, не включительно */
|
||||||
|
@IsISO8601({ strict: true }, { message: MOMENT_MESSAGE })
|
||||||
|
@Matches(WITH_OFFSET, { message: MOMENT_MESSAGE })
|
||||||
|
to: string;
|
||||||
|
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
assigneeId?: string;
|
||||||
|
|
||||||
|
@IssueStatusListDecorator()
|
||||||
|
status?: string[];
|
||||||
|
}
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
import { IsDateString, 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 {
|
export class CreateIssueRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -24,6 +40,10 @@ export class CreateIssueRequestDto {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
@@ -31,4 +51,20 @@ export class CreateIssueRequestDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
dueDate?: string;
|
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[];
|
||||||
}
|
}
|
||||||
@@ -49,3 +49,28 @@ describe('FilterIssueDto.status', () => {
|
|||||||
expect(errors.map((el) => el.property)).toEqual(['status']);
|
expect(errors.map((el) => el.property)).toEqual(['status']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FilterIssueDto.hasDueDate', () => {
|
||||||
|
it('leaves hasDueDate undefined when absent', () => {
|
||||||
|
const { dto, errors } = parse({});
|
||||||
|
|
||||||
|
expect(dto.hasDueDate).toBeUndefined();
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['true', true],
|
||||||
|
['false', false],
|
||||||
|
])('parses "%s"', (value, expected) => {
|
||||||
|
const { dto, errors } = parse({ hasDueDate: value });
|
||||||
|
|
||||||
|
expect(dto.hasDueDate).toBe(expected);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-boolean value', () => {
|
||||||
|
const { errors } = parse({ hasDueDate: 'yes' });
|
||||||
|
|
||||||
|
expect(errors.map((el) => el.property)).toEqual(['hasDueDate']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Transform } from 'class-transformer';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator';
|
import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
import { ISSUE_USER_ROLES, type IssueUserRole } from '../../domain/repositories/issue.repository.interface.js';
|
||||||
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js';
|
||||||
|
|
||||||
export class FilterIssueDto {
|
export class FilterIssueDto {
|
||||||
@IntDecorator({ isOptional: true })
|
@IntDecorator({ isOptional: true })
|
||||||
@@ -16,22 +16,21 @@ export class FilterIssueDto {
|
|||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
|
@IssueStatusListDecorator()
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) =>
|
|
||||||
value === undefined || value === null
|
|
||||||
? undefined
|
|
||||||
: ([] as unknown[])
|
|
||||||
.concat(value)
|
|
||||||
.flatMap((el) => String(el).split(','))
|
|
||||||
.map((el) => el.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
@IsArray()
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@IsIn(ISSUE_STATUSES.map((status) => status.alias), { each: true })
|
|
||||||
status?: string[];
|
status?: string[];
|
||||||
|
|
||||||
|
@IssueTypeListDecorator()
|
||||||
|
type?: string[];
|
||||||
|
|
||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
/** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */
|
||||||
|
@BooleanDecorator({ isOptional: true })
|
||||||
|
hasDueDate?: boolean;
|
||||||
|
|
||||||
|
/** Только задачи, где текущий пользователь исполнитель, автор или наблюдатель */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_USER_ROLES)
|
||||||
|
role?: IssueUserRole;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } 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';
|
||||||
|
|
||||||
|
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
|
||||||
|
function AliasListDecorator(aliases: readonly string[]) {
|
||||||
|
return applyDecorators(
|
||||||
|
IsOptional(),
|
||||||
|
Transform(({ value }) =>
|
||||||
|
value === undefined || value === null
|
||||||
|
? undefined
|
||||||
|
: ([] as unknown[])
|
||||||
|
.concat(value)
|
||||||
|
.flatMap((el) => String(el).split(','))
|
||||||
|
.map((el) => el.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
IsArray(),
|
||||||
|
ArrayNotEmpty(),
|
||||||
|
IsIn(aliases, { each: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueStatusListDecorator() {
|
||||||
|
return AliasListDecorator(ISSUE_STATUSES.map((status) => status.alias));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?type=BUG,FEATURE` или `?type=BUG&type=FEATURE` */
|
||||||
|
export function IssueTypeListDecorator() {
|
||||||
|
return AliasListDecorator(ISSUE_TYPE_ALIASES);
|
||||||
|
}
|
||||||
@@ -1,5 +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_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 {
|
export class UpdateIssueRequestDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -21,6 +36,10 @@ export class UpdateIssueRequestDto {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignee?: string | null;
|
assignee?: string | null;
|
||||||
@@ -28,4 +47,20 @@ export class UpdateIssueRequestDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
dueDate?: string | null;
|
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[];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { IssueDetailType } from './issue-detail.type.js';
|
||||||
|
|
||||||
|
@ObjectType('CalendarIssues')
|
||||||
|
export class CalendarIssuesType {
|
||||||
|
@Field(() => [IssueDetailType])
|
||||||
|
list: IssueDetailType[];
|
||||||
|
|
||||||
|
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
|
||||||
|
@Field()
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||||
import { IsDate, 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()
|
@InputType()
|
||||||
export class CreateIssueInput {
|
export class CreateIssueInput {
|
||||||
@@ -31,6 +47,11 @@ export class CreateIssueInput {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@Field({ nullable: true })
|
@Field({ nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -40,4 +61,20 @@ export class CreateIssueInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDate()
|
@IsDate()
|
||||||
dueDate?: Date;
|
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 { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { IssueTagType } from './issue-tag.type.js';
|
||||||
import { UserType } from '../../../user/presentation/graphql/user.type.js';
|
import { UserType } from '../../../user/presentation/graphql/user.type.js';
|
||||||
|
|
||||||
@ObjectType('IssueDetail')
|
@ObjectType('IssueDetail')
|
||||||
@@ -21,18 +22,30 @@ export class IssueDetailType {
|
|||||||
@Field(() => Int)
|
@Field(() => Int)
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
type: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
reporterId: string | null;
|
reporterId: string | null;
|
||||||
|
|
||||||
|
@Field(() => [String])
|
||||||
|
observerIds: string[];
|
||||||
|
|
||||||
|
@Field(() => [IssueTagType])
|
||||||
|
tags: IssueTagType[];
|
||||||
|
|
||||||
@Field(() => UserType, { nullable: true })
|
@Field(() => UserType, { nullable: true })
|
||||||
assignee: UserType | null;
|
assignee: UserType | null;
|
||||||
|
|
||||||
@Field(() => UserType, { nullable: true })
|
@Field(() => UserType, { nullable: true })
|
||||||
reporter: UserType | null;
|
reporter: UserType | null;
|
||||||
|
|
||||||
|
@Field(() => [UserType])
|
||||||
|
observers: UserType[];
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { IssueTagType } from './issue-tag.type.js';
|
||||||
|
|
||||||
@ObjectType('Issue')
|
@ObjectType('Issue')
|
||||||
export class IssueType {
|
export class IssueType {
|
||||||
@@ -20,12 +21,21 @@ export class IssueType {
|
|||||||
@Field(() => Int)
|
@Field(() => Int)
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
type: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
reporterId: string | null;
|
reporterId: string | null;
|
||||||
|
|
||||||
|
@Field(() => [String])
|
||||||
|
observerIds: string[];
|
||||||
|
|
||||||
|
@Field(() => [IssueTagType])
|
||||||
|
tags: IssueTagType[];
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||||
import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsDate,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
import { ISSUE_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()
|
@InputType()
|
||||||
export class UpdateIssueInput {
|
export class UpdateIssueInput {
|
||||||
@@ -27,6 +42,11 @@ export class UpdateIssueInput {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -36,4 +56,20 @@ export class UpdateIssueInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDate()
|
@IsDate()
|
||||||
dueDate?: Date | null;
|
dueDate?: Date | null;
|
||||||
|
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
observers?: string[];
|
||||||
|
|
||||||
|
@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 { NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||||
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
|
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
|
||||||
@@ -21,7 +21,18 @@ import { IssueDetailType } from '../graphql/issue-detail.type.js';
|
|||||||
import { IssuePageType } from '../graphql/issue-page.type.js';
|
import { IssuePageType } from '../graphql/issue-page.type.js';
|
||||||
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
||||||
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
|
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
|
||||||
|
import { CalendarIssuesType } from '../graphql/calendar-issues.type.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
|
||||||
|
import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js';
|
||||||
|
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 { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
|
||||||
|
import { IssueEditForbiddenException } from '../../domain/exceptions/issue-edit-forbidden.exception.js';
|
||||||
|
import { IssueProjectForbiddenException } from '../../domain/exceptions/issue-project-forbidden.exception.js';
|
||||||
|
import { IssueObserverNotFoundException } from '../../domain/exceptions/issue-observer-not-found.exception.js';
|
||||||
|
import { UnobserveIssueUseCase } from '../../application/use-cases/unobserve-issue/unobserve-issue.use-case.js';
|
||||||
|
import { UnobserveIssueCommand } from '../../application/use-cases/unobserve-issue/unobserve-issue.command.js';
|
||||||
|
import { ISSUE_USER_ROLES, type IssueUserRole } from '../../domain/repositories/issue.repository.interface.js';
|
||||||
|
|
||||||
@Resolver(() => IssueType)
|
@Resolver(() => IssueType)
|
||||||
export class IssueResolver {
|
export class IssueResolver {
|
||||||
@@ -32,6 +43,8 @@ export class IssueResolver {
|
|||||||
private readonly listIssuesUseCase: ListIssuesUseCase,
|
private readonly listIssuesUseCase: ListIssuesUseCase,
|
||||||
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
||||||
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
||||||
|
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
|
||||||
|
private readonly unobserveIssueUseCase: UnobserveIssueUseCase,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Query(() => IssueDetailType, { name: 'issue' })
|
@Query(() => IssueDetailType, { name: 'issue' })
|
||||||
@@ -50,8 +63,45 @@ export class IssueResolver {
|
|||||||
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
||||||
@Session() session: UserSession,
|
@Session() session: UserSession,
|
||||||
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||||
|
@Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean,
|
||||||
|
@Args('type', { type: () => [String], nullable: true }) type?: string[],
|
||||||
|
@Args('role', { type: () => String, nullable: true }) role?: string,
|
||||||
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
||||||
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page, session.user.id, projectId));
|
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,
|
||||||
|
page,
|
||||||
|
session.user.id,
|
||||||
|
projectId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
hasDueDate ?? undefined,
|
||||||
|
type ?? undefined,
|
||||||
|
(role as IssueUserRole | undefined) ?? undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задачи с dueDate в [from, to) — для календаря, без пагинации */
|
||||||
|
@Query(() => CalendarIssuesType, { name: 'calendarIssues' })
|
||||||
|
calendar(
|
||||||
|
@Args('from', { type: () => Date }) from: Date,
|
||||||
|
@Args('to', { type: () => Date }) to: Date,
|
||||||
|
@Session() session: UserSession,
|
||||||
|
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||||
|
@Args('status', { type: () => [String], nullable: true }) status?: string[],
|
||||||
|
@Args('assigneeId', { type: () => String, nullable: true }) assigneeId?: string,
|
||||||
|
): Promise<CalendarIssuesDto> {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.listCalendarIssuesUseCase.execute(
|
||||||
|
new ListCalendarIssuesQuery(session.user.id, from, to, projectId, status, assigneeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => IssueType, { name: 'createIssue' })
|
@Mutation(() => IssueType, { name: 'createIssue' })
|
||||||
@@ -67,6 +117,9 @@ export class IssueResolver {
|
|||||||
input.priority,
|
input.priority,
|
||||||
input.assignee,
|
input.assignee,
|
||||||
input.dueDate,
|
input.dueDate,
|
||||||
|
input.type,
|
||||||
|
input.observers,
|
||||||
|
input.tags,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -89,6 +142,9 @@ export class IssueResolver {
|
|||||||
input.priority,
|
input.priority,
|
||||||
input.assignee,
|
input.assignee,
|
||||||
input.dueDate,
|
input.dueDate,
|
||||||
|
input.type,
|
||||||
|
input.observers ?? undefined,
|
||||||
|
input.tags ?? undefined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -100,6 +156,13 @@ export class IssueResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Наблюдатель отписывается от задачи сам */
|
||||||
|
@Mutation(() => Boolean, { name: 'unobserveIssue' })
|
||||||
|
async unobserve(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise<boolean> {
|
||||||
|
await this.handleErrors(() => this.unobserveIssueUseCase.execute(new UnobserveIssueCommand(id, session.user.id)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */
|
/** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */
|
||||||
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
try {
|
try {
|
||||||
@@ -108,6 +171,12 @@ export class IssueResolver {
|
|||||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
|
if (error instanceof InvalidCalendarRangeException || error instanceof IssueObserverNotFoundException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
if (error instanceof IssueEditForbiddenException || error instanceof IssueProjectForbiddenException) {
|
||||||
|
throw new ForbiddenException(error.message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,22 @@ in-process pub/sub уже используется в issue-модуле
|
|||||||
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
|
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
|
||||||
(общая БД, синхронный поход не нужен) и шлёт через существующий
|
(общая БД, синхронный поход не нужен) и шлёт через существующий
|
||||||
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
|
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
|
||||||
- `PushChannelSender` — читает токены устройств из `notification_device_token`,
|
- `PushChannelSender` — читает токены устройств (Firebase Installation ID)
|
||||||
но сама отправка — заглушка (лог вместо реального вызова). Провайдер
|
из `notification_device_token` и шлёт через Firebase Cloud Messaging
|
||||||
(FCM/APNs) ещё не подключён; когда появится, меняется только тело
|
(`firebase-admin`, `sendEachForMulticast`, `subject` → title, `content` → body).
|
||||||
`send()`, остальной пайплайн — нет.
|
Токены, которые FCM вернул как незарегистрированные, удаляются. Отправка
|
||||||
|
`FAILED`, если ни одно устройство не приняло сообщение.
|
||||||
|
|
||||||
|
### Конфигурация Firebase
|
||||||
|
|
||||||
|
`infrastructure/firebase/firebase-messaging.provider.ts`, источники по приоритету:
|
||||||
|
|
||||||
|
- `FIREBASE_SERVICE_ACCOUNT` — содержимое JSON-ключа сервисного аккаунта
|
||||||
|
(как есть или в base64). Основной вариант для деплоя.
|
||||||
|
- `GOOGLE_APPLICATION_CREDENTIALS` — путь к JSON-файлу ключа (удобно локально),
|
||||||
|
`FIREBASE_PROJECT_ID` — опционально.
|
||||||
|
|
||||||
|
Без них приложение стартует, но push-отправки получают статус `FAILED`.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -90,6 +102,7 @@ in-process pub/sub уже используется в issue-модуле
|
|||||||
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
|
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
|
||||||
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
|
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
|
||||||
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
|
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
|
||||||
|
| `POST` | `/test-push` | `{ title?, body? }` | Тестовый push текущему пользователю на все его устройства → `202`. Результат доставки — в `notification_message` |
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
@@ -113,7 +126,6 @@ notification.module.ts — сборка DI
|
|||||||
|
|
||||||
## Чего пока нет (сознательно, вне текущего объёма)
|
## Чего пока нет (сознательно, вне текущего объёма)
|
||||||
|
|
||||||
- Реального push-провайдера (FCM/APNs) — только заглушка с логированием.
|
|
||||||
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
|
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
|
||||||
- SMS-канала — при необходимости добавляется как ещё одно значение в
|
- SMS-канала — при необходимости добавляется как ещё одно значение в
|
||||||
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
|
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
|
||||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user