Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1b3754df | ||
|
|
c5691da2b8 | ||
|
|
afc37f8fdb | ||
|
|
8d02d25da0 | ||
|
|
5ad0ecdf4c | ||
|
|
7aecc6f96e | ||
|
|
cb29c7d2c4 | ||
|
|
f44590b8ca |
No files matched your search
@@ -11,3 +11,5 @@ coverage
|
|||||||
tsconfig.build.tsbuildinfo
|
tsconfig.build.tsbuildinfo
|
||||||
src/generated
|
src/generated
|
||||||
storage
|
storage
|
||||||
|
configs
|
||||||
|
service-account.json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Gitea Actions Demo
|
name: Gitea Actions
|
||||||
run-name: Deploy 🚀
|
run-name: Deploy 🚀
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+2412
-1054
File diff suppressed because it is too large.
Load diff
+18
-19
@@ -24,48 +24,47 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^5.5.1",
|
"@apollo/server": "^5.5.1",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
"@as-integrations/express5": "^1.1.2",
|
||||||
"@aws-sdk/client-s3": "^3.1145.0",
|
"@aws-sdk/client-s3": "^3.1152.0",
|
||||||
"@better-auth/prisma-adapter": "^1.7.2",
|
"@better-auth/prisma-adapter": "^1.7.11",
|
||||||
"@nestjs/apollo": "^14.0.0",
|
"@nestjs/apollo": "^14.0.0",
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.2.0",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.1",
|
||||||
"@nestjs/core": "^12.0.1",
|
"@nestjs/core": "^12.2.0",
|
||||||
"@nestjs/event-emitter": "^12.0.1",
|
"@nestjs/event-emitter": "^12.0.1",
|
||||||
"@nestjs/graphql": "^14.0.0",
|
"@nestjs/graphql": "^14.0.0",
|
||||||
"@nestjs/platform-express": "^12.0.1",
|
"@nestjs/platform-express": "^12.2.0",
|
||||||
"@nestjs/schedule": "^12.0.2",
|
"@nestjs/schedule": "^12.0.2",
|
||||||
"@nestjs/typeorm": "^12.0.1",
|
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"@thallesp/nestjs-better-auth": "^2.7.0",
|
"@thallesp/nestjs-better-auth": "^2.8.0",
|
||||||
"better-auth": "^1.7.2",
|
"better-auth": "^1.7.11",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"file-type": "^22.1.1",
|
"file-type": "^22.2.0",
|
||||||
|
"firebase-admin": "^14.5.0",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
"nodemailer": "^10.0.0",
|
"nodemailer": "^10.1.7",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^1.1.1",
|
"valibot": "^1.5.0"
|
||||||
"valibot": "^1.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^12.0.0",
|
"@nestjs/cli": "^12.0.0",
|
||||||
"@nestjs/mau": "^0.2.6",
|
"@nestjs/mau": "^0.2.8",
|
||||||
"@nestjs/schematics": "^12.0.0",
|
"@nestjs/schematics": "^12.0.0",
|
||||||
"@nestjs/testing": "^12.0.1",
|
"@nestjs/testing": "^12.2.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/multer": "^2.3.0",
|
"@types/multer": "^2.3.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/supertest": "^7.0.0",
|
"@types/supertest": "^7.2.1",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"oxlint": "^1.58.0",
|
"oxlint": "^1.88.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.9.10",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.3.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
|
|||||||
@@ -0,0 +1,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");
|
||||||
@@ -26,6 +26,7 @@ model User {
|
|||||||
notificationMessages NotificationMessage[]
|
notificationMessages NotificationMessage[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
uploadedFiles File[]
|
uploadedFiles File[]
|
||||||
|
issueChanges IssueChange[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -87,6 +88,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 +98,7 @@ model Issue {
|
|||||||
dueDate DateTime? @db.Timestamptz(3)
|
dueDate DateTime? @db.Timestamptz(3)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
attachments IssueAttachment[]
|
attachments IssueAttachment[]
|
||||||
|
changes IssueChange[]
|
||||||
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,6 +109,8 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,3 +269,29 @@ model CommentAttachment {
|
|||||||
@@index([fileId])
|
@@index([fileId])
|
||||||
@@map("comment_attachment")
|
@@map("comment_attachment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// История изменений задачи — одна строка на изменённое поле.
|
||||||
|
/// Строки одного сохранения задачи объединены общим changeId.
|
||||||
|
/// field: created | title | content | status | priority | assignee | dueDate
|
||||||
|
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
||||||
|
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
/// При создании задачи пишутся две строки: 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")
|
||||||
|
}
|
||||||
+4
-8
@@ -5,10 +5,12 @@ 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 { 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 +26,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 +45,12 @@ import { join } from 'node:path';
|
|||||||
ProjectModule,
|
ProjectModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
StatusModule,
|
StatusModule,
|
||||||
|
IssueTypeModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
CommentModule,
|
CommentModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
IssueAttachmentModule,
|
IssueAttachmentModule,
|
||||||
|
HistoryModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Transform, TransformFnParams } from 'class-transformer';
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
import { applyDecorators } from '@nestjs/common';
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
|
||||||
interface ToStringOptions {
|
interface ToStringOptions {
|
||||||
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
export interface StringDecoratorParams extends BaseDecoratorParams {}
|
||||||
|
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
|
||||||
export interface IntDecoratorParams extends BaseDecoratorParams {
|
export interface IntDecoratorParams extends BaseDecoratorParams {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
@@ -24,6 +25,15 @@ const ToNumber = () =>
|
|||||||
return Number.isNaN(parsed) ? undefined : parsed;
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */
|
||||||
|
const ToBoolean = () =>
|
||||||
|
Transform(({ value }) => {
|
||||||
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
const ToString = (options?: ToStringOptions) =>
|
const ToString = (options?: ToStringOptions) =>
|
||||||
Transform(({ value }: TransformFnParams) => {
|
Transform(({ value }: TransformFnParams) => {
|
||||||
if (value === undefined || value === null) return undefined;
|
if (value === undefined || value === null) return undefined;
|
||||||
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
|
|||||||
|
|
||||||
return applyDecorators(...args);
|
return applyDecorators(...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BooleanDecorator(params?: BooleanDecoratorParams) {
|
||||||
|
const args = [IsBoolean(), ToBoolean()];
|
||||||
|
|
||||||
|
if (params?.isOptional) args.push(IsOptional());
|
||||||
|
|
||||||
|
return applyDecorators(...args);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
|
||||||
|
|
||||||
export function pgConfig(config: ConfigService): TypeOrmModuleOptions {
|
export function pgConfig(config: ConfigService): unknown {
|
||||||
return {
|
return {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: config.getOrThrow<string>('DATABASE_URL'),
|
url: config.getOrThrow<string>('DATABASE_URL'),
|
||||||
|
|||||||
+22
-1
@@ -2,12 +2,33 @@ 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') {
|
||||||
|
// Send the OTP for sign in
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
|
||||||
|
void EmailSender.getTransporter().sendMail({
|
||||||
|
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 {
|
||||||
|
// Send the OTP for password reset
|
||||||
|
console.log({ email, otp, type });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: 'postgresql',
|
provider: 'postgresql',
|
||||||
@@ -24,7 +45,7 @@ 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 });
|
console.log('onExistingUserSignUp', { user });
|
||||||
|
|
||||||
|
|||||||
@@ -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: пользователи из 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[];
|
||||||
|
}
|
||||||
+12
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+77
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+73
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+105
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@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 (record.field === 'assignee') {
|
||||||
|
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 (record.field === 'assignee') {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
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 — то же правило, что
|
||||||
|
* ownedByUser в 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})
|
||||||
|
${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;
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ISSUE_TYPES } from '../../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ListIssueTypesUseCase {
|
||||||
|
execute(): typeof ISSUE_TYPES {
|
||||||
|
return ISSUE_TYPES;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { IssueTypeController } from './presentation/controllers/issue-type.controller.js';
|
||||||
|
import { IssueTypeResolver } from './presentation/resolvers/issue-type.resolver.js';
|
||||||
|
import { ListIssueTypesUseCase } from './application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [IssueTypeController],
|
||||||
|
providers: [ListIssueTypesUseCase, IssueTypeResolver],
|
||||||
|
})
|
||||||
|
export class IssueTypeModule {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
|
@Controller('issue-types')
|
||||||
|
export class IssueTypeController {
|
||||||
|
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(): typeof ISSUE_TYPES {
|
||||||
|
return this.listIssueTypesUseCase.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
/** Не `IssueType` — это имя занято value-объектом типа задачи */
|
||||||
|
@ObjectType('IssueTypeDefinition')
|
||||||
|
export class IssueTypeDefinitionType {
|
||||||
|
@Field()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
alias: string;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
|
||||||
|
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
|
||||||
|
import { IssueTypeDefinitionType } from '../graphql/issue-type-definition.type.js';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class IssueTypeResolver {
|
||||||
|
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
|
||||||
|
|
||||||
|
@Query(() => [IssueTypeDefinitionType], { name: 'issueTypes' })
|
||||||
|
list(): typeof ISSUE_TYPES {
|
||||||
|
return this.listIssueTypesUseCase.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IssueWithUsersDto } from './issue-with-users.dto.js';
|
||||||
|
|
||||||
|
export class CalendarIssuesDto {
|
||||||
|
list: IssueWithUsersDto[];
|
||||||
|
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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,
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
@@ -30,6 +31,7 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
|
||||||
|
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.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 {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,15 @@ 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';
|
||||||
|
|
||||||
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 useCase: CreateIssueUseCase;
|
let useCase: CreateIssueUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -20,6 +23,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(),
|
||||||
@@ -41,7 +45,14 @@ 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;
|
||||||
|
useCase = new CreateIssueUseCase(
|
||||||
|
repository,
|
||||||
|
projectRepository,
|
||||||
|
issueEventsService,
|
||||||
|
issueUserExpander,
|
||||||
|
new IssueAssignmentNotifierService(notificationsService),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +64,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 +83,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 +115,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 +132,58 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,7 @@ import { CreateIssueCommand } from './create-issue.command.js';
|
|||||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,6 +21,7 @@ export class CreateIssueUseCase {
|
|||||||
private readonly projectRepository: IProjectRepository,
|
private readonly projectRepository: IProjectRepository,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
private readonly issueUserExpander: IssueUserExpanderService,
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
||||||
@@ -28,6 +30,7 @@ export class CreateIssueUseCase {
|
|||||||
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,
|
||||||
projectId: command.projectId,
|
projectId: command.projectId,
|
||||||
@@ -42,6 +45,7 @@ export class CreateIssueUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -32,6 +33,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(),
|
||||||
|
|||||||
+2
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'user-2',
|
assignee: 'user-2',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -33,6 +34,7 @@ describe('GetIssueByCkeyUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+1
@@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
|||||||
content: null,
|
content: null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
priority: 0,
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
assignee: 'user-2',
|
assignee: 'user-2',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -33,6 +34,7 @@ describe('GetIssueUseCase', () => {
|
|||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
findByCKey: vi.fn(),
|
findByCKey: vi.fn(),
|
||||||
findAll: vi.fn(),
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
countByStatus: vi.fn(),
|
countByStatus: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
export class ListCalendarIssuesQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly from: Date,
|
||||||
|
public readonly to: Date,
|
||||||
|
public readonly projectId?: string,
|
||||||
|
public readonly status?: string[],
|
||||||
|
public readonly assigneeId?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||||
|
import { InvalidCalendarRangeException } from '../../../domain/exceptions/invalid-calendar-range.exception.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from './list-calendar-issues.use-case.js';
|
||||||
|
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
|
||||||
|
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
|
|
||||||
|
const FROM = new Date('2026-09-28T00:00:00+03:00');
|
||||||
|
const TO = new Date('2026-11-09T00:00:00+03:00');
|
||||||
|
|
||||||
|
const buildIssue = (id: number) =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id,
|
||||||
|
key: `ISSUE-${id}`,
|
||||||
|
title: `Issue ${id}`,
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
type: 'TASK',
|
||||||
|
assignee: null,
|
||||||
|
reporter: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: new Date('2026-10-15T12:00:00Z'),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ListCalendarIssuesUseCase', () => {
|
||||||
|
let repository: IIssueRepository;
|
||||||
|
let issueUserExpander: IssueUserExpanderService;
|
||||||
|
let useCase: ListCalendarIssuesUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByCKey: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findByDueDateRange: vi.fn(),
|
||||||
|
countByStatus: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
issueUserExpander = {
|
||||||
|
expandAll: vi.fn(async (issues: Issue[]) => issues.map((issue) => ({ id: issue.id }) as IssueWithUsersDto)),
|
||||||
|
} as unknown as IssueUserExpanderService;
|
||||||
|
useCase = new ListCalendarIssuesUseCase(repository, issueUserExpander);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the range and filters, asking for one issue more than the limit', async () => {
|
||||||
|
vi.mocked(repository.findByDueDateRange).mockResolvedValue([buildIssue(1), buildIssue(2)]);
|
||||||
|
|
||||||
|
const result = await useCase.execute(
|
||||||
|
new ListCalendarIssuesQuery('user-1', FROM, TO, 'project-1', ['OPEN'], 'user-2'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repository.findByDueDateRange).toHaveBeenCalledWith({
|
||||||
|
from: FROM,
|
||||||
|
to: TO,
|
||||||
|
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
status: ['OPEN'],
|
||||||
|
assigneeId: 'user-2',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ list: [{ id: 1 }, { id: 2 }], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts the list to the limit and marks it as truncated', async () => {
|
||||||
|
const issues = Array.from({ length: ListCalendarIssuesUseCase.MAX_ISSUES + 1 }, (_, index) =>
|
||||||
|
buildIssue(index + 1),
|
||||||
|
);
|
||||||
|
vi.mocked(repository.findByDueDateRange).mockResolvedValue(issues);
|
||||||
|
|
||||||
|
const result = await useCase.execute(new ListCalendarIssuesQuery('user-1', FROM, TO));
|
||||||
|
|
||||||
|
expect(result.truncated).toBe(true);
|
||||||
|
expect(result.list).toHaveLength(ListCalendarIssuesUseCase.MAX_ISSUES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid range without querying the repository', async () => {
|
||||||
|
await expect(useCase.execute(new ListCalendarIssuesQuery('user-1', TO, FROM))).rejects.toThrow(
|
||||||
|
InvalidCalendarRangeException,
|
||||||
|
);
|
||||||
|
expect(repository.findByDueDateRange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||||
|
import { CalendarRange } from '../../../domain/value-objects/calendar-range.vo.js';
|
||||||
|
import { CalendarIssuesDto } from '../../dto/calendar-issues.dto.js';
|
||||||
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Задачи для календаря: все задачи с dueDate в [from, to) без пагинации,
|
||||||
|
* но не больше MAX_ISSUES — иначе ответ помечается как truncated.
|
||||||
|
* Задачи без dueDate в календарь не попадают.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ListCalendarIssuesUseCase {
|
||||||
|
static readonly MAX_ISSUES = 500;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUE_REPOSITORY)
|
||||||
|
private readonly issueRepository: IIssueRepository,
|
||||||
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: ListCalendarIssuesQuery): Promise<CalendarIssuesDto> {
|
||||||
|
const range = CalendarRange.create({ from: query.from, to: query.to });
|
||||||
|
|
||||||
|
// Берём на одну больше лимита, чтобы узнать, есть ли что-то за ним
|
||||||
|
const issues = await this.issueRepository.findByDueDateRange({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
|
||||||
|
userId: query.userId,
|
||||||
|
projectId: query.projectId,
|
||||||
|
status: query.status,
|
||||||
|
assigneeId: query.assigneeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncated = issues.length > ListCalendarIssuesUseCase.MAX_ISSUES;
|
||||||
|
const list = truncated ? issues.slice(0, ListCalendarIssuesUseCase.MAX_ISSUES) : issues;
|
||||||
|
|
||||||
|
return { list: await this.issueUserExpander.expandAll(list), truncated };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ export class ListIssuesQuery {
|
|||||||
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[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -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,8 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,8 @@ 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';
|
||||||
|
|
||||||
const buildIssue = () =>
|
const buildIssue = () =>
|
||||||
Issue.reconstitute({
|
Issue.reconstitute({
|
||||||
@@ -17,6 +19,7 @@ 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',
|
||||||
@@ -30,6 +33,7 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
let projectRepository: IProjectRepository;
|
let projectRepository: IProjectRepository;
|
||||||
let issueEventsService: IssueEventsService;
|
let issueEventsService: IssueEventsService;
|
||||||
let issueUserExpander: IssueUserExpanderService;
|
let issueUserExpander: IssueUserExpanderService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
let useCase: UpdateIssueUseCase;
|
let useCase: UpdateIssueUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -37,6 +41,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(),
|
||||||
@@ -58,7 +63,14 @@ 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;
|
||||||
|
useCase = new UpdateIssueUseCase(
|
||||||
|
repository,
|
||||||
|
projectRepository,
|
||||||
|
issueEventsService,
|
||||||
|
issueUserExpander,
|
||||||
|
new IssueAssignmentNotifierService(notificationsService),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies only the fields provided in the command', async () => {
|
it('applies only the fields provided in the command', async () => {
|
||||||
@@ -72,6 +84,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());
|
||||||
|
|
||||||
|
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 +126,44 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,7 @@ import { UpdateIssueCommand } from './update-issue.command.js';
|
|||||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||||
|
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
|
||||||
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,6 +21,7 @@ export class UpdateIssueUseCase {
|
|||||||
private readonly projectRepository: IProjectRepository,
|
private readonly projectRepository: IProjectRepository,
|
||||||
private readonly issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
private readonly issueUserExpander: IssueUserExpanderService,
|
private readonly issueUserExpander: IssueUserExpanderService,
|
||||||
|
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
||||||
@@ -45,6 +47,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,7 +63,7 @@ export class UpdateIssueUseCase {
|
|||||||
issue.updateDueDate(command.dueDate);
|
issue.updateDueDate(command.dueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.issueRepository.update(issue);
|
await this.issueRepository.update(issue, command.userId);
|
||||||
const dto = await this.issueUserExpander.expand(issue);
|
const dto = await this.issueUserExpander.expand(issue);
|
||||||
|
|
||||||
if (issue.assignee) {
|
if (issue.assignee) {
|
||||||
@@ -65,6 +71,7 @@ export class UpdateIssueUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,26 @@
|
|||||||
|
/**
|
||||||
|
* Поля задачи, изменения которых пишутся в историю (таблица issue_change).
|
||||||
|
* created — служебное «поле»: строка о создании задачи, значения пустые.
|
||||||
|
*/
|
||||||
|
export const ISSUE_CHANGE_FIELDS = [
|
||||||
|
'created',
|
||||||
|
'title',
|
||||||
|
'content',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'type',
|
||||||
|
'assignee',
|
||||||
|
'dueDate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменение одного поля задачи. Значения сериализованы в строки:
|
||||||
|
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
*/
|
||||||
|
export interface IssueFieldChange {
|
||||||
|
field: IssueChangeField;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
describe('Issue', () => {
|
describe('Issue', () => {
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
@@ -18,6 +19,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 +32,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 +73,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 +97,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 +144,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 +162,90 @@ 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' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
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 { 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 type { IssueChangeField, IssueFieldChange } from './issue-change.js';
|
||||||
|
|
||||||
interface IssueProps {
|
interface IssueProps {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -11,6 +14,7 @@ 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;
|
||||||
@@ -27,9 +31,14 @@ 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[] = [];
|
||||||
|
/** Несохранённые изменения полей, по одному на поле, в порядке изменения */
|
||||||
|
private changes = new Map<IssueChangeField, IssueFieldChange>();
|
||||||
|
|
||||||
private constructor(private readonly props: IssueProps) {}
|
private constructor(private readonly props: IssueProps) {}
|
||||||
|
|
||||||
@@ -39,6 +48,7 @@ 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;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -51,6 +61,7 @@ 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,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
@@ -59,7 +70,12 @@ export class Issue {
|
|||||||
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));
|
||||||
|
}
|
||||||
|
|
||||||
return issue;
|
return issue;
|
||||||
}
|
}
|
||||||
@@ -72,6 +88,7 @@ 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;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -86,6 +103,7 @@ 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,
|
||||||
projectId: props.projectId,
|
projectId: props.projectId,
|
||||||
@@ -119,6 +137,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;
|
||||||
}
|
}
|
||||||
@@ -144,37 +166,76 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */
|
||||||
|
pullChanges(): IssueFieldChange[] {
|
||||||
|
const changes = [...this.changes.values()];
|
||||||
|
this.changes.clear();
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запоминает изменение поля. Присвоение того же значения — не изменение:
|
||||||
|
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
|
||||||
|
* сливается с предыдущей, а возврат к исходному значению её отменяет.
|
||||||
|
*/
|
||||||
|
private recordChange(field: IssueChangeField, oldValue: string | null, newValue: string | null): void {
|
||||||
|
if (oldValue === newValue) return;
|
||||||
|
|
||||||
|
const pending = this.changes.get(field);
|
||||||
|
if (!pending) {
|
||||||
|
this.changes.set(field, { field, oldValue, newValue });
|
||||||
|
} else if (pending.oldValue === newValue) {
|
||||||
|
this.changes.delete(field);
|
||||||
|
} else {
|
||||||
|
pending.newValue = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
this.touch();
|
this.touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
|
export class InvalidCalendarRangeException extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InvalidCalendarRangeException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,27 @@ export interface 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[];
|
||||||
}): 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,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,6 +10,7 @@ describe('IssuePrismaMapper', () => {
|
|||||||
content: 'Details',
|
content: 'Details',
|
||||||
status: 'IN_PROGRESS',
|
status: 'IN_PROGRESS',
|
||||||
priority: 2,
|
priority: 2,
|
||||||
|
type: 'BUG',
|
||||||
assigneeId: 'user-2',
|
assigneeId: 'user-2',
|
||||||
reporterId: 'user-1',
|
reporterId: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -26,6 +27,7 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ 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',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
@@ -61,6 +64,7 @@ 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');
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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,
|
||||||
projectId: record.projectId,
|
projectId: record.projectId,
|
||||||
@@ -27,6 +28,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,
|
||||||
reporterId: issue.reporter,
|
reporterId: issue.reporter,
|
||||||
projectId: issue.projectId,
|
projectId: issue.projectId,
|
||||||
@@ -42,6 +44,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,
|
||||||
|
|||||||
+82
-10
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -8,6 +9,7 @@ import { PaginationMeta } from '../../../../../../common/interfaces/pagination-m
|
|||||||
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';
|
||||||
|
|
||||||
@@ -16,6 +18,32 @@ function ownedByUser(userId: string) {
|
|||||||
return [{ assigneeId: userId }, { reporterId: userId }];
|
return [{ assigneeId: userId }, { reporterId: userId }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Видимость и фильтры, общие для списка и календаря */
|
||||||
|
function commonFilters(params: {
|
||||||
|
userId: string;
|
||||||
|
status?: string[];
|
||||||
|
assigneeId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
}): Prisma.IssueWhereInput[] {
|
||||||
|
return [
|
||||||
|
{ OR: ownedByUser(params.userId) },
|
||||||
|
...(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()
|
||||||
export class IssuePrismaRepository implements IIssueRepository {
|
export class IssuePrismaRepository implements IIssueRepository {
|
||||||
constructor(private readonly prisma: PrismaAdapter) {}
|
constructor(private readonly prisma: PrismaAdapter) {}
|
||||||
@@ -40,10 +68,12 @@ 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[];
|
||||||
}): 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 +81,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
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -85,6 +114,26 @@ 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 } }],
|
||||||
|
},
|
||||||
|
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'],
|
||||||
@@ -96,9 +145,17 @@ 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) => {
|
||||||
data: IssuePrismaMapper.toCreateInput(issue),
|
const created = await tx.issue.create({
|
||||||
|
data: IssuePrismaMapper.toCreateInput(issue),
|
||||||
|
});
|
||||||
|
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 +167,26 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
updateIssue,
|
||||||
|
this.prisma.issueChange.createMany({
|
||||||
|
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
async delete(id: number): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { 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 +15,12 @@ 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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ProjectModule, UserModule],
|
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule],
|
||||||
controllers: [IssueController],
|
controllers: [IssueController],
|
||||||
providers: [
|
providers: [
|
||||||
CreateIssueUseCase,
|
CreateIssueUseCase,
|
||||||
@@ -27,9 +30,11 @@ import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/ge
|
|||||||
UpdateIssueUseCase,
|
UpdateIssueUseCase,
|
||||||
DeleteIssueUseCase,
|
DeleteIssueUseCase,
|
||||||
GetIssueStatsUseCase,
|
GetIssueStatsUseCase,
|
||||||
|
ListCalendarIssuesUseCase,
|
||||||
IssueResolver,
|
IssueResolver,
|
||||||
IssueEventsService,
|
IssueEventsService,
|
||||||
IssueUserExpanderService,
|
IssueUserExpanderService,
|
||||||
|
IssueAssignmentNotifierService,
|
||||||
{
|
{
|
||||||
// Domain видит только интерфейс IIssueRepository,
|
// Domain видит только интерфейс IIssueRepository,
|
||||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -37,6 +38,10 @@ 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';
|
||||||
|
|
||||||
@Controller('issues')
|
@Controller('issues')
|
||||||
export class IssueController {
|
export class IssueController {
|
||||||
@@ -48,6 +53,7 @@ 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 issueEventsService: IssueEventsService,
|
private readonly issueEventsService: IssueEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -64,6 +70,7 @@ 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -80,6 +87,25 @@ export class IssueController {
|
|||||||
query.status,
|
query.status,
|
||||||
query.text,
|
query.text,
|
||||||
query.assigneeId,
|
query.assigneeId,
|
||||||
|
query.hasDueDate,
|
||||||
|
query.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задачи с 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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,6 +169,7 @@ 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -162,6 +189,9 @@ 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) {
|
||||||
|
throw new BadRequestException(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,5 @@
|
|||||||
import { IsDateString, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
export class CreateIssueRequestDto {
|
export class CreateIssueRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -24,6 +25,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;
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
|
import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js';
|
||||||
|
|
||||||
export class FilterIssueDto {
|
export class FilterIssueDto {
|
||||||
@IntDecorator({ isOptional: true })
|
@IntDecorator({ isOptional: true })
|
||||||
@@ -13,9 +14,16 @@ export class FilterIssueDto {
|
|||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
|
||||||
@StringDecorator({ isOptional: true })
|
@IssueStatusListDecorator()
|
||||||
status?: string;
|
status?: string[];
|
||||||
|
|
||||||
|
@IssueTypeListDecorator()
|
||||||
|
type?: string[];
|
||||||
|
|
||||||
@StringDecorator({ isOptional: true })
|
@StringDecorator({ isOptional: true })
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
||||||
|
/** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */
|
||||||
|
@BooleanDecorator({ isOptional: true })
|
||||||
|
hasDueDate?: boolean;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator';
|
||||||
|
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
|
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
|
||||||
|
function AliasListDecorator(aliases: readonly string[]) {
|
||||||
|
return applyDecorators(
|
||||||
|
IsOptional(),
|
||||||
|
Transform(({ value }) =>
|
||||||
|
value === undefined || value === null
|
||||||
|
? undefined
|
||||||
|
: ([] as unknown[])
|
||||||
|
.concat(value)
|
||||||
|
.flatMap((el) => String(el).split(','))
|
||||||
|
.map((el) => el.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
IsArray(),
|
||||||
|
ArrayNotEmpty(),
|
||||||
|
IsIn(aliases, { each: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueStatusListDecorator() {
|
||||||
|
return AliasListDecorator(ISSUE_STATUSES.map((status) => status.alias));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?type=BUG,FEATURE` или `?type=BUG&type=FEATURE` */
|
||||||
|
export function IssueTypeListDecorator() {
|
||||||
|
return AliasListDecorator(ISSUE_TYPE_ALIASES);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
export class UpdateIssueRequestDto {
|
export class UpdateIssueRequestDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -21,6 +22,10 @@ export class UpdateIssueRequestDto {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
assignee?: string | null;
|
assignee?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { IssueDetailType } from './issue-detail.type.js';
|
||||||
|
|
||||||
|
@ObjectType('CalendarIssues')
|
||||||
|
export class CalendarIssuesType {
|
||||||
|
@Field(() => [IssueDetailType])
|
||||||
|
list: IssueDetailType[];
|
||||||
|
|
||||||
|
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
|
||||||
|
@Field()
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||||
import { IsDate, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsDate, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class CreateIssueInput {
|
export class CreateIssueInput {
|
||||||
@@ -31,6 +32,11 @@ export class CreateIssueInput {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@Field({ nullable: true })
|
@Field({ nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export class IssueDetailType {
|
|||||||
@Field(() => Int)
|
@Field(() => Int)
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
type: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class IssueType {
|
|||||||
@Field(() => Int)
|
@Field(() => Int)
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
type: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
assigneeId: string | null;
|
assigneeId: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Field, InputType, Int } from '@nestjs/graphql';
|
import { Field, InputType, Int } from '@nestjs/graphql';
|
||||||
import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
|
||||||
|
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class UpdateIssueInput {
|
export class UpdateIssueInput {
|
||||||
@@ -27,6 +28,11 @@ export class UpdateIssueInput {
|
|||||||
@Max(4)
|
@Max(4)
|
||||||
priority?: number;
|
priority?: number;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(ISSUE_TYPE_ALIASES)
|
||||||
|
type?: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||||
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
|
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
|
||||||
@@ -21,6 +21,11 @@ import { IssueDetailType } from '../graphql/issue-detail.type.js';
|
|||||||
import { IssuePageType } from '../graphql/issue-page.type.js';
|
import { IssuePageType } from '../graphql/issue-page.type.js';
|
||||||
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
import { CreateIssueInput } from '../graphql/create-issue.input.js';
|
||||||
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
|
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
|
||||||
|
import { CalendarIssuesType } from '../graphql/calendar-issues.type.js';
|
||||||
|
import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
|
||||||
|
import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js';
|
||||||
|
import { CalendarIssuesDto } from '../../application/dto/calendar-issues.dto.js';
|
||||||
|
import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js';
|
||||||
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
|
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
|
||||||
|
|
||||||
@Resolver(() => IssueType)
|
@Resolver(() => IssueType)
|
||||||
@@ -32,6 +37,7 @@ export class IssueResolver {
|
|||||||
private readonly listIssuesUseCase: ListIssuesUseCase,
|
private readonly listIssuesUseCase: ListIssuesUseCase,
|
||||||
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
private readonly updateIssueUseCase: UpdateIssueUseCase,
|
||||||
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
private readonly deleteIssueUseCase: DeleteIssueUseCase,
|
||||||
|
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Query(() => IssueDetailType, { name: 'issue' })
|
@Query(() => IssueDetailType, { name: 'issue' })
|
||||||
@@ -50,8 +56,39 @@ export class IssueResolver {
|
|||||||
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
||||||
@Session() session: UserSession,
|
@Session() session: UserSession,
|
||||||
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||||
|
@Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean,
|
||||||
|
@Args('type', { type: () => [String], nullable: true }) type?: string[],
|
||||||
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
||||||
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page, session.user.id, projectId));
|
return this.listIssuesUseCase.execute(
|
||||||
|
new ListIssuesQuery(
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
session.user.id,
|
||||||
|
projectId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
hasDueDate ?? undefined,
|
||||||
|
type ?? undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Задачи с dueDate в [from, to) — для календаря, без пагинации */
|
||||||
|
@Query(() => CalendarIssuesType, { name: 'calendarIssues' })
|
||||||
|
calendar(
|
||||||
|
@Args('from', { type: () => Date }) from: Date,
|
||||||
|
@Args('to', { type: () => Date }) to: Date,
|
||||||
|
@Session() session: UserSession,
|
||||||
|
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||||
|
@Args('status', { type: () => [String], nullable: true }) status?: string[],
|
||||||
|
@Args('assigneeId', { type: () => String, nullable: true }) assigneeId?: string,
|
||||||
|
): Promise<CalendarIssuesDto> {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.listCalendarIssuesUseCase.execute(
|
||||||
|
new ListCalendarIssuesQuery(session.user.id, from, to, projectId, status, assigneeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => IssueType, { name: 'createIssue' })
|
@Mutation(() => IssueType, { name: 'createIssue' })
|
||||||
@@ -67,6 +104,7 @@ export class IssueResolver {
|
|||||||
input.priority,
|
input.priority,
|
||||||
input.assignee,
|
input.assignee,
|
||||||
input.dueDate,
|
input.dueDate,
|
||||||
|
input.type,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -89,6 +127,7 @@ export class IssueResolver {
|
|||||||
input.priority,
|
input.priority,
|
||||||
input.assignee,
|
input.assignee,
|
||||||
input.dueDate,
|
input.dueDate,
|
||||||
|
input.type,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -108,6 +147,9 @@ export class IssueResolver {
|
|||||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||||
throw new NotFoundException(error.message);
|
throw new NotFoundException(error.message);
|
||||||
}
|
}
|
||||||
|
if (error instanceof InvalidCalendarRangeException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,22 @@ in-process pub/sub уже используется в issue-модуле
|
|||||||
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
|
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
|
||||||
(общая БД, синхронный поход не нужен) и шлёт через существующий
|
(общая БД, синхронный поход не нужен) и шлёт через существующий
|
||||||
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
|
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
|
||||||
- `PushChannelSender` — читает токены устройств из `notification_device_token`,
|
- `PushChannelSender` — читает токены устройств (Firebase Installation ID)
|
||||||
но сама отправка — заглушка (лог вместо реального вызова). Провайдер
|
из `notification_device_token` и шлёт через Firebase Cloud Messaging
|
||||||
(FCM/APNs) ещё не подключён; когда появится, меняется только тело
|
(`firebase-admin`, `sendEachForMulticast`, `subject` → title, `content` → body).
|
||||||
`send()`, остальной пайплайн — нет.
|
Токены, которые FCM вернул как незарегистрированные, удаляются. Отправка
|
||||||
|
`FAILED`, если ни одно устройство не приняло сообщение.
|
||||||
|
|
||||||
|
### Конфигурация Firebase
|
||||||
|
|
||||||
|
`infrastructure/firebase/firebase-messaging.provider.ts`, источники по приоритету:
|
||||||
|
|
||||||
|
- `FIREBASE_SERVICE_ACCOUNT` — содержимое JSON-ключа сервисного аккаунта
|
||||||
|
(как есть или в base64). Основной вариант для деплоя.
|
||||||
|
- `GOOGLE_APPLICATION_CREDENTIALS` — путь к JSON-файлу ключа (удобно локально),
|
||||||
|
`FIREBASE_PROJECT_ID` — опционально.
|
||||||
|
|
||||||
|
Без них приложение стартует, но push-отправки получают статус `FAILED`.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -90,6 +102,7 @@ in-process pub/sub уже используется в issue-модуле
|
|||||||
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
|
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
|
||||||
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
|
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
|
||||||
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
|
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
|
||||||
|
| `POST` | `/test-push` | `{ title?, body? }` | Тестовый push текущему пользователю на все его устройства → `202`. Результат доставки — в `notification_message` |
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
@@ -113,7 +126,6 @@ notification.module.ts — сборка DI
|
|||||||
|
|
||||||
## Чего пока нет (сознательно, вне текущего объёма)
|
## Чего пока нет (сознательно, вне текущего объёма)
|
||||||
|
|
||||||
- Реального push-провайдера (FCM/APNs) — только заглушка с логированием.
|
|
||||||
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
|
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
|
||||||
- SMS-канала — при необходимости добавляется как ещё одно значение в
|
- SMS-канала — при необходимости добавляется как ещё одно значение в
|
||||||
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
|
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
|
||||||
@@ -7,5 +7,7 @@ export interface IDeviceTokenRepository {
|
|||||||
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
|
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
|
||||||
upsert(userId: string, token: string, platform: string): Promise<void>;
|
upsert(userId: string, token: string, platform: string): Promise<void>;
|
||||||
remove(userId: string, token: string): Promise<void>;
|
remove(userId: string, token: string): Promise<void>;
|
||||||
|
/** Удаляет сразу несколько токенов пользователя, например протухшие после ответа FCM */
|
||||||
|
removeMany(userId: string, tokens: string[]): Promise<void>;
|
||||||
findByUserId(userId: string): Promise<NotificationDeviceToken[]>;
|
findByUserId(userId: string): Promise<NotificationDeviceToken[]>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Logger, type Provider } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { applicationDefault, cert, getApps, initializeApp, type App, type ServiceAccount } from 'firebase-admin/app';
|
||||||
|
import { getMessaging, type Messaging } from 'firebase-admin/messaging';
|
||||||
|
|
||||||
|
/** DI-токен для клиента FCM; значение null — Firebase не сконфигурирован */
|
||||||
|
export const FIREBASE_MESSAGING = Symbol('FIREBASE_MESSAGING');
|
||||||
|
|
||||||
|
const FIREBASE_APP_NAME = 'notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Источники credentials по приоритету:
|
||||||
|
* 1. FIREBASE_SERVICE_ACCOUNT — содержимое JSON-ключа сервисного аккаунта
|
||||||
|
* (как есть или в base64), удобно для деплоя через переменные окружения/секреты.
|
||||||
|
* 2. GOOGLE_APPLICATION_CREDENTIALS — путь к JSON-файлу (стандартный механизм Google, удобно локально).
|
||||||
|
* Если нет ни того, ни другого, провайдер отдаёт null и push-канал помечает отправки как FAILED.
|
||||||
|
*/
|
||||||
|
export const firebaseMessagingProvider: Provider = {
|
||||||
|
provide: FIREBASE_MESSAGING,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): Messaging | null => {
|
||||||
|
const app = getApps().find((candidate) => candidate.name === FIREBASE_APP_NAME) ?? createApp(config);
|
||||||
|
return app ? getMessaging(app) : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function createApp(config: ConfigService): App | null {
|
||||||
|
const serviceAccountRaw = config.get<string>('FIREBASE_SERVICE_ACCOUNT');
|
||||||
|
if (serviceAccountRaw) {
|
||||||
|
return initializeApp({ credential: cert(parseServiceAccount(serviceAccountRaw)) }, FIREBASE_APP_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.get<string>('GOOGLE_APPLICATION_CREDENTIALS')) {
|
||||||
|
return initializeApp(
|
||||||
|
{ credential: applicationDefault(), projectId: config.get<string>('FIREBASE_PROJECT_ID') },
|
||||||
|
FIREBASE_APP_NAME,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Logger('FirebaseMessaging').warn(
|
||||||
|
'Neither FIREBASE_SERVICE_ACCOUNT nor GOOGLE_APPLICATION_CREDENTIALS is set, push delivery is disabled',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServiceAccount(raw: string): ServiceAccount {
|
||||||
|
const json = raw.trim().startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf-8');
|
||||||
|
return JSON.parse(json) as ServiceAccount;
|
||||||
|
}
|
||||||
+4
@@ -19,6 +19,10 @@ export class DeviceTokenPrismaRepository implements IDeviceTokenRepository {
|
|||||||
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
|
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeMany(userId: string, tokens: string[]): Promise<void> {
|
||||||
|
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token: { in: tokens } } });
|
||||||
|
}
|
||||||
|
|
||||||
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
|
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
|
||||||
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
|
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,79 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { Messaging } from 'firebase-admin/messaging';
|
||||||
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
|
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
|
||||||
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
||||||
import {
|
import {
|
||||||
DEVICE_TOKEN_REPOSITORY,
|
DEVICE_TOKEN_REPOSITORY,
|
||||||
type IDeviceTokenRepository,
|
type IDeviceTokenRepository,
|
||||||
} from '../../domain/repositories/device-token.repository.interface.js';
|
} from '../../domain/repositories/device-token.repository.interface.js';
|
||||||
|
import { FIREBASE_MESSAGING } from '../firebase/firebase-messaging.provider.js';
|
||||||
|
|
||||||
|
/** Лимит FCM на количество адресатов в одном sendEachForMulticast */
|
||||||
|
const FCM_MULTICAST_LIMIT = 500;
|
||||||
|
|
||||||
|
/** Коды ошибок FCM, после которых токен устройства больше не валиден и его можно удалить */
|
||||||
|
const STALE_TOKEN_ERROR_CODES = new Set([
|
||||||
|
'messaging/installation-id-not-registered',
|
||||||
|
'messaging/registration-token-not-registered',
|
||||||
|
'messaging/invalid-registration-token',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Провайдер push-доставки (FCM/APNs) пока не подключён — отправка логируется.
|
* Отправка push через Firebase Cloud Messaging. В notification_device_token.token
|
||||||
* Токены уже читаются из БД, так что при подключении провайдера
|
* хранится Firebase Installation ID (FID) устройства.
|
||||||
* меняется только тело send(), остальной пайплайн трогать не нужно.
|
* Отправка считается успешной, если сообщение принято FCM хотя бы для одного устройства.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PushChannelSender implements NotificationChannelSender {
|
export class PushChannelSender implements NotificationChannelSender {
|
||||||
private readonly logger = new Logger(PushChannelSender.name);
|
private readonly logger = new Logger(PushChannelSender.name);
|
||||||
readonly channel: NotificationChannelValue = 'PUSH';
|
readonly channel: NotificationChannelValue = 'PUSH';
|
||||||
|
|
||||||
constructor(@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository) {}
|
constructor(
|
||||||
|
@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository,
|
||||||
|
@Inject(FIREBASE_MESSAGING) private readonly messaging: Messaging | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
async send(params: { userId: string; content: string }): Promise<void> {
|
async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
|
||||||
const tokens = await this.deviceTokens.findByUserId(params.userId);
|
if (!this.messaging) {
|
||||||
|
throw new Error('Push provider is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = (await this.deviceTokens.findByUserId(params.userId)).map((deviceToken) => deviceToken.token);
|
||||||
if (tokens.length === 0) {
|
if (tokens.length === 0) {
|
||||||
throw new Error(`No device tokens registered for user ${params.userId}`);
|
throw new Error(`No device tokens registered for user ${params.userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
let successCount = 0;
|
||||||
`Push provider is not configured yet, skipping actual delivery to ${tokens.length} device(s): ${params.content}`,
|
const staleTokens: string[] = [];
|
||||||
);
|
const errors = new Set<string>();
|
||||||
|
|
||||||
|
for (let offset = 0; offset < tokens.length; offset += FCM_MULTICAST_LIMIT) {
|
||||||
|
const fids = tokens.slice(offset, offset + FCM_MULTICAST_LIMIT);
|
||||||
|
const response = await this.messaging.sendEachForMulticast({
|
||||||
|
fids,
|
||||||
|
notification: { title: params.subject, body: params.content },
|
||||||
|
});
|
||||||
|
|
||||||
|
successCount += response.successCount;
|
||||||
|
response.responses.forEach((result, index) => {
|
||||||
|
if (result.success || !result.error) return;
|
||||||
|
if (STALE_TOKEN_ERROR_CODES.has(result.error.code)) {
|
||||||
|
staleTokens.push(fids[index]);
|
||||||
|
} else {
|
||||||
|
errors.add(`${result.error.code}: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staleTokens.length > 0) {
|
||||||
|
this.logger.log(`Removing ${staleTokens.length} stale device token(s) of user ${params.userId}`);
|
||||||
|
await this.deviceTokens.removeMany(params.userId, staleTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Push was not delivered to any of ${tokens.length} device(s): ${[...errors].join('; ') || 'all tokens are stale'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { NotificationPreferencePrismaRepository } from './infrastructure/persist
|
|||||||
import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js';
|
import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js';
|
||||||
import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js';
|
import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js';
|
||||||
import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
|
import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
|
||||||
|
import { firebaseMessagingProvider } from './infrastructure/firebase/firebase-messaging.provider.js';
|
||||||
import { NotificationsService } from './application/services/notifications.service.js';
|
import { NotificationsService } from './application/services/notifications.service.js';
|
||||||
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
|
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
|
||||||
import { NotificationController } from './presentation/controllers/notification.controller.js';
|
import { NotificationController } from './presentation/controllers/notification.controller.js';
|
||||||
@@ -30,6 +31,7 @@ import { NotificationController } from './presentation/controllers/notification.
|
|||||||
provide: NOTIFICATION_MESSAGE_REPOSITORY,
|
provide: NOTIFICATION_MESSAGE_REPOSITORY,
|
||||||
useClass: NotificationMessagePrismaRepository,
|
useClass: NotificationMessagePrismaRepository,
|
||||||
},
|
},
|
||||||
|
firebaseMessagingProvider,
|
||||||
EmailChannelSender,
|
EmailChannelSender,
|
||||||
PushChannelSender,
|
PushChannelSender,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { NotificationsService } from '../../application/services/notifications.s
|
|||||||
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
|
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
|
||||||
import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js';
|
import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js';
|
||||||
import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js';
|
import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js';
|
||||||
|
import { SendTestPushDto } from '../dto/send-test-push.dto.js';
|
||||||
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
|
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
|
||||||
|
|
||||||
@Controller('notifications')
|
@Controller('notifications')
|
||||||
@@ -48,6 +49,21 @@ export class NotificationController {
|
|||||||
return this.notifications.setPreference(session.user.id, channel, dto.enabled);
|
return this.notifications.setPreference(session.user.id, channel, dto.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тестовый push текущему пользователю на все его зарегистрированные устройства.
|
||||||
|
* Идёт через обычный пайплайн (fire-and-forget), результат — в notification_message.
|
||||||
|
*/
|
||||||
|
@Post('test-push')
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
sendTestPush(@Body() dto: SendTestPushDto, @Session() session: UserSession): void {
|
||||||
|
this.notifications.notify({
|
||||||
|
userId: session.user.id,
|
||||||
|
subject: dto.title ?? 'Тестовое уведомление',
|
||||||
|
content: dto.body ?? 'Push-уведомления работают',
|
||||||
|
channels: ['PUSH'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */
|
/** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */
|
||||||
private parseChannel(value: string) {
|
private parseChannel(value: string) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SendTestPushDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
нужна помощь по архитектуре, есть основной сервис заявок и сервис уведомлений, уведомления должны обладать категориями и типами (пуш, емеил). Связываются сервисы через брокер (nats). Как лучше организовать хранение данных. В каком сервисе ххранить категории, где хранить пуш токены, сами сообщения и тд. Что передавать через брокер в каком виде.
|
|
||||||
|
|
||||||
## Общий принцип
|
|
||||||
|
|
||||||
Каждый сервис — источник правды только для своих данных (database-per-service). Сервис заявок не должен знать о пуш-токенах, шаблонах писем и т.п. Сервис уведомлений не должен знать бизнес-логику заявок — только достаточно данных, чтобы отрендерить сообщение.
|
|
||||||
|
|
||||||
## Где что хранить
|
|
||||||
|
|
||||||
**Сервис заявок (source):**
|
|
||||||
- Сами заявки и их статусы — как обычно.
|
|
||||||
- Ничего про уведомления не хранит вообще (ни каналы, ни адреса, ни статусы доставки).
|
|
||||||
|
|
||||||
**Сервис уведомлений (owner всего, что касается доставки):**
|
|
||||||
- **Категории уведомлений** (`category`: code, name, важность, дефолтные каналы) — справочник здесь. Категория — это про то, *как доставлять*, а не бизнес-сущность заявок.
|
|
||||||
- **Типы/каналы** (push, email, позже sms) — тоже справочник/enum здесь.
|
|
||||||
- **Подписки/настройки пользователя** — какие категории по каким каналам получать (`user_id, category, channel, enabled`).
|
|
||||||
- **Push-токены устройств** — таблица `device_tokens (user_id, token, platform, updated_at)`. Регистрируются напрямую клиентским приложением через API notification-сервиса, а не через сервис заявок.
|
|
||||||
- **Email-адреса** — либо дублируются с синхронизацией из identity/user-сервиса (если он есть), либо приходят вместе с событием как поле payload'а (см. ниже). Дублировать с явной ревалидацией — обычно надёжнее, чем ходить синхронно в другой сервис при каждой отправке.
|
|
||||||
- **Шаблоны сообщений** — тоже здесь.
|
|
||||||
- **Сами сообщения/лог отправок** — таблица `notifications (id, correlation_id, category, channel, recipient, status, payload, sent_at, delivered_at, error)`. Это и есть история/аудит уведомлений.
|
|
||||||
|
|
||||||
## Что передавать через брокер и в каком виде
|
|
||||||
|
|
||||||
Через NATS должны идти **доменные события**, а не команды "отправь уведомление". Сервис заявок публикует факт бизнес-события, сервис уведомлений сам решает, нужно ли и как на него реагировать — это держит сервисы независимыми (заявки не знают о существовании уведомлений в принципе).
|
|
||||||
|
|
||||||
Рекомендации:
|
|
||||||
|
|
||||||
**Subject naming:**
|
|
||||||
```
|
|
||||||
orders.request.created
|
|
||||||
orders.request.status_changed
|
|
||||||
orders.request.cancelled
|
|
||||||
```
|
|
||||||
|
|
||||||
**Формат события (envelope):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event_id": "uuid", // для идемпотентности/дедупликации
|
|
||||||
"event_type": "orders.request.status_changed",
|
|
||||||
"version": 1,
|
|
||||||
"occurred_at": "2026-09-22T09:00:00Z",
|
|
||||||
"aggregate_id": "request-uuid",
|
|
||||||
"actor_id": "user-uuid",
|
|
||||||
"payload": {
|
|
||||||
"status": "approved",
|
|
||||||
"recipient_user_id": "user-uuid"
|
|
||||||
// минимально необходимые поля для рендера, не вся заявка целиком
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Важные моменты:
|
|
||||||
- **event_id** обязателен — по нему notification-сервис дедуплицирует (at-least-once доставка в NATS/JetStream — норма, а не баг).
|
|
||||||
- **"Полу-жирные" события**: не гоняйте туда-сюда синхронные запросы за деталями заявки при каждом уведомлении (это связывает сервисы и роняет доступность notification-сервиса при падении orders). Но и не пихайте в событие всю заявку целиком. Кладите ровно то, что нужно для шаблонов (статус, имя, сумма — по необходимости), остальное — по `aggregate_id` через API orders, если это редкий кейс.
|
|
||||||
- **Версионирование** payload'а (`version`) — пригодится, когда формат события будет меняться.
|
|
||||||
- Используйте **JetStream** (persistent streams), а не core NATS, чтобы события не терялись при недоступности notification-сервиса.
|
|
||||||
|
|
||||||
## Поток обработки в notification-сервисе
|
|
||||||
|
|
||||||
1. Consumer получает событие `orders.request.status_changed`.
|
|
||||||
2. По `event_type` находит маппинг → категорию уведомления (это конфигурация внутри notification-сервиса, orders о ней не знает).
|
|
||||||
3. Смотрит подписки пользователя по этой категории → определяет каналы (push/email).
|
|
||||||
4. Рендерит шаблон, создаёт запись в `notifications` со статусом `queued`.
|
|
||||||
5. Отправляет через провайдера (FCM/APNs, SMTP/SES и т.д.), обновляет статус (`sent`/`failed`/`delivered`).
|
|
||||||
6. Опционально публикует обратное событие `notifications.sent` / `notifications.failed` — полезно для аналитики/аудита, но orders-сервису это обычно не нужно подписывать.
|
|
||||||
|
|
||||||
## Итого — граница ответственности
|
|
||||||
|
|
||||||
| Данные | Где хранятся |
|
|
||||||
|---|---|
|
|
||||||
| Заявки | orders-сервис |
|
|
||||||
| Категории и каналы уведомлений | notification-сервис |
|
|
||||||
| Push-токены | notification-сервис |
|
|
||||||
| Настройки подписок пользователя | notification-сервис |
|
|
||||||
| Шаблоны и текст сообщений | notification-сервис |
|
|
||||||
| История/статусы отправленных уведомлений | notification-сервис |
|
|
||||||
| Событие через брокер | orders → notification, "тонкое" доменное событие с event_id |
|
|
||||||
|
|
||||||
Если понадобится — могу накидать примерную схему БД (таблицы notification-сервиса) или пример NATS consumer'а с dedup-логикой.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
а как пуш токены появляются в сервисе уведомлений?
|
|
||||||
|
|
||||||
|
|
||||||
Клиентское приложение (мобильный/веб) регистрирует токен **напрямую в сервисе уведомлений**, минуя сервис заявок. Это нормальный паттерн — регистрация токена не имеет отношения к бизнес-логике заявок.
|
|
||||||
|
|
||||||
## Как это выглядит на практике
|
|
||||||
|
|
||||||
1. Клиент получает push-токен от FCM/APNs при старте приложения или логине.
|
|
||||||
2. Клиент шлёт его в notification-сервис через его собственный API (обычно REST/gRPC endpoint), не через orders-сервис:
|
|
||||||
```
|
|
||||||
POST /api/v1/devices
|
|
||||||
{
|
|
||||||
"user_id": "...",
|
|
||||||
"token": "...",
|
|
||||||
"platform": "ios" | "android" | "web"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Notification-сервис делает upsert в свою таблицу `device_tokens`.
|
|
||||||
|
|
||||||
## Нюансы, которые стоит сразу заложить
|
|
||||||
|
|
||||||
**Аутентификация запроса.** Notification-сервис должен уметь проверить, что `user_id` в запросе — это действительно тот пользователь, который прислал токен (JWT/сессия), иначе кто угодно сможет зарегистрировать чужой токен на себя. Значит, notification-сервису нужен доступ к тому же механизму аутентификации, что и остальным API (общий auth middleware/gateway, проверка JWT и т.д.) — это отдельный вопрос архитектуры, но обычно решается через API Gateway, который уже провалидировал токен до того, как запрос дошёл до сервиса.
|
|
||||||
|
|
||||||
**Один пользователь — несколько токенов.** Человек заходит с телефона и с двух браузеров — три токена, все живые. Ключ уникальности обычно `(user_id, token)`, а не `user_id` — просто добавляете новую запись, если токен новый.
|
|
||||||
|
|
||||||
**Протухание токенов.** FCM/APNs могут вернуть ошибку `Unregistered`/`InvalidToken` при попытке отправки — по этому ответу нужно удалять/помечать токен неактивным в `device_tokens`. Это внутренняя логика notification-сервиса, никак не завязанная на orders.
|
|
||||||
|
|
||||||
**Ре-регистрация при обновлении токена.** FCM/APNs периодически ротируют токены сами — клиент должен слать обновлённый токен заново тем же способом (upsert по старому+новому или просто upsert по user_id+platform, если храните один активный токен на платформу).
|
|
||||||
|
|
||||||
## А что насчёт удаления при логауте?
|
|
||||||
|
|
||||||
Тоже прямой вызов клиента в notification-сервис:
|
|
||||||
```
|
|
||||||
DELETE /api/v1/devices/{token}
|
|
||||||
```
|
|
||||||
чтобы не слать пуши на токен вышедшего пользователя.
|
|
||||||
|
|
||||||
## Итого
|
|
||||||
|
|
||||||
Путь такой: **клиент → API Gateway (auth) → notification-сервис**, без участия orders-сервиса и без NATS — это синхронная операция регистрации устройства, а не бизнес-событие. NATS остаётся только для доменных событий вроде "заявка изменила статус".
|
|
||||||
|
|
||||||
Если у вас пока нет отдельного API Gateway / auth-сервиса — как сейчас происходит аутентификация между клиентом и бэкендом в целом?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
есть монолит в котором реализована проверка пользователя
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Значит нужно решить, как notification-сервис (и вообще любой новый сервис) будет проверять, что запрос от легитимного пользователя, не дублируя логику авторизации монолита.
|
|
||||||
|
|
||||||
## Варианты
|
|
||||||
|
|
||||||
**1. JWT, который монолит уже выдаёт клиенту**
|
|
||||||
|
|
||||||
Если монолит при логине выдаёт клиенту JWT (даже если сессии где-то ещё используются внутри) — самый простой путь. Notification-сервис получает токен в заголовке, сам проверяет подпись (публичным ключом/секретом монолита) и достаёт `user_id` из claims, без похода в монолит по сети.
|
|
||||||
|
|
||||||
```
|
|
||||||
Client → notification-service
|
|
||||||
Authorization: Bearer <jwt from monolith>
|
|
||||||
```
|
|
||||||
|
|
||||||
Плюс: notification-сервис не зависит от доступности монолита на каждый запрос.
|
|
||||||
Минус: нужен способ отзывать токены (revocation list) и следить за синхронизацией ключей.
|
|
||||||
|
|
||||||
**2. Если сейчас сессии (cookie), а не JWT**
|
|
||||||
|
|
||||||
Тогда варианты:
|
|
||||||
- Монолит отдаёт эндпоинт **интроспекции**: `POST /internal/auth/verify` с cookie/session-id → возвращает `user_id`. Notification-сервис дергает его синхронно на каждый запрос регистрации токена (это редкая операция — регистрация устройства, не хайлоад, так что синхронный вызов ок).
|
|
||||||
- Либо ставите перед всеми сервисами (включая монолит) общий **API Gateway/reverse proxy**, который сам проверяет сессию/JWT и прокидывает дальше уже проверенный `X-User-Id` в заголовке. Это правильный долгосрочный путь при переходе на микросервисы, но требует инфраструктурных изменений сейчас.
|
|
||||||
|
|
||||||
## Что практичнее на вашем этапе
|
|
||||||
|
|
||||||
Раз у вас уже выделяется notification-сервис как первый шаг из монолита, я бы не городил Gateway ради одного сервиса. Практичнее:
|
|
||||||
|
|
||||||
1. Уточнить, **что уже выдаёт монолит клиенту** — JWT или cookie-сессию.
|
|
||||||
2. Если JWT — notification-сервис верифицирует его сам (шарите secret/public key).
|
|
||||||
3. Если сессия — добавить в монолите один internal-эндпоинт интроспекции токена, notification-сервис ходит туда синхронно только на операции регистрации/удаления устройства (низкая частота, некритично к задержке).
|
|
||||||
4. NATS-события от orders в этой части не участвуют — это чисто клиент↔notification-сервис путь.
|
|
||||||
|
|
||||||
Что у вас сейчас — JWT или серверные сессии?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
jwt
|
|
||||||
|
|
||||||
|
|
||||||
С JWT всё просто — notification-сервису вообще не нужно ходить в монолит по сети для проверки.
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
1. Монолит подписывает JWT своим ключом при логине (симметричным HMAC-секретом или, лучше, приватным ключом при RS256/ES256).
|
|
||||||
2. Notification-сервис получает тот же токен от клиента в заголовке `Authorization: Bearer <jwt>`.
|
|
||||||
3. Notification-сервис **сам** проверяет подпись и достаёт `user_id` (и что нужно) из claims — без обращения к монолиту.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/devices
|
|
||||||
Authorization: Bearer <jwt>
|
|
||||||
{
|
|
||||||
"token": "...",
|
|
||||||
"platform": "ios"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`user_id` при этом берётся **из claims токена**, а не из тела запроса — иначе любой авторизованный пользователь сможет зарегистрировать токен на чужой `user_id`, просто подставив его в JSON. Тело запроса содержит только то, что клиент не может подделать в свою пользу (device token, platform), а personal identity всегда из верифицированного JWT.
|
|
||||||
|
|
||||||
## Что нужно расшарить между монолитом и notification-сервисом
|
|
||||||
|
|
||||||
- **Если HMAC (HS256)** — общий секрет. Хранить в секрет-менеджере (Vault/K8s secret/SSM), не в коде, раздать обоим сервисам.
|
|
||||||
- **Если RSA/EC (RS256/ES256)** — гораздо лучше для мультисервисной архитектуры: монолит держит приватный ключ и подписывает, notification-сервис знает только **публичный** ключ и может только проверять, но не подделывать токены. Публичный ключ можно даже раздавать через `/.well-known/jwks.json` эндпоинт на монолите — тогда новые сервисы просто подтягивают его при старте/по кэшу, без ручной синхронизации секретов.
|
|
||||||
|
|
||||||
Если сейчас HS256 — при росте числа сервисов стоит подумать о переходе на RS256/ES256 именно из соображений "каждому новому сервису для чтения не нужно доверять секрет, которым можно подписывать".
|
|
||||||
|
|
||||||
## Отзыв токенов (revocation)
|
|
||||||
|
|
||||||
Раз проверка теперь оффлайн (без похода в монолит), классическая проблема JWT — как обработать логаут/бан до истечения `exp`. Варианты, если это важно для вас:
|
|
||||||
- короткий TTL access-токена (5-15 мин) + refresh-токен — тогда отозванный доступ отваливается быстро сам по себе;
|
|
||||||
- либо блэклист отозванных `jti` в Redis, который проверяют все сервисы (но это уже добавляет сетевой поход и связанность).
|
|
||||||
|
|
||||||
Если у вас логаут не критичен к мгновенности (например, там просто короткий TTL) — можно не усложнять и оставить чисто оффлайн-проверку.
|
|
||||||
|
|
||||||
Какой у вас алгоритм подписи сейчас — HS256 или уже RS256/подобный?
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
а смс отправка, в сервисе уведомлений хранить телефоны
|
|
||||||
|
|
||||||
|
|
||||||
Да, по той же логике, что и с email — если SMS станет одним из каналов, номера телефонов хранятся в notification-сервисе, а не запрашиваются синхронно у монолита при каждой отправке.
|
|
||||||
|
|
||||||
## Варианты получения номера
|
|
||||||
|
|
||||||
**1. Дублирование с синхронизацией (рекомендую)**
|
|
||||||
- При регистрации/изменении профиля в монолите публикуется событие `user.profile_updated` (или `user.phone_changed`) в NATS.
|
|
||||||
- Notification-сервис подписывается, обновляет свою локальную копию `user_contacts (user_id, phone, email, updated_at)`.
|
|
||||||
- При отправке SMS notification-сервис берёт номер из своей БД — без синхронного похода в монолит.
|
|
||||||
|
|
||||||
Плюс: полная развязка, отправка не зависит от доступности монолита.
|
|
||||||
Минус: нуженource-of-truth-событие на каждое изменение контактных данных в монолите (если такого механизма пока нет — придётся добавить).
|
|
||||||
|
|
||||||
**2. Номер приходит вместе с доменным событием**
|
|
||||||
Если SMS шлётся не "вообще", а в ответ на конкретное действие (например, подтверждение заявки), можно не хранить номер вовсе, а прокидывать его в payload события:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event_type": "orders.request.status_changed",
|
|
||||||
"payload": {
|
|
||||||
"recipient_user_id": "...",
|
|
||||||
"recipient_phone": "+79991234567"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Плюс: не нужен отдельный механизм синхронизации контактов.
|
|
||||||
Минус: монолит должен знать, что нужно положить телефон в событие (небольшая связанность "по данным", не по вызовам) — но это всё равно значительно лучше, чем синхронный HTTP-запрос за телефоном на каждую отправку.
|
|
||||||
|
|
||||||
## Что выбрать
|
|
||||||
|
|
||||||
Если контактные данные меняются редко и на них завязано несколько каналов (email, sms, push-уведомления с персонализацией по имени) — вариант 1 чище: заводите таблицу контактов в notification-сервисе и синхронизируете событием при изменении профиля.
|
|
||||||
|
|
||||||
Если SMS — разовый узкий кейс (например, только "код подтверждения" или "статус заявки") — вариант 2 проще и не требует лишней инфраструктуры синхронизации.
|
|
||||||
|
|
||||||
## Общая таблица получается такая
|
|
||||||
|
|
||||||
| Данные | Где хранится | Как приходят |
|
|
||||||
|---|---|---|
|
|
||||||
| Push-токены | notification-сервис | напрямую от клиента, по своему API |
|
|
||||||
| Email/телефон | notification-сервис (копия) | событие `user.profile_updated` из монолита, либо в payload доменного события |
|
|
||||||
| Категории/каналы/шаблоны | notification-сервис | конфигурация сервиса |
|
|
||||||
| Заявки | монолит/orders | — |
|
|
||||||
|
|
||||||
Одна важная деталь: раз номера и email — персональные данные (ПДн), при дублировании в notification-сервисе стоит сразу продумать их шифрование at rest и политику хранения/удаления (например, по GDPR/152-ФЗ, если это применимо к вашему проекту) — то есть не просто copy-paste, а с тем же уровнем защиты, что и в монолите.
|
|
||||||
|
|
||||||
Возникает SMS-канал уже сейчас, или это на перспективу — просто закладываете архитектуру с запасом?
|
|
||||||
|
|
||||||
|
|
||||||
перспектива
|
|
||||||
|
|
||||||
Тогда не стоит сейчас городить полноценную синхронизацию контактов ради канала, которого ещё нет.
|
|
||||||
|
|
||||||
## Что делать сейчас
|
|
||||||
|
|
||||||
Заложите на будущее минимально — не более:
|
|
||||||
|
|
||||||
1. **Таблицу `user_contacts` сразу спроектируйте с запасом** (даже если пока заполняете только то, что нужно для push/email):
|
|
||||||
```sql
|
|
||||||
user_contacts (
|
|
||||||
user_id UUID PRIMARY KEY,
|
|
||||||
email TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
updated_at TIMESTAMPTZ
|
|
||||||
)
|
|
||||||
```
|
|
||||||
Так при добавлении SMS не придётся переделывать схему — просто начнёте заполнять `phone`.
|
|
||||||
|
|
||||||
2. **Общий механизм синхронизации делайте один — для всех контактных полей сразу**, а не отдельно под email и отдельно под будущий phone. Т.е. событие `user.profile_updated` с обоими полями в payload:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event_type": "user.profile_updated",
|
|
||||||
"payload": {
|
|
||||||
"user_id": "...",
|
|
||||||
"email": "...",
|
|
||||||
"phone": "..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Notification-сервис просто апсертит всё, что пришло, не заботясь о том, используется ли `phone` уже сейчас.
|
|
||||||
|
|
||||||
3. **Канал `sms` заведите как enum/значение в справочнике каналов уже сейчас**, даже неактивное — не придётся трогать миграции и код маппинга категорий→каналов, когда он реально понадобится.
|
|
||||||
|
|
||||||
## Чего делать не стоит
|
|
||||||
|
|
||||||
- Не подключайте SMS-провайдера (Twilio/SMS.ru/др.) заранее — это чистый YAGNI, только усложнит деплой и конфиги без пользы.
|
|
||||||
- Не делайте отдельную инфраструктуру верификации телефонов (подтверждение номера) заранее — это отдельная фича, привязывайте её к моменту, когда SMS реально понадобится.
|
|
||||||
- Не усложняйте событие `orders.request.status_changed` полем `recipient_phone`, если SMS туда пока не шлётся — лишнее поле без потребителя.
|
|
||||||
|
|
||||||
Итог: с минимальными изменениями в схеме и в контракте события `user.profile_updated` вы ничего не переделываете, когда дойдёте до SMS — просто добавляете провайдера и обработчик канала. Остальное — по факту появления реальной необходимости.
|
|
||||||
|
|
||||||
|
|
||||||
получается настройки пользователя по рассылке сообщений тоже хранить в сервисе уведомлений
|
|
||||||
|
|
||||||
|
|
||||||
Да, верно — это тоже часть зоны ответственности notification-сервиса: настройки касаются доставки уведомлений, а не бизнес-логики монолита.
|
|
||||||
|
|
||||||
## Схема хранения
|
|
||||||
|
|
||||||
```sql
|
|
||||||
notification_preferences (
|
|
||||||
user_id UUID,
|
|
||||||
category TEXT, -- 'request_status_changed', 'request_approved', ...
|
|
||||||
channel TEXT, -- 'push', 'email', 'sms'
|
|
||||||
enabled BOOLEAN DEFAULT true,
|
|
||||||
updated_at TIMESTAMPTZ,
|
|
||||||
PRIMARY KEY (user_id, category, channel)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Дефолт — если записи нет, считаете `enabled = true` (или прописываете дефолты явно при создании пользователя/по первому событию — так проще для аналитики "кто что выключил").
|
|
||||||
|
|
||||||
## Как пользователь меняет настройки
|
|
||||||
|
|
||||||
Тут два пути, зависит от того, как у вас организован фронтенд/UI:
|
|
||||||
|
|
||||||
**1. Фронт стучится напрямую в notification-сервис**
|
|
||||||
```
|
|
||||||
PUT /api/v1/preferences
|
|
||||||
Authorization: Bearer <jwt>
|
|
||||||
{
|
|
||||||
"category": "request_approved",
|
|
||||||
"channel": "email",
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Тот же принцип авторизации, что и с push-токенами — JWT валидируется notification-сервисом самостоятельно, `user_id` берётся из claims.
|
|
||||||
|
|
||||||
Плюс: чисто, без лишних хопов.
|
|
||||||
Минус: если UI настроек живёт внутри монолитного приложения (например, единая страница профиля), фронту нужно знать про два разных бэкенда — обычно решается тем, что API Gateway/reverse-proxy просто роутит `/api/notifications/*` в notification-сервис, а остальное — в монолит. Для фронта это прозрачно, один домен.
|
|
||||||
|
|
||||||
**2. Монолит проксирует запрос**
|
|
||||||
Если пока нет общего гейтвея и не хочется светить notification-сервис наружу отдельным доменом — монолит принимает запрос от фронта и просто форвардит его в notification-сервис (server-to-server, с сервисным токеном или доверенной сетью). Проще для фронта прямо сейчас, но добавляет лишний хоп и точку отказа.
|
|
||||||
|
|
||||||
Если планируете API Gateway в перспективе (вы уже говорили, что growth в сторону микросервисов) — я бы сразу шёл по варианту 1, чтобы не переделывать потом.
|
|
||||||
|
|
||||||
## Что ещё сюда относится
|
|
||||||
|
|
||||||
- **Глобальный opt-out** — отдельная настройка `user_id → all_notifications_disabled` полезна для "отписаться от всего" (часто требуется по закону для email-рассылок — unsubscribe-ссылка).
|
|
||||||
- **Read-модель для быстрого чтения** — так как эта таблица читается на **каждое** событие из NATS (проверка "слать ли"), убедитесь что там индекс по `user_id`, и в целом это должна быть быстрая точка доступа (можно и в Redis кэшировать активные подписки, если объёмы большие).
|
|
||||||
|
|
||||||
Итого зона ответственности notification-сервиса теперь выглядит так:
|
|
||||||
|
|
||||||
| Сущность | Таблица |
|
|
||||||
|---|---|
|
|
||||||
| Push-токены | `device_tokens` |
|
|
||||||
| Контакты (email/phone) | `user_contacts` |
|
|
||||||
| Настройки подписок | `notification_preferences` |
|
|
||||||
| Категории/каналы | справочник/enum |
|
|
||||||
| Шаблоны | `templates` |
|
|
||||||
| Лог отправок | `notifications` |
|
|
||||||
|
|
||||||
Всё, что касается "кому, как и хочет ли получать" — здесь, и нигде больше. Монолит/orders об этом не знает вообще, только публикует факт события.
|
|
||||||
Reference in new issue
Block a user