Author SHA1 Message Date
vadim 4ac4d314e4 cicd
Gitea Actions / Deploy (push) Successful in 2m11s
2026-11-03 09:35:02 +03:00
vadim 917ea9748e docs
Gitea Actions / Deploy (push) Failing after 27m39s
2026-11-01 21:36:45 +03:00
vadim d170fc8790 tags
Gitea Actions / Deploy (push) Canceled after 45m38s
2026-10-28 16:22:24 +03:00
vadimandClaude Opus 5.5 b1f6b61994 create issues fix
Gitea Actions / Deploy (push) Canceled after 5m13s
issue observers

Наблюдатели задачи видят её и комментируют, но не редактируют.

- таблица issue_observer, наблюдатели — часть сущности Issue
- видимость: reporter, assignee или наблюдатель (задачи, история, SSE)
- правка/удаление задачи и вложений — только reporter/assignee (403 наблюдателю)
- observers в create/update, DELETE /issues/:id/observers/me, фильтр role
- история изменений и уведомления новым наблюдателям

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:23:49 +03:00
vadim 46903e9ec5 avatar
Gitea Actions / Deploy (push) Successful in 1m52s
2026-10-23 19:26:26 +03:00
vadim f085517732 email 2026-10-23 19:26:26 +03:00
vadim de1b3754df otp code, packages update
Gitea Actions / Deploy (push) Successful in 1m34s
2026-10-19 17:23:50 +03:00
vadim c5691da2b8 issue type
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:37:58 +03:00
vadim afc37f8fdb send push to assignee
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:19:57 +03:00
vadim 8d02d25da0 firebase messaging
Gitea Actions / Deploy (push) Successful in 2m1s
2026-10-16 16:51:55 +03:00
vadim 5ad0ecdf4c typo
Gitea Actions / Deploy (push) Successful in 36s
2026-10-14 21:26:15 +03:00
vadim 7aecc6f96e calendar due date
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-12 11:08:27 +03:00
vadim cb29c7d2c4 Add issue change history and daily issue stats
Gitea Actions Demo / Deploy (push) Successful in 32s
Issue changes are recorded per field in the issue_change table, in the same
transaction as the issue itself. The new history module exposes:
- GET /api/issues/:issueId/history — change timeline grouped by save
- GET /api/issues/stats/daily — created, status transitions and status
  snapshot per day, with time zone support

The migration backfills history for existing issues. Setting a field to its
current value no longer bumps updatedAt or emits issue.updated.
2026-10-08 09:55:21 +03:00
vadim f44590b8ca update issue filter
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-06 23:18:00 +03:00
173 changed files with 7495 additions and 1564 deletions

No files matched your search

+2
View File
@@ -11,3 +11,5 @@ coverage
tsconfig.build.tsbuildinfo tsconfig.build.tsbuildinfo
src/generated src/generated
storage storage
configs
service-account.json
+2 -2
View File
@@ -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
+2
View File
@@ -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
View File
@@ -1,3 +1,4 @@
# Prisma
npm install prisma@7 --save-dev npm install prisma@7 --save-dev
+2412 -1054
View File
File diff suppressed because it is too large. Load diff
+18 -19
View File
@@ -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,28 @@
-- CreateTable
CREATE TABLE "issue_change" (
"id" SERIAL NOT NULL,
"issueId" INTEGER NOT NULL,
"changeId" TEXT NOT NULL,
"field" TEXT NOT NULL,
"oldValue" TEXT,
"newValue" TEXT,
"actorId" TEXT,
"changedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "issue_change_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "issue_change_issueId_changedAt_idx" ON "issue_change"("issueId", "changedAt");
-- CreateIndex
CREATE INDEX "issue_change_field_changedAt_idx" ON "issue_change"("field", "changedAt");
-- CreateIndex
CREATE INDEX "issue_change_actorId_idx" ON "issue_change"("actorId");
-- AddForeignKey
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+99 -2
View File
@@ -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,7 +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[]
observedIssues IssueObserver[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -87,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?
@@ -96,6 +103,9 @@ model Issue {
dueDate DateTime? @db.Timestamptz(3) dueDate DateTime? @db.Timestamptz(3)
comments Comment[] comments Comment[]
attachments IssueAttachment[] attachments IssueAttachment[]
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)
@@ -106,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
@@ -119,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])
@@ -126,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)
@@ -224,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])
@@ -264,3 +333,31 @@ model CommentAttachment {
@@index([fileId]) @@index([fileId])
@@map("comment_attachment") @@map("comment_attachment")
} }
/// История изменений задачи — одна строка на изменённое поле.
/// Строки одного сохранения задачи объединены общим changeId.
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
/// observer — id пользователя, tag — имя тега на момент изменения.
/// При создании задачи пишутся две строки: created и status null -> OPEN,
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
model IssueChange {
id Int @id @default(autoincrement())
issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
changeId String
field String
oldValue String?
newValue String?
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
changedAt DateTime @db.Timestamptz(3)
@@index([issueId, changedAt])
@@index([field, changedAt])
@@index([actorId])
@@map("issue_change")
}
+6 -8
View File
@@ -5,10 +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 { 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';
@@ -24,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: {
@@ -51,10 +46,13 @@ import { join } from 'node:path';
ProjectModule, ProjectModule,
UserModule, UserModule,
StatusModule, StatusModule,
IssueTypeModule,
NotificationModule, NotificationModule,
CommentModule, CommentModule,
FileModule, FileModule,
IssueAttachmentModule, IssueAttachmentModule,
HistoryModule,
TagModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
+19 -1
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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),
);
});
} }
} }
-5
View File
@@ -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(),
@@ -0,0 +1,22 @@
import type { IssueStatusValue } from '../../../issue/domain/value-objects/issue-status.vo.js';
/** Счётчик по каждому статусу; статусы без задач — 0 */
export type IssueStatusCounts = Record<IssueStatusValue, number>;
export class DailyIssueStatsDayDto {
/** YYYY-MM-DD в часовом поясе запроса */
date: string;
/** Создано задач за день */
created: number;
/** Переходов в статус за день (создание задачи в OPEN сюда не входит). transitions.DONE — выполнено за день */
transitions: IssueStatusCounts;
/** Задач в каждом статусе на конец дня */
byStatus: IssueStatusCounts;
}
export class DailyIssueStatsDto {
from: string;
to: string;
timeZone: string;
days: DailyIssueStatsDayDto[];
}
@@ -0,0 +1,21 @@
import type { IssueChangeField } from '../../../issue/domain/entities/issue-change.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
export class IssueFieldChangeDto {
field: IssueChangeField;
oldValue: string | null;
newValue: string | null;
/** Только для field = assignee/observer: пользователи из oldValue/newValue (null — не назначен или удалён) */
oldUser?: UserDto | null;
newUser?: UserDto | null;
}
/** Одно сохранение задачи: кто, когда и какие поля изменил */
export class IssueHistoryEntryDto {
/** changeId */
id: string;
changedAt: Date;
actorId: string | null;
actor: UserDto | null;
changes: IssueFieldChangeDto[];
}
@@ -0,0 +1,12 @@
export class GetDailyIssueStatsQuery {
constructor(
public readonly userId: string,
public readonly projectId?: string,
/** YYYY-MM-DD */
public readonly from?: string,
/** YYYY-MM-DD */
public readonly to?: string,
/** IANA, например Europe/Moscow */
public readonly timeZone?: string,
) {}
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueHistoryRepository } from '../../../domain/repositories/issue-history.repository.interface.js';
import { InvalidStatsPeriodException } from '../../../domain/exceptions/invalid-stats-period.exception.js';
import { GetDailyIssueStatsUseCase } from './get-daily-issue-stats.use-case.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
const zero = { OPEN: 0, IN_PROGRESS: 0, DONE: 0, CANCELLED: 0 };
describe('GetDailyIssueStatsUseCase', () => {
let repository: IIssueHistoryRepository;
let useCase: GetDailyIssueStatsUseCase;
beforeEach(() => {
repository = {
findByIssue: vi.fn(),
countDailyEvents: vi.fn().mockResolvedValue([]),
countDailyStatuses: vi.fn().mockResolvedValue([]),
};
useCase = new GetDailyIssueStatsUseCase(repository);
});
it('passes the validated period and visibility params to the repository', async () => {
await useCase.execute(
new GetDailyIssueStatsQuery('user-1', 'project-1', '2026-10-01', '2026-10-03', 'Europe/Moscow'),
);
const range = {
userId: 'user-1',
projectId: 'project-1',
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'Europe/Moscow',
};
expect(repository.countDailyEvents).toHaveBeenCalledWith(range);
expect(repository.countDailyStatuses).toHaveBeenCalledWith(range);
});
it('fills every day and every status, including the empty ones', async () => {
vi.mocked(repository.countDailyEvents).mockResolvedValue([
{ date: '2026-10-01', kind: 'created', count: 2 },
{ date: '2026-10-03', kind: 'status', status: 'DONE', count: 1 },
]);
vi.mocked(repository.countDailyStatuses).mockResolvedValue([
{ date: '2026-10-01', status: 'OPEN', count: 2 },
{ date: '2026-10-03', status: 'OPEN', count: 1 },
{ date: '2026-10-03', status: 'DONE', count: 1 },
]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-03'));
expect(result).toEqual({
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'UTC',
days: [
{ date: '2026-10-01', created: 2, transitions: zero, byStatus: { ...zero, OPEN: 2 } },
{ date: '2026-10-02', created: 0, transitions: zero, byStatus: zero },
{ date: '2026-10-03', created: 0, transitions: { ...zero, DONE: 1 }, byStatus: { ...zero, OPEN: 1, DONE: 1 } },
],
});
});
it('ignores statuses that are no longer known', async () => {
vi.mocked(repository.countDailyStatuses).mockResolvedValue([{ date: '2026-10-01', status: 'LEGACY', count: 5 }]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-01'));
expect(result.days[0]!.byStatus).toEqual(zero);
});
it('rejects an invalid period without querying the database', async () => {
await expect(
useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-05', '2026-10-01')),
).rejects.toThrow(InvalidStatsPeriodException);
expect(repository.countDailyEvents).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISSUE_STATUSES, type IssueStatusValue } from '../../../../issue/domain/value-objects/issue-status.vo.js';
import {
ISSUE_HISTORY_REPOSITORY,
type IIssueHistoryRepository,
} from '../../../domain/repositories/issue-history.repository.interface.js';
import { StatsPeriod } from '../../../domain/value-objects/stats-period.vo.js';
import { DailyIssueStatsDayDto, DailyIssueStatsDto, IssueStatusCounts } from '../../dto/daily-issue-stats.dto.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
/**
* Статистика задач по дням, считается по истории изменений (issue_change).
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee/наблюдатель.
*/
@Injectable()
export class GetDailyIssueStatsUseCase {
constructor(
@Inject(ISSUE_HISTORY_REPOSITORY)
private readonly historyRepository: IIssueHistoryRepository,
) {}
async execute(query: GetDailyIssueStatsQuery): Promise<DailyIssueStatsDto> {
const period = StatsPeriod.create({ from: query.from, to: query.to, timeZone: query.timeZone });
const range = {
userId: query.userId,
projectId: query.projectId,
from: period.from,
to: period.to,
timeZone: period.timeZone,
};
const [events, statuses] = await Promise.all([
this.historyRepository.countDailyEvents(range),
this.historyRepository.countDailyStatuses(range),
]);
// Каждый день периода попадает в ответ, даже если в нём ничего не было
const days = new Map<string, DailyIssueStatsDayDto>(
period.days.map((date) => [
date,
{ date, created: 0, transitions: emptyStatusCounts(), byStatus: emptyStatusCounts() },
]),
);
for (const event of events) {
const day = days.get(event.date);
if (!day) continue;
if (event.kind === 'created') {
day.created += event.count;
} else if (isKnownStatus(event.status)) {
day.transitions[event.status] += event.count;
}
}
for (const entry of statuses) {
const day = days.get(entry.date);
if (day && isKnownStatus(entry.status)) {
day.byStatus[entry.status] += entry.count;
}
}
return { from: period.from, to: period.to, timeZone: period.timeZone, days: [...days.values()] };
}
}
function emptyStatusCounts(): IssueStatusCounts {
return Object.fromEntries(ISSUE_STATUSES.map((status) => [status.alias, 0])) as IssueStatusCounts;
}
function isKnownStatus(status: string): status is IssueStatusValue {
return ISSUE_STATUSES.some((known) => known.alias === status);
}
@@ -0,0 +1,6 @@
export class GetIssueHistoryQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
IIssueHistoryRepository,
IssueChangeRecord,
} from '../../../domain/repositories/issue-history.repository.interface.js';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { Issue } from '../../../../issue/domain/entities/issue.entity.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
import type { UserDto } from '../../../../user/application/dto/user.dto.js';
import { GetIssueHistoryUseCase } from './get-issue-history.use-case.js';
import { GetIssueHistoryQuery } from './get-issue-history.query.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',
projectId: 'project-1',
dueDate: null,
createdAt: new Date('2026-10-01T00:00:00.000Z'),
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
});
const user = (id: string) => ({ id, name: id }) as UserDto;
let nextId = 1;
const record = (
props: Partial<IssueChangeRecord> & Pick<IssueChangeRecord, 'changeId' | 'field'>,
): IssueChangeRecord => ({
id: nextId++,
issueId: 1,
oldValue: null,
newValue: null,
actorId: 'user-1',
changedAt: new Date('2026-10-01T00:00:00.000Z'),
...props,
});
describe('GetIssueHistoryUseCase', () => {
let historyRepository: IIssueHistoryRepository;
let issueRepository: IIssueRepository;
let getUsersUseCase: GetUsersUseCase;
let useCase: GetIssueHistoryUseCase;
beforeEach(() => {
historyRepository = { findByIssue: vi.fn(), countDailyEvents: vi.fn(), countDailyStatuses: vi.fn() };
issueRepository = {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
getUsersUseCase = {
execute: vi.fn(async (query: { ids: string[] }) => query.ids.filter((id) => id !== 'deleted').map(user)),
} as unknown as GetUsersUseCase;
useCase = new GetIssueHistoryUseCase(historyRepository, issueRepository, getUsersUseCase);
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new GetIssueHistoryQuery(1, 'user-9'))).rejects.toThrow(IssueNotFoundException);
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-9');
expect(historyRepository.findByIssue).not.toHaveBeenCalled();
});
it('groups rows of one save into an entry and expands actors and assignees', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
vi.mocked(historyRepository.findByIssue).mockResolvedValue([
record({ changeId: 'c1', field: 'created' }),
record({ changeId: 'c1', field: 'status', newValue: 'OPEN' }),
record({ changeId: 'c2', field: 'status', oldValue: 'OPEN', newValue: 'DONE', actorId: 'user-2' }),
record({ changeId: 'c2', field: 'assignee', oldValue: 'deleted', newValue: 'user-3', actorId: 'user-2' }),
]);
const entries = await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'));
expect(entries.map((entry) => [entry.id, entry.actor?.id, entry.changes.map((change) => change.field)])).toEqual([
['c1', 'user-1', ['created', 'status']],
['c2', 'user-2', ['status', 'assignee']],
]);
expect(entries[1]!.changes[0]).toEqual({ field: 'status', oldValue: 'OPEN', newValue: 'DONE' });
expect(entries[1]!.changes[1]).toMatchObject({ oldUser: null, newUser: { id: 'user-3' } });
expect(getUsersUseCase.execute).toHaveBeenCalledTimes(1);
});
it('does not query users for an empty history', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
vi.mocked(historyRepository.findByIssue).mockResolvedValue([]);
expect(await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'))).toEqual([]);
expect(getUsersUseCase.execute).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,87 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ISSUE_REPOSITORY,
type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.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 { UserDto } from '../../../../user/application/dto/user.dto.js';
import {
ISSUE_HISTORY_REPOSITORY,
type IIssueHistoryRepository,
type IssueChangeRecord,
} from '../../../domain/repositories/issue-history.repository.interface.js';
import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js';
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
import type { IssueChangeField } from '../../../../issue/domain/entities/issue-change.js';
/** Поля, значения которых — id пользователей */
const USER_FIELDS = new Set<IssueChangeField>(['assignee', 'observer']);
@Injectable()
export class GetIssueHistoryUseCase {
constructor(
@Inject(ISSUE_HISTORY_REPOSITORY)
private readonly historyRepository: IIssueHistoryRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly getUsersUseCase: GetUsersUseCase,
) {}
async execute(query: GetIssueHistoryQuery): Promise<IssueHistoryEntryDto[]> {
const issue = await this.issueRepository.findById(query.issueId, query.userId);
if (!issue) {
throw new IssueNotFoundException(query.issueId);
}
const records = await this.historyRepository.findByIssue(query.issueId);
const usersById = await this.loadUsers(records);
const userOf = (id: string | null) => (id ? (usersById.get(id) ?? null) : null);
// Строки одного сохранения объединяются по changeId, порядок — по первой строке
const entries = new Map<string, IssueHistoryEntryDto>();
for (const record of records) {
let entry = entries.get(record.changeId);
if (!entry) {
entry = {
id: record.changeId,
changedAt: record.changedAt,
actorId: record.actorId,
actor: userOf(record.actorId),
changes: [],
};
entries.set(record.changeId, entry);
}
const change: IssueFieldChangeDto = {
field: record.field,
oldValue: record.oldValue,
newValue: record.newValue,
};
if (USER_FIELDS.has(record.field)) {
change.oldUser = userOf(record.oldValue);
change.newUser = userOf(record.newValue);
}
entry.changes.push(change);
}
return [...entries.values()];
}
/** Авторы изменений, назначенные/снятые исполнители и наблюдатели */
private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> {
const ids = new Set<string>();
for (const record of records) {
if (record.actorId) ids.add(record.actorId);
if (USER_FIELDS.has(record.field)) {
if (record.oldValue) ids.add(record.oldValue);
if (record.newValue) ids.add(record.newValue);
}
}
if (ids.size === 0) return new Map();
const users = await this.getUsersUseCase.execute(new GetUsersQuery([...ids]));
return new Map(users.map((user) => [user.id, user]));
}
}
@@ -0,0 +1,6 @@
export class InvalidStatsPeriodException extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidStatsPeriodException';
}
}
@@ -0,0 +1,46 @@
import type { IssueChangeField } from '../../../issue/domain/entities/issue-change.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const ISSUE_HISTORY_REPOSITORY = Symbol('ISSUE_HISTORY_REPOSITORY');
/** Строка истории: изменение одного поля задачи (см. модель IssueChange) */
export interface IssueChangeRecord {
id: number;
issueId: number;
/** Общий для всех полей, изменённых одним сохранением задачи */
changeId: string;
field: IssueChangeField;
oldValue: string | null;
newValue: string | null;
actorId: string | null;
changedAt: Date;
}
/** Период статистики: даты YYYY-MM-DD включительно, границы дней — в timeZone */
export interface DailyStatsRange {
userId: string;
projectId?: string;
from: string;
to: string;
timeZone: string;
}
/** created — задачи, созданные за день; status — переходы в status за день (без начального OPEN) */
export type DailyEventCount =
{ date: string; kind: 'created'; count: number } | { date: string; kind: 'status'; status: string; count: number };
/** Сколько задач было в статусе на конец дня */
export interface DailyStatusCount {
date: string;
status: string;
count: number;
}
export interface IIssueHistoryRepository {
/** История задачи от старых изменений к новым */
findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
/** Только задачи, видимые пользователю (reporter/assignee/наблюдатель) */
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { StatsPeriod } from './stats-period.vo.js';
import { InvalidStatsPeriodException } from '../exceptions/invalid-stats-period.exception.js';
describe('StatsPeriod', () => {
const now = new Date('2026-10-07T22:30:00.000Z');
it('defaults to the last 30 days up to today in UTC', () => {
const period = StatsPeriod.create({}, now);
expect(period.from).toBe('2026-09-08');
expect(period.to).toBe('2026-10-07');
expect(period.timeZone).toBe('UTC');
expect(period.days).toHaveLength(30);
});
it('takes "today" in the requested time zone', () => {
// 22:30 UTC — в Москве уже 8 октября
expect(StatsPeriod.create({ timeZone: 'Europe/Moscow' }, now).to).toBe('2026-10-08');
});
it('lists every day of an explicit period inclusively', () => {
const period = StatsPeriod.create({ from: '2026-02-27', to: '2026-03-02' }, now);
expect(period.days).toEqual(['2026-02-27', '2026-02-28', '2026-03-01', '2026-03-02']);
});
it.each([
[{ from: '2026-10-05', to: '2026-10-01' }, 'from after to'],
[{ from: '2026-02-30' }, 'non-existent date'],
[{ to: '07.10.2026' }, 'wrong date format'],
[{ from: '2025-01-01', to: '2026-10-07' }, 'period too long'],
[{ timeZone: 'Mars/Olympus' }, 'unknown time zone'],
[{ timeZone: '+03:00' }, 'offset instead of IANA name'],
] as const)('rejects %j (%s)', (params, _reason) => {
expect(() => StatsPeriod.create(params, now)).toThrow(InvalidStatsPeriodException);
});
it('accepts the maximum period length', () => {
expect(StatsPeriod.create({ from: '2025-10-07', to: '2026-10-07' }, now).days).toHaveLength(StatsPeriod.MAX_DAYS);
});
});
@@ -0,0 +1,93 @@
import { InvalidStatsPeriodException } from '../exceptions/invalid-stats-period.exception.js';
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Только IANA-имена (Europe/Moscow, UTC). Смещения вида "+03:00" Intl понимает,
* а Postgres в AT TIME ZONE трактует по POSIX — с обратным знаком.
*/
const TIME_ZONE_PATTERN = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Период дневной статистики: from..to включительно (YYYY-MM-DD),
* границы дней считаются в часовом поясе timeZone.
*/
export class StatsPeriod {
static readonly MAX_DAYS = 366;
static readonly DEFAULT_DAYS = 30;
private constructor(
public readonly from: string,
public readonly to: string,
public readonly timeZone: string,
) {}
/**
* Без to — сегодня в timeZone, без from — DEFAULT_DAYS дней до to.
* Без timeZone — UTC.
*/
static create(params: { from?: string; to?: string; timeZone?: string }, now: Date = new Date()): StatsPeriod {
const timeZone = params.timeZone ?? 'UTC';
if (!isValidTimeZone(timeZone)) {
throw new InvalidStatsPeriodException(`Неизвестный часовой пояс: "${timeZone}"`);
}
const to = params.to !== undefined ? parseDate(params.to, 'to') : parseDate(todayIn(timeZone, now), 'to');
const from =
params.from !== undefined ? parseDate(params.from, 'from') : to - (StatsPeriod.DEFAULT_DAYS - 1) * DAY_MS;
if (from > to) {
throw new InvalidStatsPeriodException('Дата from позже даты to');
}
const days = (to - from) / DAY_MS + 1;
if (days > StatsPeriod.MAX_DAYS) {
throw new InvalidStatsPeriodException(`Период больше ${StatsPeriod.MAX_DAYS} дней`);
}
return new StatsPeriod(formatDate(from), formatDate(to), timeZone);
}
/** Все дни периода по порядку, YYYY-MM-DD */
get days(): string[] {
const days: string[] = [];
for (let day = Date.parse(this.from); day <= Date.parse(this.to); day += DAY_MS) {
days.push(formatDate(day));
}
return days;
}
}
function isValidTimeZone(timeZone: string): boolean {
if (!TIME_ZONE_PATTERN.test(timeZone)) return false;
try {
new Intl.DateTimeFormat('en-US', { timeZone });
return true;
} catch {
return false;
}
}
/** YYYY-MM-DD → полночь UTC в мс; несуществующие даты (02-30) отклоняются */
function parseDate(value: string, name: string): number {
const time = DATE_PATTERN.test(value) ? Date.parse(value) : NaN;
if (Number.isNaN(time) || formatDate(time) !== value) {
throw new InvalidStatsPeriodException(`Некорректная дата ${name}: "${value}", ожидается YYYY-MM-DD`);
}
return time;
}
function formatDate(time: number): string {
return new Date(time).toISOString().slice(0, 10);
}
function todayIn(timeZone: string, now: Date): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value;
return `${part('year')}-${part('month')}-${part('day')}`;
}
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { IssueModule } from '../issue/issue.module.js';
import { UserModule } from '../user/user.module.js';
import { ISSUE_HISTORY_REPOSITORY } from './domain/repositories/issue-history.repository.interface.js';
import { IssueHistoryPrismaRepository } from './infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.js';
import { HistoryController } from './presentation/controllers/history.controller.js';
import { GetIssueHistoryUseCase } from './application/use-cases/get-issue-history/get-issue-history.use-case.js';
import { GetDailyIssueStatsUseCase } from './application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.js';
/**
* Чтение истории изменений задач и статистика по ней. Пишет историю
* issue-модуль (IssuePrismaRepository) — в одной транзакции с задачей;
* этот модуль от issue зависит, issue о нём не знает.
*/
@Module({
imports: [PrismaModule, IssueModule, UserModule],
controllers: [HistoryController],
providers: [
GetIssueHistoryUseCase,
GetDailyIssueStatsUseCase,
{
// Domain видит только интерфейс IIssueHistoryRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: ISSUE_HISTORY_REPOSITORY,
useClass: IssueHistoryPrismaRepository,
},
],
})
export class HistoryModule {}
@@ -0,0 +1,90 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import type { IssueChangeField } from '../../../../../issue/domain/entities/issue-change.js';
import type {
DailyEventCount,
DailyStatsRange,
DailyStatusCount,
IIssueHistoryRepository,
IssueChangeRecord,
} from '../../../../domain/repositories/issue-history.repository.interface.js';
/**
* Задачи, видимые пользователю: reporter, assignee или наблюдатель — то же
* правило, что visibleTo в IssuePrismaRepository. Возвращает CTE "visible" (id задач).
*/
function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql {
return Prisma.sql`"visible" AS (
SELECT "id" FROM "issue"
WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId}
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}
)`;
}
@Injectable()
export class IssueHistoryPrismaRepository implements IIssueHistoryRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findByIssue(issueId: number): Promise<IssueChangeRecord[]> {
const records = await this.prisma.issueChange.findMany({
where: { issueId },
orderBy: [{ changedAt: 'asc' }, { id: 'asc' }],
});
return records.map((record) => ({ ...record, field: record.field as IssueChangeField }));
}
async countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]> {
const rows = await this.prisma.$queryRaw<
Array<{ date: string; field: string; status: string | null; count: number }>
>`
WITH ${visibleIssuesCte(range)}
SELECT to_char((c."changedAt" AT TIME ZONE ${range.timeZone})::date, 'YYYY-MM-DD') AS "date",
c."field",
c."newValue" AS "status",
count(*)::int AS "count"
FROM "issue_change" c
JOIN "visible" v ON v."id" = c."issueId"
WHERE (c."field" = 'created' OR (c."field" = 'status' AND c."oldValue" IS NOT NULL))
AND c."changedAt" >= (${range.from}::date)::timestamp AT TIME ZONE ${range.timeZone}
AND c."changedAt" < (${range.to}::date + 1)::timestamp AT TIME ZONE ${range.timeZone}
GROUP BY 1, 2, 3
`;
return rows.map((row) =>
row.field === 'created'
? { date: row.date, kind: 'created', count: row.count }
: { date: row.date, kind: 'status', status: row.status as string, count: row.count },
);
}
/**
* Статус задачи на конец дня — последний переход строго до полуночи
* следующего дня. Переходы превращаются в интервалы [validFrom, validTo),
* день засчитывается интервалу, который покрывает его конец.
*/
async countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]> {
return this.prisma.$queryRaw<DailyStatusCount[]>`
WITH ${visibleIssuesCte(range)},
"days" AS (
SELECT d::date AS "day",
(d::date + 1)::timestamp AT TIME ZONE ${range.timeZone} AS "dayEnd"
FROM generate_series(${range.from}::date, ${range.to}::date, interval '1 day') AS d
),
"intervals" AS (
SELECT c."newValue" AS "status",
c."changedAt" AS "validFrom",
lead(c."changedAt") OVER (PARTITION BY c."issueId" ORDER BY c."changedAt", c."id") AS "validTo"
FROM "issue_change" c
JOIN "visible" v ON v."id" = c."issueId"
WHERE c."field" = 'status'
)
SELECT to_char(d."day", 'YYYY-MM-DD') AS "date", i."status", count(*)::int AS "count"
FROM "days" d
JOIN "intervals" i ON i."validFrom" < d."dayEnd" AND (i."validTo" IS NULL OR i."validTo" >= d."dayEnd")
GROUP BY d."day", i."status"
`;
}
}
@@ -0,0 +1,50 @@
import { BadRequestException, Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { GetIssueHistoryUseCase } from '../../application/use-cases/get-issue-history/get-issue-history.use-case.js';
import { GetIssueHistoryQuery } from '../../application/use-cases/get-issue-history/get-issue-history.query.js';
import { GetDailyIssueStatsUseCase } from '../../application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.js';
import { GetDailyIssueStatsQuery } from '../../application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.js';
import { DailyIssueStatsQueryDto } from '../dto/daily-issue-stats-query.dto.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { InvalidStatsPeriodException } from '../../domain/exceptions/invalid-stats-period.exception.js';
@Controller('issues')
export class HistoryController {
constructor(
private readonly getIssueHistoryUseCase: GetIssueHistoryUseCase,
private readonly getDailyIssueStatsUseCase: GetDailyIssueStatsUseCase,
) {}
/** Статистика по дням: создано, переходы между статусами и срез статусов на конец дня */
@Get('stats/daily')
dailyStats(@Query() query: DailyIssueStatsQueryDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.getDailyIssueStatsUseCase.execute(
new GetDailyIssueStatsQuery(session.user.id, query.projectId, query.from, query.to, query.timeZone),
),
);
}
/** История изменений задачи от старых к новым, одна запись на сохранение */
@Get(':issueId/history')
history(@Param('issueId', ParseIntPipe) issueId: number, @Session() session: UserSession) {
return this.handleErrors(() =>
this.getIssueHistoryUseCase.execute(new GetIssueHistoryQuery(issueId, session.user.id)),
);
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
if (error instanceof InvalidStatsPeriodException) {
throw new BadRequestException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,18 @@
import { StringDecorator } from '../../../../common/decorators/validators.js';
export class DailyIssueStatsQueryDto {
@StringDecorator({ isOptional: true })
projectId?: string;
/** YYYY-MM-DD, по умолчанию — 30 дней до to */
@StringDecorator({ isOptional: true })
from?: string;
/** YYYY-MM-DD включительно, по умолчанию — сегодня в timeZone */
@StringDecorator({ isOptional: true })
to?: string;
/** IANA-имя, например Europe/Moscow; по умолчанию UTC */
@StringDecorator({ isOptional: true })
timeZone?: string;
}
@@ -36,7 +36,7 @@ export class IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit
/** /**
* Файл виден, если видна хоть одна задача, к которой он прикреплён. * Файл виден, если видна хоть одна задача, к которой он прикреплён.
* Видимость проверяем через IIssueRepository.findById, чтобы правило * Видимость проверяем через IIssueRepository.findById, чтобы правило
* (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче. * (reporter/assignee/наблюдатель) жило в одном месте. Обычно файл прикреплён к одной задаче.
*/ */
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> { async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId); const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
@@ -8,6 +8,7 @@ import {
type IIssueRepository, type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js'; } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js'; import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js'; import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js';
import { AttachFileCommand } from './attach-file.command.js'; import { AttachFileCommand } from './attach-file.command.js';
@@ -15,7 +16,7 @@ import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js';
/** /**
* Прикрепляет к задаче файл, заранее загруженный через POST /files. * Прикрепляет к задаче файл, заранее загруженный через POST /files.
* Прикреплять может любой, кому видна задача (как и править её), * Прикреплять может тот, кто может править задачу (не наблюдатель),
* но только собственный файл — см. StoredFile.assertOwnedBy. * но только собственный файл — см. StoredFile.assertOwnedBy.
*/ */
@Injectable() @Injectable()
@@ -29,9 +30,13 @@ export class AttachFileUseCase {
) {} ) {}
async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> { async execute(command: AttachFileCommand): Promise<IssueAttachmentDto> {
if (!(await this.issueRepository.findById(command.issueId, command.userId))) { const issue = await this.issueRepository.findById(command.issueId, command.userId);
if (!issue) {
throw new IssueNotFoundException(command.issueId); throw new IssueNotFoundException(command.issueId);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.issueId);
}
const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId); const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId);
const attachment = await this.attachmentRepository.create( const attachment = await this.attachmentRepository.create(
@@ -8,12 +8,14 @@ import {
type IIssueRepository, type IIssueRepository,
} from '../../../../issue/domain/repositories/issue.repository.interface.js'; } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueEditForbiddenException } from '../../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js'; import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js';
import { DetachFileCommand } from './detach-file.command.js'; import { DetachFileCommand } from './detach-file.command.js';
/** /**
* Открепляет файл от задачи. Сам файл не удаляется: если он больше * Открепляет файл от задачи. Сам файл не удаляется: если он больше
* ни к чему не прикреплён, его удалит GC file-модуля. * ни к чему не прикреплён, его удалит GC file-модуля.
* Наблюдателям задачи недоступно — как и её правка.
*/ */
@Injectable() @Injectable()
export class DetachFileUseCase { export class DetachFileUseCase {
@@ -25,9 +27,13 @@ export class DetachFileUseCase {
) {} ) {}
async execute(command: DetachFileCommand): Promise<void> { async execute(command: DetachFileCommand): Promise<void> {
if (!(await this.issueRepository.findById(command.issueId, command.userId))) { const issue = await this.issueRepository.findById(command.issueId, command.userId);
if (!issue) {
throw new IssueNotFoundException(command.issueId); throw new IssueNotFoundException(command.issueId);
} }
if (!issue.canBeEditedBy(command.userId)) {
throw new IssueEditForbiddenException(command.issueId);
}
if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) { if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) {
throw new IssueAttachmentNotFoundException(command.issueId, command.fileId); throw new IssueAttachmentNotFoundException(command.issueId, command.fileId);
@@ -23,6 +23,7 @@ import { IssueAttachmentNotFoundException } from '../../domain/exceptions/issue-
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js'; import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js'; import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
import { IssueEditForbiddenException } from '../../../issue/domain/exceptions/issue-edit-forbidden.exception.js';
/** /**
* Вложения задачи. Сам файл загружается отдельно (POST /files), * Вложения задачи. Сам файл загружается отдельно (POST /files),
@@ -78,7 +79,7 @@ export class IssueAttachmentController {
) { ) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof FileForbiddenException) { if (error instanceof FileForbiddenException || error instanceof IssueEditForbiddenException) {
throw new ForbiddenException(error.message); throw new ForbiddenException(error.message);
} }
throw error; throw error;
@@ -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[],
) {} ) {}
} }
@@ -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));
@@ -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(),
@@ -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(),
@@ -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,
) {}
}
@@ -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();
});
});
@@ -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,11 +1,16 @@
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,
public readonly page: number, public readonly page: number,
public readonly userId: string, public readonly userId: string,
public readonly projectId?: string, public readonly projectId?: string,
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,
) {}
}
@@ -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[],
) {} ) {}
} }
@@ -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 () => {
@@ -72,6 +106,14 @@ describe('UpdateIssueUseCase', () => {
expect(repository.update).toHaveBeenCalledTimes(1); expect(repository.update).toHaveBeenCalledTimes(1);
}); });
it('passes the acting user to the repository as the author of the changes', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ assignee: 'user-7' }));
await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
expect(repository.update).toHaveBeenCalledWith(expect.any(Issue), 'user-7');
});
it('assigns and unassigns depending on the assignee value', async () => { it('assigns and unassigns depending on the assignee value', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue()); vi.mocked(repository.findById).mockResolvedValue(buildIssue());
@@ -106,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);
} }
await this.issueRepository.update(issue); // После assignee: новый исполнитель отбрасывается из наблюдателей
const dto = await this.issueUserExpander.expand(issue); if (command.observers !== undefined) {
issue.setObservers(command.observers);
if (issue.assignee) {
await this.projectRepository.addMember(issue.projectId, issue.assignee);
} }
if (command.tags !== undefined) {
issue.setTags(
(await this.tagRepository.findOrCreateByNames(issue.projectId, command.tags)).map(
IssueApplicationMapper.toTagRef,
),
);
}
await this.issueParticipants.ensureObserversExist(issue.observers.filter((id) => !observersBefore.includes(id)));
await this.issueRepository.update(issue, command.userId);
const dto = await this.issueUserExpander.expand(issue);
await this.issueParticipants.addToProject(issue);
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);
} }
@@ -0,0 +1,30 @@
/**
* Поля задачи, изменения которых пишутся в историю (таблица issue_change).
* created — служебное «поле»: строка о создании задачи, значения пустые.
*/
export const ISSUE_CHANGE_FIELDS = [
'created',
'title',
'content',
'status',
'priority',
'type',
'assignee',
'dueDate',
'observer',
'tag',
] as const;
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
/**
* Изменение одного поля задачи. Значения сериализованы в строки:
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
* observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id)
* или снят (oldValue = id, newValue = null). tag — так же, по строке на тег.
*/
export interface IssueFieldChange {
field: IssueChangeField;
oldValue: string | null;
newValue: string | null;
}
@@ -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,5 +163,261 @@ 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', () => {
const build = () =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Title',
content: null,
status: 'OPEN',
priority: 0,
type: 'TASK',
assignee: null,
reporter: 'user-1',
projectId: 'project-1',
dueDate: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
it('records creation and the initial OPEN status for a new issue', () => {
const issue = Issue.create({ key: 'ISSUE-1', title: 'Title', reporter: 'user-1', projectId: 'project-1' });
expect(issue.pullChanges()).toEqual([
{ field: 'created', oldValue: null, newValue: null },
{ field: 'status', oldValue: null, newValue: 'OPEN' },
]);
});
it('records every changed field with serialized values', () => {
const issue = build();
issue.updateTitle('New title');
issue.updateContent('Text');
issue.changeStatus('IN_PROGRESS');
issue.changePriority(3);
issue.changeType('BUG');
issue.assignTo('user-2');
issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z'));
expect(issue.pullChanges()).toEqual([
{ field: 'title', oldValue: 'Title', newValue: 'New title' },
{ field: 'content', oldValue: null, newValue: 'Text' },
{ field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' },
{ field: 'priority', oldValue: '0', newValue: '3' },
{ field: 'type', oldValue: 'TASK', newValue: 'BUG' },
{ field: 'assignee', oldValue: null, newValue: 'user-2' },
{ field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' },
]);
expect(issue.pullChanges()).toEqual([]);
});
it('ignores setting the same value: no change, no event, no updatedAt bump', () => {
const issue = build();
const before = issue.updatedAt;
issue.updateTitle('Title');
issue.changeStatus('OPEN');
issue.unassign();
expect(issue.pullChanges()).toEqual([]);
expect(issue.pullDomainEvents()).toEqual([]);
expect(issue.updatedAt).toBe(before);
});
it('merges repeated changes of a field and drops a change reverted to the original', () => {
const issue = build();
issue.changeStatus('IN_PROGRESS');
issue.changeStatus('DONE');
issue.assignTo('user-2');
issue.unassign();
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,13 @@
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';
interface IssueProps { interface IssueProps {
id: number | null; id: number | null;
@@ -11,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;
@@ -27,9 +36,17 @@ interface IssueProps {
* `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи * `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи
* он равен null до сохранения в репозитории. `key` (например, "ISSUE-1") * он равен null до сохранения в репозитории. `key` (например, "ISSUE-1")
* присваивается на уровне приложения при создании и не зависит от id. * присваивается на уровне приложения при создании и не зависит от id.
*
* Сущность копит изменения полей (pullChanges) — репозиторий сохраняет их
* в историю в той же транзакции, что и саму задачу.
*/ */
export class Issue { export class Issue {
private domainEvents: unknown[] = []; private domainEvents: unknown[] = [];
/**
* Несохранённые изменения полей, по одному на поле, в порядке изменения.
* Ключ — поле, для observer — поле и id наблюдателя, для tag — поле и id тега.
*/
private changes = new Map<string, IssueFieldChange>();
private constructor(private readonly props: IssueProps) {} private constructor(private readonly props: IssueProps) {}
@@ -39,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 {
@@ -51,15 +71,29 @@ 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(),
updatedAt: new Date(), updatedAt: new Date(),
}); });
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
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;
} }
@@ -72,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;
@@ -86,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,
@@ -119,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;
} }
@@ -127,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;
} }
@@ -144,40 +201,135 @@ export class Issue {
} }
updateTitle(title: string): void { updateTitle(title: string): void {
this.props.title = IssueTitle.create(title); const next = IssueTitle.create(title);
this.touch(); this.recordChange('title', this.title, next.value);
this.props.title = next;
} }
updateContent(content: string | null): void { updateContent(content: string | null): void {
this.recordChange('content', this.content, content);
this.props.content = content; this.props.content = content;
this.touch();
} }
changeStatus(status: string): void { changeStatus(status: string): void {
this.props.status = IssueStatus.create(status); const next = IssueStatus.create(status);
this.touch(); this.recordChange('status', this.status, next.value);
this.props.status = next;
} }
changePriority(priority: number): void { changePriority(priority: number): void {
this.props.priority = IssuePriority.create(priority); const next = IssuePriority.create(priority);
this.touch(); this.recordChange('priority', String(this.priority), String(next.value));
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.props.assignee = userId; this.props.assignee = userId;
this.touch(); this.addDomainEvent(new IssueAssignedEvent(this.key, userId));
// Исполнитель и так видит задачу и может её редактировать
this.removeObserver(userId);
} }
unassign(): void { unassign(): void {
this.recordChange('assignee', this.assignee, null);
this.props.assignee = null; this.props.assignee = null;
this.touch();
} }
updateDueDate(dueDate: Date | null): void { updateDueDate(dueDate: Date | null): void {
this.recordChange('dueDate', this.dueDate?.toISOString() ?? null, dueDate?.toISOString() ?? null);
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[] {
const changes = [...this.changes.values()];
this.changes.clear();
return changes;
}
/**
* Запоминает изменение поля. Присвоение того же значения — не изменение:
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
* сливается с предыдущей, а возврат к исходному значению её отменяет.
*/
private recordChange(
field: IssueChangeField,
oldValue: string | null,
newValue: string | null,
key: string = field,
): void {
if (oldValue === newValue) return;
const pending = this.changes.get(key);
if (!pending) {
this.changes.set(key, { field, oldValue, newValue });
} else if (pending.oldValue === newValue) {
this.changes.delete(key);
} else {
pending.newValue = newValue;
}
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,21 +4,43 @@ 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: {
limit: number; limit: number;
page: number; page: number;
userId: string; userId: string;
status?: string; status?: string[];
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) */
create(issue: Issue): Promise<Issue>; create(issue: Issue): Promise<Issue>;
update(issue: Issue): 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,
@@ -1,19 +1,69 @@
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';
import { splitCKey } from '../../../../domain/helpers/get-ckey.js'; import { splitCKey } from '../../../../domain/helpers/get-ckey.js';
import { Prisma } from '../../../../../../generated/prisma/client.js'; import { Prisma } from '../../../../../../generated/prisma/client.js';
import { IssueProjectNotFoundException } from '../../../../domain/exceptions/issue-project-not-found.exception.js'; import { IssueProjectNotFoundException } from '../../../../domain/exceptions/issue-project-not-found.exception.js';
import type { IssueFieldChange } from '../../../../domain/entities/issue-change.js';
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 */
function toChangeRows(
issueId: number,
changes: IssueFieldChange[],
actorId: string | null,
changedAt: Date,
): Prisma.IssueChangeCreateManyInput[] {
const changeId = randomUUID();
return changes.map((change) => ({ issueId, changeId, actorId, changedAt, ...change }));
} }
@Injectable() @Injectable()
@@ -22,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;
} }
@@ -30,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;
@@ -40,10 +92,13 @@ export class IssuePrismaRepository implements IIssueRepository {
limit: number; limit: number;
page: number; page: number;
userId: string; userId: string;
status?: string; status?: string[];
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);
@@ -51,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 ? [{ status: 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
? [ ? [
{ {
@@ -72,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' },
@@ -85,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 },
}); });
@@ -96,9 +172,18 @@ export class IssuePrismaRepository implements IIssueRepository {
} }
async create(issue: Issue): Promise<Issue> { async create(issue: Issue): Promise<Issue> {
const changes = issue.pullChanges();
try { try {
const record = await this.prisma.issue.create({ const record = await this.prisma.$transaction(async (tx) => {
const created = await tx.issue.create({
data: IssuePrismaMapper.toCreateInput(issue), data: IssuePrismaMapper.toCreateInput(issue),
include: WITH_RELATIONS,
});
await tx.issueChange.createMany({
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
});
return created;
}); });
return IssuePrismaMapper.toDomain(record); return IssuePrismaMapper.toDomain(record);
@@ -110,11 +195,47 @@ export class IssuePrismaRepository implements IIssueRepository {
} }
} }
async update(issue: Issue): Promise<void> { async update(issue: Issue, actorId: string): Promise<void> {
await this.prisma.issue.update({ const issueId = issue.id as number;
where: { id: issue.id as number }, const changes = issue.pullChanges();
const updateIssue = this.prisma.issue.update({
where: { id: issueId },
data: IssuePrismaMapper.toUpdateInput(issue), data: IssuePrismaMapper.toUpdateInput(issue),
}); });
if (changes.length === 0) {
await updateIssue;
return;
}
const observers = [...issue.observers];
const tagIds = issue.tags.map((tag) => tag.id);
await this.prisma.$transaction([
updateIssue,
// Наблюдатели и теги меняются только вместе с записью observer/tag в истории
...(changes.some((change) => change.field === 'observer')
? [
this.prisma.issueObserver.deleteMany({ where: { issueId, userId: { notIn: observers } } }),
this.prisma.issueObserver.createMany({
data: observers.map((userId) => ({ issueId, userId })),
skipDuplicates: true,
}),
]
: []),
...(changes.some((change) => change.field === 'tag')
? [
this.prisma.issueTag.deleteMany({ where: { issueId, tagId: { notIn: tagIds } } }),
this.prisma.issueTag.createMany({
data: tagIds.map((tagId) => ({ issueId, tagId })),
skipDuplicates: true,
}),
]
: []),
this.prisma.issueChange.createMany({
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
}),
]);
} }
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
+11 -1
View File
@@ -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[];
} }
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FilterIssueDto } from './filter-issue.dto.js';
const parse = (query: Record<string, unknown>) => {
const dto = plainToInstance(FilterIssueDto, query);
return { dto, errors: validateSync(dto) };
};
describe('FilterIssueDto.status', () => {
it('leaves status undefined when absent', () => {
const { dto, errors } = parse({});
expect(dto.status).toBeUndefined();
expect(errors).toHaveLength(0);
});
it('wraps a single status into an array', () => {
const { dto, errors } = parse({ status: 'OPEN' });
expect(dto.status).toEqual(['OPEN']);
expect(errors).toHaveLength(0);
});
it('splits a comma-separated list', () => {
const { dto, errors } = parse({ status: 'OPEN, IN_PROGRESS' });
expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']);
expect(errors).toHaveLength(0);
});
it('accepts a repeated query param', () => {
const { dto, errors } = parse({ status: ['OPEN', 'DONE'] });
expect(dto.status).toEqual(['OPEN', 'DONE']);
expect(errors).toHaveLength(0);
});
it('rejects an unknown status', () => {
const { errors } = parse({ status: 'OPEN,UNKNOWN' });
expect(errors.map((el) => el.property)).toEqual(['status']);
});
it('rejects an empty value', () => {
const { errors } = parse({ 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']);
});
});
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more