Compare commits

...
7 Commits
Author SHA1 Message Date
vadim 5cfc8986d7 prettier
Gitea Actions Demo / Deploy (push) Successful in 1m34s
2026-10-01 12:04:21 +03:00
vadim 4b6ecfbf5d comments implement 2026-10-01 12:02:03 +03:00
vadim 9e0bef2527 update git ignore 2026-10-01 11:25:31 +03:00
vadimandClaude Opus 5.5 3272c75512 Add Comment model for issue comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:51 +03:00
vadimandClaude Opus 5.5 2696f33a7e Keep issues when assignee or reporter is deleted
Switch issue.assigneeId/reporterId FKs from CASCADE to SET NULL and make
reporter nullable through the entity, DTOs and GraphQL types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:51 +03:00
vadimandClaude Opus 5.5 4b8c855855 Add missing migration for notification tables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:23:46 +03:00
vadim 60391ee123 notification 2026-09-29 18:42:29 +03:00
76 changed files with 2652 additions and 31 deletions

No files matched your search

+1
View File
@@ -2,6 +2,7 @@
/dist /dist
/node_modules /node_modules
/build /build
*.tsbuildinfo
# Logs # Logs
logs logs
+23
View File
@@ -16,6 +16,7 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0", "@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^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.0.1",
"@nestjs/typeorm": "^12.0.1", "@nestjs/typeorm": "^12.0.1",
@@ -1239,6 +1240,22 @@
} }
} }
}, },
"node_modules/@nestjs/event-emitter": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-12.0.1.tgz",
"integrity": "sha512-7MbpgNcxUcDlqcYY/qgZm1H7MfK3uGcRsd8lEjj9ZYvI9qTl6kdkkykpxZ6YjBRH3wlZK5dFLAei8h5qpsHzOw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/graphql": { "node_modules/@nestjs/graphql": {
"version": "14.0.2", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-14.0.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-14.0.2.tgz",
@@ -5005,6 +5022,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/expect-type": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0", "@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^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.0.1",
"@nestjs/typeorm": "^12.0.1", "@nestjs/typeorm": "^12.0.1",
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "notification_device_token" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "notification_device_token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_preference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "notification_preference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_message" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"subject" TEXT,
"content" TEXT NOT NULL,
"error" TEXT,
"sentAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "notification_device_token_userId_idx" ON "notification_device_token"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "notification_device_token_userId_token_key" ON "notification_device_token"("userId", "token");
-- CreateIndex
CREATE UNIQUE INDEX "notification_preference_userId_channel_key" ON "notification_preference"("userId", "channel");
-- CreateIndex
CREATE INDEX "notification_message_userId_idx" ON "notification_message"("userId");
-- CreateIndex
CREATE INDEX "notification_message_status_idx" ON "notification_message"("status");
-- CreateIndex
CREATE INDEX "notification_message_createdAt_idx" ON "notification_message"("createdAt");
-- AddForeignKey
ALTER TABLE "notification_device_token" ADD CONSTRAINT "notification_device_token_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_preference" ADD CONSTRAINT "notification_preference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_message" ADD CONSTRAINT "notification_message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- DropForeignKey
ALTER TABLE "issue" DROP CONSTRAINT "issue_assigneeId_fkey";
-- DropForeignKey
ALTER TABLE "issue" DROP CONSTRAINT "issue_reporterId_fkey";
-- AlterTable
ALTER TABLE "issue" ALTER COLUMN "reporterId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "comment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"issueId" INTEGER NOT NULL,
"authorId" TEXT,
"editedAt" TIMESTAMPTZ(3),
"deletedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "comment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "comment_issueId_createdAt_idx" ON "comment"("issueId", "createdAt");
-- CreateIndex
CREATE INDEX "comment_authorId_idx" ON "comment"("authorId");
-- AddForeignKey
ALTER TABLE "comment" ADD CONSTRAINT "comment_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comment" ADD CONSTRAINT "comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+80 -3
View File
@@ -21,6 +21,10 @@ model User {
reporterIssues Issue[] @relation(name: "reporterIssues") reporterIssues Issue[] @relation(name: "reporterIssues")
projects Project[] projects Project[]
projectMemberships ProjectMember[] projectMemberships ProjectMember[]
notificationDeviceTokens NotificationDeviceToken[]
notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[]
comments Comment[]
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -83,12 +87,13 @@ model Issue {
status String status String
priority Int @default(0) priority Int @default(0)
assigneeId String? assigneeId String?
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: Cascade) assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
reporterId String reporterId String?
reporter User @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: Cascade) reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull)
projectId String projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
dueDate DateTime? @db.Timestamptz(3) dueDate DateTime? @db.Timestamptz(3)
comments Comment[]
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)
@@ -132,3 +137,75 @@ model ProjectMember {
@@index([userId]) @@index([userId])
@@map("project_member") @@map("project_member")
} }
/// Push-токены устройств пользователя. Регистрируются напрямую клиентом,
/// без участия остального приложения.
model NotificationDeviceToken {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String
platform String // ios | android | web
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, token])
@@index([userId])
@@map("notification_device_token")
}
/// Включён ли у пользователя конкретный канал доставки уведомлений.
/// Категорий нет — настройка одна на канал.
/// Допустимые значения channel валидируются в коде (NotificationChannel VO),
/// а не типом БД — расширение списка каналов не требует миграции.
model NotificationPreference {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
enabled Boolean @default(true)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, channel])
@@map("notification_preference")
}
/// Лог/история отправленных уведомлений — одна запись на попытку доставки
/// по одному каналу. channel/status — строки, валидируются в коде
/// (NotificationChannel/NotificationStatus VO), см. комментарий выше.
model NotificationMessage {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
status String @default("PENDING")
subject String?
content String
error String?
sentAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@index([userId])
@@index([status])
@@index([createdAt])
@@map("notification_message")
}
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
/// deletedAt — мягкое удаление, editedAt — время последней правки текста.
model Comment {
id String @id @default(uuid(7))
content String
issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
editedAt DateTime? @db.Timestamptz(3)
deletedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@index([issueId, createdAt])
@@index([authorId])
@@map("comment")
}
+8
View File
@@ -5,9 +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 { NotificationModule } from './modules/notification/notification.module.js';
import { CommentModule } from './modules/comment/comment.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';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { GraphQLModule } from '@nestjs/graphql'; import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -18,6 +21,9 @@ import { join } from 'node:path';
isGlobal: true, isGlobal: true,
cache: true, cache: true,
}), }),
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.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'),
@@ -40,6 +46,8 @@ import { join } from 'node:path';
ProjectModule, ProjectModule,
UserModule, UserModule,
StatusModule, StatusModule,
NotificationModule,
CommentModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -1,6 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { SSE_METADATA } from '@nestjs/common/constants.js';
import { PaginationMeta } from '../interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../interfaces/pagination-meta.interface.js';
export interface ResponseEnvelope<T> { export interface ResponseEnvelope<T> {
@@ -30,18 +31,21 @@ export class TransformResponseInterceptor<T> implements NestInterceptor<
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T>, next: CallHandler<T>,
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> { ): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
// GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта —
// конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его.
if (context.getType() !== 'http') { if (context.getType() !== 'http') {
return next.handle(); return next.handle();
} }
// SSE-обработчик возвращает поток MessageEvent ({type, data, id}) — его
// сериализует сам Nest. Обёртка в {data, code} спрятала бы type внутрь data,
// и клиентский addEventListener('<type>') перестал бы срабатывать.
if (Reflect.getMetadata(SSE_METADATA, context.getHandler())) {
return next.handle();
}
const response = context.switchToHttp().getResponse(); const response = context.switchToHttp().getResponse();
return next.handle().pipe( return next.handle().pipe(
map((result) => { map((result) => {
// Пагинированные ответы ({data, meta}) разворачиваем на верхний
// уровень конверта, а не оборачиваем повторно как data.data.
if (isPaginatedResult<T>(result)) { if (isPaginatedResult<T>(result)) {
return { return {
data: result.data ?? null, data: result.data ?? null,
@@ -0,0 +1,15 @@
import { UserDto } from '../../../user/application/dto/user.dto.js';
export class CommentDto {
id: string;
issueId: number;
authorId: string | null;
/** null — автор удалил аккаунт */
author: UserDto | null;
/** null у удалённого комментария — текст наружу не отдаём */
content: string | null;
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentApplicationMapper } from './comment-application.mapper.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
const author: UserDto = {
id: 'user-1',
name: 'User 1',
email: 'user-1@example.com',
emailVerified: true,
image: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1') =>
Comment.reconstitute({
id: 'comment-1',
content: 'Secret',
issueId: 1,
authorId,
editedAt: null,
deletedAt,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('CommentApplicationMapper', () => {
it('expands the author', () => {
const dto = CommentApplicationMapper.toDto(buildComment(), new Map([[author.id, author]]));
expect(dto.author).toEqual(author);
expect(dto.content).toBe('Secret');
});
it('hides content of a deleted comment', () => {
const dto = CommentApplicationMapper.toDto(buildComment(new Date()), new Map([[author.id, author]]));
expect(dto.content).toBeNull();
expect(dto.deletedAt).toBeInstanceOf(Date);
});
it('returns null author when the author account is deleted', () => {
const dto = CommentApplicationMapper.toDto(buildComment(null, null), new Map());
expect(dto.authorId).toBeNull();
expect(dto.author).toBeNull();
});
});
@@ -0,0 +1,19 @@
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
export class CommentApplicationMapper {
static toDto(comment: Comment, usersById: ReadonlyMap<string, UserDto>): CommentDto {
return {
id: comment.id as string,
issueId: comment.issueId,
authorId: comment.authorId,
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
content: comment.isDeleted ? null : comment.content,
editedAt: comment.editedAt,
deletedAt: comment.deletedAt,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
}
@@ -0,0 +1,42 @@
import { Inject, Injectable } from '@nestjs/common';
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
import {
ISSUE_REPOSITORY,
type IIssueRepository,
} from '../../../issue/domain/repositories/issue.repository.interface.js';
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js';
@Injectable()
export class CommentAccessService {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
/** Видна ли задача пользователю (то же правило, что в IIssueRepository.findById) */
async isIssueVisible(issueId: number, userId: string): Promise<boolean> {
return (await this.issueRepository.findById(issueId, userId)) !== null;
}
/**
* Возвращает неудалённый комментарий, если его задача видна пользователю.
* Иначе — CommentNotFoundException, чтобы не раскрывать существование
* комментариев к чужим задачам.
*/
async getVisibleComment(id: string, userId: string): Promise<Comment> {
const comment = await this.commentRepository.findById(id);
if (!comment || comment.isDeleted) {
throw new CommentNotFoundException(id);
}
const issue = await this.issueRepository.findById(comment.issueId, userId);
if (!issue) {
throw new CommentNotFoundException(id);
}
return comment;
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */
@Injectable()
export class CommentAuthorExpanderService {
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
async expand(comment: Comment): Promise<CommentDto> {
const [dto] = await this.expandAll([comment]);
return dto!;
}
async expandAll(comments: Comment[]): Promise<CommentDto[]> {
if (comments.length === 0) return [];
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [];
const usersById = new Map(users.map((user) => [user.id, user]));
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById));
}
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Observable, Subject } from 'rxjs';
import { CommentDto } from '../dto/comment.dto.js';
export type CommentEventType = 'comment.created' | 'comment.updated' | 'comment.deleted';
export interface CommentEvent {
type: CommentEventType;
comment: CommentDto;
}
/**
* In-process шина событий comment-модуля для SSE-подписчиков
* (по аналогии с IssueEventsService). Работает в пределах одного
* инстанса приложения — для нескольких инстансов понадобится Redis pub/sub.
*/
@Injectable()
export class CommentEventsService {
private readonly events$ = new Subject<CommentEvent>();
emit(type: CommentEventType, comment: CommentDto): void {
this.events$.next({ type, comment });
}
onEvents(): Observable<CommentEvent> {
return this.events$.asObservable();
}
}
@@ -0,0 +1,7 @@
export class CreateCommentCommand {
constructor(
public readonly issueId: number,
public readonly authorId: string,
public readonly content: string,
) {}
}
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CreateCommentUseCase } from './create-comment.use-case.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
describe('CreateCommentUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: CreateCommentUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new CreateCommentUseCase(commentRepository, issueRepository, mockAuthorExpander(), commentEventsService);
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
Comment.reconstitute({
id: 'comment-1',
content: comment.content,
issueId: comment.issueId,
authorId: comment.authorId,
editedAt: null,
deletedAt: null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}),
);
});
it('creates a comment on a visible issue and emits comment.created', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
const received: unknown[] = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
const dto = await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello'));
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-1');
expect(dto).toMatchObject({ id: 'comment-1', issueId: 1, authorId: 'user-1', content: 'Hello' });
expect(received).toEqual([{ type: 'comment.created', comment: dto }]);
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new CreateCommentCommand(1, 'user-2', 'Hello'))).rejects.toThrow(
IssueNotFoundException,
);
expect(commentRepository.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,48 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
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 { Comment } from '../../../domain/entities/comment.entity.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
@Injectable()
export class CreateCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(command: CreateCommentCommand): Promise<CommentDto> {
// Комментировать можно только видимую пользователю задачу
const issue = await this.issueRepository.findById(command.issueId, command.authorId);
if (!issue) {
throw new IssueNotFoundException(command.issueId);
}
const comment = Comment.create({
issueId: command.issueId,
authorId: command.authorId,
content: command.content,
});
const saved = await this.commentRepository.create(comment);
const dto = await this.commentAuthorExpander.expand(saved);
this.commentEventsService.emit('comment.created', dto);
return dto;
}
}
@@ -0,0 +1,6 @@
export class DeleteCommentCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js';
import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { DeleteCommentUseCase } from './delete-comment.use-case.js';
import { DeleteCommentCommand } from './delete-comment.command.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
describe('DeleteCommentUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: DeleteCommentUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new DeleteCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(),
commentEventsService,
);
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
});
it('soft-deletes the comment and emits comment.deleted without content', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
const received: CommentEvent[] = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1'));
const saved = vi.mocked(commentRepository.update).mock.calls[0]![0] as Comment;
expect(saved.isDeleted).toBe(true);
expect(received).toHaveLength(1);
expect(received[0]!.type).toBe('comment.deleted');
expect(received[0]!.comment.content).toBeNull();
});
it('throws CommentForbiddenException for a non-author', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await expect(useCase.execute(new DeleteCommentCommand('comment-1', 'user-2'))).rejects.toThrow(
CommentForbiddenException,
);
expect(commentRepository.update).not.toHaveBeenCalled();
});
it('throws CommentNotFoundException when the comment does not exist', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new DeleteCommentCommand('missing', 'user-1'))).rejects.toThrow(
CommentNotFoundException,
);
});
});
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
import { DeleteCommentCommand } from './delete-comment.command.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
/** Мягкое удаление: проставляет deletedAt, запись остаётся в ленте */
@Injectable()
export class DeleteCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(command: DeleteCommentCommand): Promise<void> {
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
comment.delete(command.userId);
await this.commentRepository.update(comment);
this.commentEventsService.emit('comment.deleted', await this.commentAuthorExpander.expand(comment));
}
}
@@ -0,0 +1,8 @@
export class ListCommentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
public readonly limit: number,
public readonly cursor?: string,
) {}
}
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { ListCommentsUseCase } from './list-comments.use-case.js';
import { ListCommentsQuery } from './list-comments.query.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
describe('ListCommentsUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let useCase: ListCommentsUseCase;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander());
});
it('forwards pagination params and returns the next cursor', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
vi.mocked(commentRepository.findByIssue).mockResolvedValue({
list: [buildComment({ id: 'comment-2' })],
nextCursor: 'comment-2',
});
const result = await useCase.execute(new ListCommentsQuery(1, 'user-1', 1, 'comment-1'));
expect(commentRepository.findByIssue).toHaveBeenCalledWith({ issueId: 1, limit: 1, cursor: 'comment-1' });
expect(result.list.map((comment) => comment.id)).toEqual(['comment-2']);
expect(result.nextCursor).toBe('comment-2');
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new ListCommentsQuery(1, 'user-2', 50))).rejects.toThrow(IssueNotFoundException);
expect(commentRepository.findByIssue).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
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 { ListCommentsQuery } from './list-comments.query.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
@Injectable()
export class ListCommentsUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
) {}
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
const issue = await this.issueRepository.findById(query.issueId, query.userId);
if (!issue) {
throw new IssueNotFoundException(query.issueId);
}
const { list, nextCursor } = await this.commentRepository.findByIssue({
issueId: query.issueId,
limit: query.limit,
cursor: query.cursor,
});
return { list: await this.commentAuthorExpander.expandAll(list), nextCursor };
}
}
@@ -0,0 +1,63 @@
import { vi } from 'vitest';
import type { ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
import { Comment } from '../../domain/entities/comment.entity.js';
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
export const buildComment = (
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
) =>
Comment.reconstitute({
id: overrides.id ?? 'comment-1',
content: 'Original',
issueId: overrides.issueId ?? 1,
authorId: overrides.authorId ?? 'user-1',
editedAt: null,
deletedAt: overrides.deletedAt ?? null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
export const buildIssue = () =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Issue',
content: null,
status: 'OPEN',
priority: 0,
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'),
});
export const mockCommentRepository = (): ICommentRepository => ({
findById: vi.fn(),
findByIssue: vi.fn(),
create: vi.fn(),
update: vi.fn(),
});
export const mockIssueRepository = (): IIssueRepository => ({
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
});
/** Экспандер без похода за пользователями: author всегда null */
export const mockAuthorExpander = () => {
const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
return {
expand: vi.fn(async (comment: Comment) => toDto(comment)),
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
} as unknown as CommentAuthorExpanderService;
};
@@ -0,0 +1,7 @@
export class UpdateCommentCommand {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly content: string,
) {}
}
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { UpdateCommentUseCase } from './update-comment.use-case.js';
import { UpdateCommentCommand } from './update-comment.command.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
describe('UpdateCommentUseCase', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: UpdateCommentUseCase;
let received: unknown[];
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new UpdateCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(),
commentEventsService,
);
received = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
});
it('updates the content and emits comment.updated', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
const dto = await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'));
expect(dto.content).toBe('Changed');
expect(dto.editedAt).toBeInstanceOf(Date);
expect(commentRepository.update).toHaveBeenCalledTimes(1);
expect(received).toEqual([{ type: 'comment.updated', comment: dto }]);
});
it('skips saving and events when content did not change', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original'));
expect(commentRepository.update).not.toHaveBeenCalled();
expect(received).toEqual([]);
});
it('throws CommentForbiddenException for a non-author', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(
CommentForbiddenException,
);
expect(commentRepository.update).not.toHaveBeenCalled();
});
it('throws CommentNotFoundException for a deleted comment', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment({ deletedAt: new Date() }));
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
CommentNotFoundException,
);
});
it('throws CommentNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
CommentNotFoundException,
);
});
});
@@ -0,0 +1,38 @@
import { Inject, Injectable } from '@nestjs/common';
import {
COMMENT_REPOSITORY,
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
import { UpdateCommentCommand } from './update-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
@Injectable()
export class UpdateCommentUseCase {
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
const changed = comment.edit(command.content, command.userId);
if (changed) {
await this.commentRepository.update(comment);
}
const dto = await this.commentAuthorExpander.expand(comment);
if (changed) {
this.commentEventsService.emit('comment.updated', dto);
}
return dto;
}
}
@@ -0,0 +1,6 @@
export class WatchIssueCommentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js';
import { CommentApplicationMapper } from '../../mappers/comment-application.mapper.js';
import { WatchIssueCommentsUseCase } from './watch-issue-comments.use-case.js';
import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
import { buildComment, buildIssue, mockIssueRepository } from '../test-helpers.js';
const dtoForIssue = (issueId: number) =>
CommentApplicationMapper.toDto(buildComment({ id: `comment-of-${issueId}`, issueId }), new Map());
describe('WatchIssueCommentsUseCase', () => {
let issueRepository: IIssueRepository;
let commentEventsService: CommentEventsService;
let useCase: WatchIssueCommentsUseCase;
beforeEach(() => {
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new WatchIssueCommentsUseCase(issueRepository, commentEventsService);
});
it('streams only events of the requested issue', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
const received: CommentEvent[] = [];
const events$ = await useCase.execute(new WatchIssueCommentsQuery(1, 'user-1'));
events$.subscribe((event) => received.push(event));
commentEventsService.emit('comment.created', dtoForIssue(1));
commentEventsService.emit('comment.created', dtoForIssue(2));
expect(received.map((event) => event.comment.id)).toEqual(['comment-of-1']);
});
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(null);
await expect(useCase.execute(new WatchIssueCommentsQuery(1, 'user-2'))).rejects.toThrow(IssueNotFoundException);
});
});
@@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { filter, type Observable } from 'rxjs';
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 { WatchIssueCommentsQuery } from './watch-issue-comments.query.js';
import { type CommentEvent, CommentEventsService } from '../../services/comment-events.service.js';
/**
* Поток событий комментариев одной задачи для SSE. Доступ к задаче
* проверяется один раз — при подключении.
*/
@Injectable()
export class WatchIssueCommentsUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentEventsService: CommentEventsService,
) {}
async execute(query: WatchIssueCommentsQuery): Promise<Observable<CommentEvent>> {
const issue = await this.issueRepository.findById(query.issueId, query.userId);
if (!issue) {
throw new IssueNotFoundException(query.issueId);
}
return this.commentEventsService.onEvents().pipe(filter((event) => event.comment.issueId === query.issueId));
}
}
+37
View File
@@ -0,0 +1,37 @@
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 { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
import { CommentController } from './presentation/controllers/comment.controller.js';
import { CreateCommentUseCase } from './application/use-cases/create-comment/create-comment.use-case.js';
import { ListCommentsUseCase } from './application/use-cases/list-comments/list-comments.use-case.js';
import { UpdateCommentUseCase } from './application/use-cases/update-comment/update-comment.use-case.js';
import { DeleteCommentUseCase } from './application/use-cases/delete-comment/delete-comment.use-case.js';
import { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
import { CommentAccessService } from './application/services/comment-access.service.js';
import { CommentAuthorExpanderService } from './application/services/comment-author-expander.service.js';
import { CommentEventsService } from './application/services/comment-events.service.js';
@Module({
imports: [PrismaModule, IssueModule, UserModule],
controllers: [CommentController],
providers: [
CreateCommentUseCase,
ListCommentsUseCase,
UpdateCommentUseCase,
DeleteCommentUseCase,
WatchIssueCommentsUseCase,
CommentAccessService,
CommentAuthorExpanderService,
CommentEventsService,
{
// Domain видит только интерфейс ICommentRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: COMMENT_REPOSITORY,
useClass: CommentPrismaRepository,
},
],
})
export class CommentModule {}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { Comment } from './comment.entity.js';
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
const buildComment = (overrides: Partial<{ authorId: string | null; deletedAt: Date | null }> = {}) =>
Comment.reconstitute({
id: 'comment-1',
content: 'Original',
issueId: 1,
authorId: overrides.authorId === undefined ? 'user-1' : overrides.authorId,
editedAt: null,
deletedAt: overrides.deletedAt ?? null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('Comment', () => {
describe('create', () => {
it('creates a new comment without id and edit/delete marks', () => {
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: ' Hello ' });
expect(comment.id).toBeNull();
expect(comment.content).toBe('Hello');
expect(comment.editedAt).toBeNull();
expect(comment.isDeleted).toBe(false);
});
it('rejects empty content', () => {
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: '' })).toThrow();
});
});
describe('edit', () => {
it('changes content and sets editedAt', () => {
const comment = buildComment();
expect(comment.edit('Changed', 'user-1')).toBe(true);
expect(comment.content).toBe('Changed');
expect(comment.editedAt).toBeInstanceOf(Date);
});
it('is a no-op when content is the same', () => {
const comment = buildComment();
expect(comment.edit(' Original ', 'user-1')).toBe(false);
expect(comment.editedAt).toBeNull();
});
it('forbids editing by someone other than the author', () => {
expect(() => buildComment().edit('Changed', 'user-2')).toThrow(CommentForbiddenException);
});
it('forbids editing when the author account is deleted', () => {
expect(() => buildComment({ authorId: null }).edit('Changed', 'user-1')).toThrow(CommentForbiddenException);
});
it('forbids editing a deleted comment', () => {
expect(() => buildComment({ deletedAt: new Date() }).edit('Changed', 'user-1')).toThrow();
});
});
describe('delete', () => {
it('soft-deletes the comment', () => {
const comment = buildComment();
comment.delete('user-1');
expect(comment.isDeleted).toBe(true);
expect(comment.deletedAt).toBeInstanceOf(Date);
});
it('forbids deletion by someone other than the author', () => {
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
});
});
});
@@ -0,0 +1,129 @@
import { CommentContent } from '../value-objects/comment-content.vo.js';
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
interface CommentProps {
id: string | null;
content: CommentContent;
issueId: number;
/** null — автор удалил аккаунт */
authorId: string | null;
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
* чтобы в ленте можно было показать «комментарий удалён».
*
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
* он равен null до сохранения в репозитории.
*/
export class Comment {
private constructor(private readonly props: CommentProps) {}
/** Фабричный метод для создания НОВОГО комментария */
static create(params: { issueId: number; authorId: string; content: string }): Comment {
const now = new Date();
return new Comment({
id: null,
content: CommentContent.create(params.content),
issueId: params.issueId,
authorId: params.authorId,
editedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
});
}
/** Восстановление сущности из персистентного слоя */
static reconstitute(props: {
id: string;
content: string;
issueId: number;
authorId: string | null;
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): Comment {
return new Comment({
...props,
content: CommentContent.create(props.content),
});
}
get id(): string | null {
return this.props.id;
}
get content(): string {
return this.props.content.value;
}
get issueId(): number {
return this.props.issueId;
}
get authorId(): string | null {
return this.props.authorId;
}
get editedAt(): Date | null {
return this.props.editedAt;
}
get deletedAt(): Date | null {
return this.props.deletedAt;
}
get isDeleted(): boolean {
return this.props.deletedAt !== null;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/** Меняет текст. Возвращает false, если текст не изменился */
edit(content: string, userId: string): boolean {
this.assertCanModify(userId);
const next = CommentContent.create(content);
if (next.equals(this.props.content)) {
return false;
}
const now = new Date();
this.props.content = next;
this.props.editedAt = now;
this.props.updatedAt = now;
return true;
}
delete(userId: string): void {
this.assertCanModify(userId);
const now = new Date();
this.props.deletedAt = now;
this.props.updatedAt = now;
}
/** Править и удалять комментарий может только его автор, и только пока он не удалён */
private assertCanModify(userId: string): void {
if (this.isDeleted) {
throw new Error('Комментарий уже удалён');
}
if (this.props.authorId !== userId) {
throw new CommentForbiddenException(this.props.id ?? 'new');
}
}
}
@@ -0,0 +1,6 @@
export class CommentForbiddenException extends Error {
constructor(id: string) {
super(`Нет прав на изменение комментария id=${id}`);
this.name = 'CommentForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class CommentNotFoundException extends Error {
constructor(id: string) {
super(`Комментарий с id=${id} не найден`);
this.name = 'CommentNotFoundException';
}
}
@@ -0,0 +1,20 @@
import { Comment } from '../entities/comment.entity.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY');
export interface ICommentRepository {
/** Возвращает и мягко удалённые комментарии — решение принимает вызывающий */
findById(id: string): Promise<Comment | null>;
/**
* Комментарии задачи от старых к новым, включая удалённые.
* Курсорная пагинация: cursor — id последнего полученного комментария.
*/
findByIssue(params: {
issueId: number;
limit: number;
cursor?: string;
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
create(comment: Comment): Promise<Comment>;
update(comment: Comment): Promise<void>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { CommentContent } from './comment-content.vo.js';
describe('CommentContent', () => {
it('trims the value', () => {
expect(CommentContent.create(' hello ').value).toBe('hello');
});
it('rejects empty content', () => {
expect(() => CommentContent.create(' ')).toThrow('Комментарий не может быть пустым');
});
it('rejects content longer than the limit', () => {
expect(() => CommentContent.create('a'.repeat(CommentContent.MAX_LENGTH + 1))).toThrow(
`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`,
);
});
});
@@ -0,0 +1,27 @@
export class CommentContent {
static readonly MAX_LENGTH = 10000;
private constructor(private readonly _value: string) {}
static create(value: string): CommentContent {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Комментарий не может быть пустым');
}
if (trimmed.length > CommentContent.MAX_LENGTH) {
throw new Error(`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`);
}
return new CommentContent(trimmed);
}
get value(): string {
return this._value;
}
equals(other: CommentContent): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,36 @@
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js';
export class CommentPrismaMapper {
static toDomain(record: PrismaComment): Comment {
return Comment.reconstitute({
id: record.id,
content: record.content,
issueId: record.issueId,
authorId: record.authorId,
editedAt: record.editedAt,
deletedAt: record.deletedAt,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
static toCreateInput(comment: Comment): Prisma.CommentUncheckedCreateInput {
return {
content: comment.content,
issueId: comment.issueId,
authorId: comment.authorId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
}
static toUpdateInput(comment: Comment): Prisma.CommentUncheckedUpdateInput {
return {
content: comment.content,
editedAt: comment.editedAt,
deletedAt: comment.deletedAt,
updatedAt: comment.updatedAt,
};
}
}
@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js';
import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js';
import { CommentPrismaMapper } from '../mappers/comment.mapper.js';
import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-not-found.exception.js';
const FOREIGN_KEY_VIOLATION = 'P2003';
@Injectable()
export class CommentPrismaRepository implements ICommentRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<Comment | null> {
const record = await this.prisma.comment.findUnique({ where: { id } });
return record ? CommentPrismaMapper.toDomain(record) : null;
}
async findByIssue(params: {
issueId: number;
limit: number;
cursor?: string;
}): Promise<{ list: Comment[]; nextCursor: string | null }> {
const limit = Math.max(1, params.limit);
// Берём на одну запись больше, чтобы понять, есть ли следующая страница.
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
const records = await this.prisma.comment.findMany({
where: { issueId: params.issueId },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1,
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
});
const hasMore = records.length > limit;
const page = hasMore ? records.slice(0, limit) : records;
return {
list: page.map(CommentPrismaMapper.toDomain),
nextCursor: hasMore ? page[page.length - 1]!.id : null,
};
}
async create(comment: Comment): Promise<Comment> {
try {
const record = await this.prisma.comment.create({
data: CommentPrismaMapper.toCreateInput(comment),
});
return CommentPrismaMapper.toDomain(record);
} catch (error) {
// Задачу удалили между проверкой доступа и вставкой
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) {
throw new IssueNotFoundException(comment.issueId);
}
throw error;
}
}
async update(comment: Comment): Promise<void> {
await this.prisma.comment.update({
where: { id: comment.id as string },
data: CommentPrismaMapper.toUpdateInput(comment),
});
}
}
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
type MessageEvent,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Sse,
UseGuards,
} from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { map, type Observable } from 'rxjs';
import { CreateCommentUseCase } from '../../application/use-cases/create-comment/create-comment.use-case.js';
import { ListCommentsUseCase } from '../../application/use-cases/list-comments/list-comments.use-case.js';
import { UpdateCommentUseCase } from '../../application/use-cases/update-comment/update-comment.use-case.js';
import { DeleteCommentUseCase } from '../../application/use-cases/delete-comment/delete-comment.use-case.js';
import { WatchIssueCommentsUseCase } from '../../application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
import { CreateCommentCommand } from '../../application/use-cases/create-comment/create-comment.command.js';
import { ListCommentsQuery } from '../../application/use-cases/list-comments/list-comments.query.js';
import { UpdateCommentCommand } from '../../application/use-cases/update-comment/update-comment.command.js';
import { DeleteCommentCommand } from '../../application/use-cases/delete-comment/delete-comment.command.js';
import { WatchIssueCommentsQuery } from '../../application/use-cases/watch-issue-comments/watch-issue-comments.query.js';
import { CreateCommentRequestDto } from '../dto/create-comment.dto.js';
import { UpdateCommentRequestDto } from '../dto/update-comment.dto.js';
import { ListCommentsDto } from '../dto/list-comments.dto.js';
import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js';
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueVisibleGuard } from '../guards/issue-visible.guard.js';
@Controller()
export class CommentController {
constructor(
private readonly createCommentUseCase: CreateCommentUseCase,
private readonly listCommentsUseCase: ListCommentsUseCase,
private readonly updateCommentUseCase: UpdateCommentUseCase,
private readonly deleteCommentUseCase: DeleteCommentUseCase,
private readonly watchIssueCommentsUseCase: WatchIssueCommentsUseCase,
) {}
@Get('issues/:issueId/comments')
list(
@Param('issueId', ParseIntPipe) issueId: number,
@Query() query: ListCommentsDto,
@Session() session: UserSession,
) {
return this.handleErrors(() =>
this.listCommentsUseCase.execute(
new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor),
),
);
}
@Post('issues/:issueId/comments')
create(
@Param('issueId', ParseIntPipe) issueId: number,
@Body() dto: CreateCommentRequestDto,
@Session() session: UserSession,
) {
return this.handleErrors(() =>
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)),
);
}
/**
* SSE-поток комментариев одной задачи: клиент открывает его на странице
* задачи и закрывает при уходе. После переподключения клиенту стоит
* перезапросить список — события, пришедшие во время обрыва, не повторяются.
* Доступ проверяет IssueVisibleGuard — так клиент без доступа получает 404.
*/
@Sse('issues/:issueId/comments/events')
@UseGuards(IssueVisibleGuard)
events(
@Param('issueId', ParseIntPipe) issueId: number,
@Session() session: UserSession,
): Promise<Observable<MessageEvent>> {
return this.handleErrors(async () => {
const events$ = await this.watchIssueCommentsUseCase.execute(
new WatchIssueCommentsQuery(issueId, session.user.id),
);
return events$.pipe(map((event) => ({ type: event.type, data: event.comment })));
});
}
@Patch('comments/:id')
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)),
);
}
@Delete('comments/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string, @Session() session: UserSession) {
await this.handleErrors(() => this.deleteCommentUseCase.execute(new DeleteCommentCommand(id, session.user.id)));
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
if (error instanceof CommentForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
export class CreateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
}
@@ -0,0 +1,10 @@
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
export class ListCommentsDto {
@IntDecorator({ isOptional: true, min: 1, max: 100 })
limit: number = 50;
/** id последнего полученного комментария (nextCursor из предыдущего ответа) */
@StringDecorator({ isOptional: true })
cursor?: string;
}
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
export class UpdateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
}
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest';
import { BadRequestException, type ExecutionContext, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { CommentAccessService } from '../../application/services/comment-access.service.js';
import { IssueVisibleGuard } from './issue-visible.guard.js';
const buildContext = (params: Record<string, string>, userId?: string) =>
({
switchToHttp: () => ({
getRequest: () => ({ params, session: userId ? { user: { id: userId } } : undefined }),
}),
}) as unknown as ExecutionContext;
const buildGuard = (visible: boolean) => {
const commentAccess = { isIssueVisible: vi.fn().mockResolvedValue(visible) } as unknown as CommentAccessService;
return { guard: new IssueVisibleGuard(commentAccess), commentAccess };
};
describe('IssueVisibleGuard', () => {
it('allows access to a visible issue', async () => {
const { guard, commentAccess } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-1'))).resolves.toBe(true);
expect(commentAccess.isIssueVisible).toHaveBeenCalledWith(1, 'user-1');
});
it('throws NotFoundException when the issue is not visible', async () => {
const { guard } = buildGuard(false);
await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-2'))).rejects.toThrow(NotFoundException);
});
it('throws BadRequestException for a non-numeric issueId', async () => {
const { guard, commentAccess } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: 'abc' }, 'user-1'))).rejects.toThrow(BadRequestException);
expect(commentAccess.isIssueVisible).not.toHaveBeenCalled();
});
it('throws UnauthorizedException without a session', async () => {
const { guard } = buildGuard(true);
await expect(guard.canActivate(buildContext({ issueId: '1' }))).rejects.toThrow(UnauthorizedException);
});
});
@@ -0,0 +1,48 @@
import {
BadRequestException,
type CanActivate,
type ExecutionContext,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { UserSession } from '@thallesp/nestjs-better-auth';
import { CommentAccessService } from '../../application/services/comment-access.service.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
/**
* Проверяет доступ к задаче из `:issueId` ДО вызова обработчика.
*
* Нужен SSE-эндпоинтам: при наличии глобального interceptor'а Nest вызывает
* @Sse-обработчик уже после отправки заголовков 200, и ошибка из него доходит
* до клиента только как `event: error`. Guard же отвечает честным 404.
*
* Сессию в request кладёт глобальный AuthGuard, который выполняется раньше.
*/
@Injectable()
export class IssueVisibleGuard implements CanActivate {
constructor(private readonly commentAccess: CommentAccessService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<{ params: Record<string, string | undefined>; session?: UserSession }>();
const userId = request.session?.user.id;
if (!userId) {
throw new UnauthorizedException();
}
// Пайпы (ParseIntPipe) выполняются после guard'ов — парсим сами
const issueId = Number(request.params.issueId);
if (!Number.isInteger(issueId)) {
throw new BadRequestException('Validation failed (numeric string is expected)');
}
if (!(await this.commentAccess.isIssueVisible(issueId, userId))) {
throw new NotFoundException(new IssueNotFoundException(issueId).message);
}
return true;
}
}
@@ -8,9 +8,9 @@ export class IssueWithUsersDto {
status: string; status: string;
priority: number; priority: number;
assigneeId: string | null; assigneeId: string | null;
reporterId: string; reporterId: string | null;
assignee: UserDto | null; assignee: UserDto | null;
reporter: UserDto; reporter: UserDto | null;
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -6,7 +6,7 @@ export class IssueDto {
status: string; status: string;
priority: number; priority: number;
assigneeId: string | null; assigneeId: string | null;
reporterId: string; reporterId: string | null;
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -23,11 +23,6 @@ export class IssueApplicationMapper {
/** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */ /** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto { static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
const reporter = usersById.get(issue.reporter);
if (!reporter) {
throw new Error(`Reporter user ${issue.reporter} not found for issue ${issue.key}`);
}
return { return {
id: issue.id as number, id: issue.id as number,
key: issue.key, key: issue.key,
@@ -38,7 +33,7 @@ export class IssueApplicationMapper {
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null, assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
reporter, reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null,
projectId: issue.projectId, projectId: issue.projectId,
dueDate: issue.dueDate, dueDate: issue.dueDate,
createdAt: issue.createdAt, createdAt: issue.createdAt,
@@ -12,7 +12,8 @@ interface IssueProps {
status: IssueStatus; status: IssueStatus;
priority: IssuePriority; priority: IssuePriority;
assignee: string | null; assignee: string | null;
reporter: string; /** null — автор задачи удалил аккаунт */
reporter: string | null;
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -72,7 +73,7 @@ export class Issue {
status: string; status: string;
priority: number; priority: number;
assignee: string | null; assignee: string | null;
reporter: string; reporter: string | null;
projectId: string; projectId: string;
dueDate: Date | null; dueDate: Date | null;
createdAt: Date; createdAt: Date;
@@ -122,7 +123,7 @@ export class Issue {
return this.props.assignee; return this.props.assignee;
} }
get reporter(): string { get reporter(): string | null {
return this.props.reporter; return this.props.reporter;
} }
@@ -24,14 +24,14 @@ export class IssueDetailType {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
assigneeId: string | null; assigneeId: string | null;
@Field() @Field(() => String, { nullable: true })
reporterId: string; reporterId: string | null;
@Field(() => UserType, { nullable: true }) @Field(() => UserType, { nullable: true })
assignee: UserType | null; assignee: UserType | null;
@Field(() => UserType) @Field(() => UserType, { nullable: true })
reporter: UserType; reporter: UserType | null;
@Field() @Field()
projectId: string; projectId: string;
@@ -23,8 +23,8 @@ export class IssueType {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
assigneeId: string | null; assigneeId: string | null;
@Field() @Field(() => String, { nullable: true })
reporterId: string; reporterId: string | null;
@Field() @Field()
projectId: string; projectId: string;
+119
View File
@@ -0,0 +1,119 @@
# Notification module
Отправка уведомлений пользователям по email и push. Живёт в монолите `ulitka`,
доставка происходит в том же процессе — без отдельного сервиса/брокера.
## Хранение данных
Таблицы в Prisma-схеме (`prisma/schema.prisma`), с префиксом `notification_`:
| Таблица | Модель | Назначение |
|---|---|---|
| `notification_device_token` | `NotificationDeviceToken` | push-токены устройств (`userId, token, platform`) |
| `notification_preference` | `NotificationPreference` | вкл/выкл канал для пользователя (`userId, channel, enabled`). Категорий уведомлений нет — настройка одна на канал. Если записи нет, канал считается включённым |
| `notification_message` | `NotificationMessage` | лог отправок: канал, статус, содержимое, ошибка, время отправки |
Миграции пока не применялись — только `prisma generate` для типов. Первая
`prisma migrate dev` создаст все три таблицы.
**Каналы и статусы — строки, а не enum БД.** `channel`/`status` — это
`String`-колонки, допустимые значения валидируются в коде через value-объекты
(`domain/value-objects/notification-channel.vo.ts`,
`.../notification-status.vo.ts`), а не типом Postgres. Это сознательный выбор
(тот же паттерн, что у `Issue.status` в issue-модуле): расширить список
каналов — правка константы в коде, без миграции БД. Убрать/переименовать
enum-значение в Postgres, наоборот, болезненно (`DROP VALUE` не
поддерживается).
## Как попросить отправить уведомление
Публичный вход — `NotificationsService.notify()`. Любой другой модуль зовёт
его и не знает, что уведомление уйдёт через email/push:
```ts
notificationsService.notify({
userId: 'user-id',
content: 'Заявка одобрена',
subject: 'Статус заявки изменился', // опционально, тема письма
channels: ['EMAIL'], // опционально; без указания — все включённые у пользователя каналы
});
```
Это fire-and-forget: вызывающий код не блокируется и не получает результат
доставки.
### Механизм передачи — EventEmitter2, не Redis
`notify()` эмитит внутрипроцессное событие `notification.notify`
(`@nestjs/event-emitter`, зарегистрирован в `AppModule` через
`EventEmitterModule.forRoot()`). `NotificationDispatcherService` слушает его
через `@OnEvent`.
Почему не Redis/очередь: `ulitka` — монолит в одном процессе, такой же
in-process pub/sub уже используется в issue-модуле
(`IssueEventsService` на RxJS `Subject`). Redis дал бы межпроцессность и
персистентность очереди с ретраями — сейчас это не нужно, и инфраструктуры
для него в проекте нет. Если понадобится (at-least-once, ретраи, вынос
уведомлений в отдельный сервис) — меняется реализация под `notify()`,
публичный контракт не трогается.
## Поток обработки (`NotificationDispatcherService`)
1. Получает событие `notification.notify`.
2. Определяет каналы: явные из `payload.channels`, либо (если не заданы) —
все каналы, для которых есть отправщик; из них исключаются явно
выключенные в `notification_preference`.
3. Для каждого канала создаёт запись в `notification_message` со статусом
`PENDING`.
4. Отправляет через `NotificationChannelSender` этого канала, обновляет
статус на `SENT`/`FAILED` (+ текст ошибки).
## Отправщики каналов
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
(общая БД, синхронный поход не нужен) и шлёт через существующий
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
- `PushChannelSender` — читает токены устройств из `notification_device_token`,
но сама отправка — заглушка (лог вместо реального вызова). Провайдер
(FCM/APNs) ещё не подключён; когда появится, меняется только тело
`send()`, остальной пайплайн — нет.
## HTTP API
Базовый путь — `/api/notifications` (глобальный prefix `api`), авторизация —
как у остальных контроллеров, через сессию (`@Session()`), `userId` берётся
из неё, а не из тела запроса.
| Метод | Путь | Тело | Действие |
|---|---|---|---|
| `POST` | `/device-tokens` | `{ token, platform: 'ios'\|'android'\|'web' }` | Регистрирует/обновляет push-токен |
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
## Структура
```
domain/
events/notify.event.ts — событие + payload
value-objects/ — допустимые channel/status + валидация
repositories/*.interface.ts — интерфейсы + DI-токены (Symbol)
channel-sender.interface.ts — контракт отправщика канала
application/services/
notifications.service.ts — публичный фасад модуля
notification-dispatcher.service.ts — обработчик события, раскладка по каналам
infrastructure/
persistence/prisma/repositories/ — Prisma-реализации репозиториев
senders/ — EmailChannelSender, PushChannelSender
presentation/
controllers/notification.controller.ts
dto/ — DTO с class-validator для входящих запросов
notification.module.ts — сборка DI
```
## Чего пока нет (сознательно, вне текущего объёма)
- Реального push-провайдера (FCM/APNs) — только заглушка с логированием.
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
- SMS-канала — при необходимости добавляется как ещё одно значение в
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
@@ -0,0 +1,72 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NOTIFY_EVENT, type NotifyEventPayload } from '../../domain/events/notify.event.js';
import { NOTIFICATION_CHANNEL_SENDERS, type NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
type INotificationPreferenceRepository,
} from '../../domain/repositories/notification-preference.repository.interface.js';
import {
NOTIFICATION_MESSAGE_REPOSITORY,
type INotificationMessageRepository,
} from '../../domain/repositories/notification-message.repository.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
/**
* Слушает события `notification.notify` и раскладывает их по каналам:
* смотрит подписки пользователя, шлёт через нужные NotificationChannelSender,
* пишет результат в лог notification_message.
*/
@Injectable()
export class NotificationDispatcherService {
private readonly logger = new Logger(NotificationDispatcherService.name);
constructor(
@Inject(NOTIFICATION_CHANNEL_SENDERS) private readonly senders: NotificationChannelSender[],
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY) private readonly preferences: INotificationPreferenceRepository,
@Inject(NOTIFICATION_MESSAGE_REPOSITORY) private readonly messages: INotificationMessageRepository,
) {}
@OnEvent(NOTIFY_EVENT)
async handle(payload: NotifyEventPayload): Promise<void> {
const channels = await this.resolveChannels(payload);
await Promise.all(channels.map((channel) => this.dispatchToChannel(channel, payload)));
}
/** Каналы из payload, отфильтрованные по подпискам; без явных подписок — все каналы включены по умолчанию */
private async resolveChannels(payload: NotifyEventPayload): Promise<NotificationChannelValue[]> {
const userPreferences = await this.preferences.findByUserId(payload.userId);
const disabledChannels = new Set(
userPreferences.filter((preference) => !preference.enabled).map((preference) => preference.channel),
);
const candidateChannels = payload.channels ?? this.senders.map((sender) => sender.channel);
return candidateChannels.filter((channel) => !disabledChannels.has(channel));
}
private async dispatchToChannel(channel: NotificationChannelValue, payload: NotifyEventPayload): Promise<void> {
const sender = this.senders.find((candidate) => candidate.channel === channel);
if (!sender) {
this.logger.warn(`No sender registered for channel ${channel}`);
return;
}
const message = await this.messages.create({
userId: payload.userId,
channel,
subject: payload.subject,
content: payload.content,
});
try {
await sender.send({ userId: payload.userId, subject: payload.subject, content: payload.content });
await this.messages.markStatus(message.id, 'SENT');
} catch (error) {
this.logger.error(
`Failed to deliver notification ${message.id} via ${channel}`,
error instanceof Error ? error.stack : undefined,
);
await this.messages.markStatus(message.id, 'FAILED', error instanceof Error ? error.message : String(error));
}
}
}
@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { NOTIFY_EVENT, type NotifyEventPayload } from '../../domain/events/notify.event.js';
import {
DEVICE_TOKEN_REPOSITORY,
type IDeviceTokenRepository,
} from '../../domain/repositories/device-token.repository.interface.js';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
type INotificationPreferenceRepository,
} from '../../domain/repositories/notification-preference.repository.interface.js';
import {
NOTIFICATION_CHANNELS,
type NotificationChannelValue,
} from '../../domain/value-objects/notification-channel.vo.js';
export interface ResolvedNotificationPreference {
channel: NotificationChannelValue;
enabled: boolean;
}
/** Публичный вход модуля для остальных частей приложения. */
@Injectable()
export class NotificationsService {
constructor(
private readonly eventEmitter: EventEmitter2,
@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository,
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY) private readonly preferences: INotificationPreferenceRepository,
) {}
/**
* Просит отправить уведомление, не дожидаясь доставки (fire-and-forget):
* вызывающий код не должен блокироваться и не должен знать про email/push.
*/
notify(payload: NotifyEventPayload): void {
this.eventEmitter.emit(NOTIFY_EVENT, payload);
}
registerDeviceToken(userId: string, token: string, platform: string): Promise<void> {
return this.deviceTokens.upsert(userId, token, platform);
}
removeDeviceToken(userId: string, token: string): Promise<void> {
return this.deviceTokens.remove(userId, token);
}
/** Все каналы с эффективным enabled — если записи в БД нет, канал считается включённым */
async getPreferences(userId: string): Promise<ResolvedNotificationPreference[]> {
const saved = await this.preferences.findByUserId(userId);
const enabledByChannel = new Map(saved.map((preference) => [preference.channel, preference.enabled]));
return NOTIFICATION_CHANNELS.map((channel) => ({
channel,
enabled: enabledByChannel.get(channel) ?? true,
}));
}
setPreference(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void> {
return this.preferences.set(userId, channel, enabled);
}
}
@@ -0,0 +1,9 @@
import type { NotificationChannelValue } from './value-objects/notification-channel.vo.js';
/** DI-токен для набора отправщиков, по одному на каждый канал доставки */
export const NOTIFICATION_CHANNEL_SENDERS = Symbol('NOTIFICATION_CHANNEL_SENDERS');
export interface NotificationChannelSender {
readonly channel: NotificationChannelValue;
send(params: { userId: string; subject?: string; content: string }): Promise<void>;
}
@@ -0,0 +1,16 @@
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
/**
* Событие, которым любой модуль просит отправить уведомление, не зная,
* как именно notification-модуль его доставит (email/push, сейчас/потом).
*/
export const NOTIFY_EVENT = 'notification.notify';
export interface NotifyEventPayload {
userId: string;
/** Если не задано — уведомление уходит по всем каналам, включённым у пользователя */
channels?: NotificationChannelValue[];
/** Используется email-каналом как тема письма */
subject?: string;
content: string;
}
@@ -0,0 +1,11 @@
import type { NotificationDeviceToken } from '../../../../generated/prisma/client.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const DEVICE_TOKEN_REPOSITORY = Symbol('DEVICE_TOKEN_REPOSITORY');
export interface IDeviceTokenRepository {
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
upsert(userId: string, token: string, platform: string): Promise<void>;
remove(userId: string, token: string): Promise<void>;
findByUserId(userId: string): Promise<NotificationDeviceToken[]>;
}
@@ -0,0 +1,16 @@
import type { NotificationMessage } from '../../../../generated/prisma/client.js';
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
import type { NotificationStatusValue } from '../value-objects/notification-status.vo.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const NOTIFICATION_MESSAGE_REPOSITORY = Symbol('NOTIFICATION_MESSAGE_REPOSITORY');
export interface INotificationMessageRepository {
create(params: {
userId: string;
channel: NotificationChannelValue;
subject?: string;
content: string;
}): Promise<NotificationMessage>;
markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void>;
}
@@ -0,0 +1,10 @@
import type { NotificationPreference } from '../../../../generated/prisma/client.js';
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const NOTIFICATION_PREFERENCE_REPOSITORY = Symbol('NOTIFICATION_PREFERENCE_REPOSITORY');
export interface INotificationPreferenceRepository {
findByUserId(userId: string): Promise<NotificationPreference[]>;
set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void>;
}
@@ -0,0 +1,23 @@
export const NOTIFICATION_CHANNELS = ['EMAIL', 'PUSH'] as const;
export type NotificationChannelValue = (typeof NOTIFICATION_CHANNELS)[number];
export class NotificationChannel {
private constructor(private readonly _value: NotificationChannelValue) {}
static create(value: string): NotificationChannel {
if (!NOTIFICATION_CHANNELS.includes(value as NotificationChannelValue)) {
throw new Error(`Недопустимый канал уведомлений: "${value}". Разрешены: ${NOTIFICATION_CHANNELS.join(', ')}`);
}
return new NotificationChannel(value as NotificationChannelValue);
}
get value(): NotificationChannelValue {
return this._value;
}
equals(other: NotificationChannel): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,27 @@
export const NOTIFICATION_STATUSES = ['PENDING', 'SENT', 'FAILED'] as const;
export type NotificationStatusValue = (typeof NOTIFICATION_STATUSES)[number];
export class NotificationStatus {
private constructor(private readonly _value: NotificationStatusValue) {}
static create(value: string): NotificationStatus {
if (!NOTIFICATION_STATUSES.includes(value as NotificationStatusValue)) {
throw new Error(`Недопустимый статус уведомления: "${value}". Разрешены: ${NOTIFICATION_STATUSES.join(', ')}`);
}
return new NotificationStatus(value as NotificationStatusValue);
}
static pending(): NotificationStatus {
return new NotificationStatus('PENDING');
}
get value(): NotificationStatusValue {
return this._value;
}
equals(other: NotificationStatus): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationDeviceToken } from '../../../../../../generated/prisma/client.js';
import type { IDeviceTokenRepository } from '../../../../domain/repositories/device-token.repository.interface.js';
@Injectable()
export class DeviceTokenPrismaRepository implements IDeviceTokenRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async upsert(userId: string, token: string, platform: string): Promise<void> {
await this.prisma.notificationDeviceToken.upsert({
where: { userId_token: { userId, token } },
create: { userId, token, platform },
update: { platform },
});
}
async remove(userId: string, token: string): Promise<void> {
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
}
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationMessage } from '../../../../../../generated/prisma/client.js';
import type { INotificationMessageRepository } from '../../../../domain/repositories/notification-message.repository.interface.js';
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
import type { NotificationStatusValue } from '../../../../domain/value-objects/notification-status.vo.js';
@Injectable()
export class NotificationMessagePrismaRepository implements INotificationMessageRepository {
constructor(private readonly prisma: PrismaAdapter) {}
create(params: {
userId: string;
channel: NotificationChannelValue;
subject?: string;
content: string;
}): Promise<NotificationMessage> {
return this.prisma.notificationMessage.create({ data: params });
}
async markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void> {
await this.prisma.notificationMessage.update({
where: { id },
data: { status, error, sentAt: status === 'SENT' ? new Date() : undefined },
});
}
}
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationPreference } from '../../../../../../generated/prisma/client.js';
import type { INotificationPreferenceRepository } from '../../../../domain/repositories/notification-preference.repository.interface.js';
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
@Injectable()
export class NotificationPreferencePrismaRepository implements INotificationPreferenceRepository {
constructor(private readonly prisma: PrismaAdapter) {}
findByUserId(userId: string): Promise<NotificationPreference[]> {
return this.prisma.notificationPreference.findMany({ where: { userId } });
}
async set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void> {
await this.prisma.notificationPreference.upsert({
where: { userId_channel: { userId, channel } },
create: { userId, channel, enabled },
update: { enabled },
});
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import EmailSender from '../../../../lib/email-sender.js';
import { PrismaAdapter } from '../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
@Injectable()
export class EmailChannelSender implements NotificationChannelSender {
readonly channel: NotificationChannelValue = 'EMAIL';
constructor(private readonly prisma: PrismaAdapter) {}
async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: params.userId },
select: { email: true },
});
await EmailSender.getTransporter().sendMail({
to: user.email,
subject: params.subject ?? 'Уведомление',
text: params.content,
});
}
}
@@ -0,0 +1,31 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
import {
DEVICE_TOKEN_REPOSITORY,
type IDeviceTokenRepository,
} from '../../domain/repositories/device-token.repository.interface.js';
/**
* Провайдер push-доставки (FCM/APNs) пока не подключён — отправка логируется.
* Токены уже читаются из БД, так что при подключении провайдера
* меняется только тело send(), остальной пайплайн трогать не нужно.
*/
@Injectable()
export class PushChannelSender implements NotificationChannelSender {
private readonly logger = new Logger(PushChannelSender.name);
readonly channel: NotificationChannelValue = 'PUSH';
constructor(@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository) {}
async send(params: { userId: string; content: string }): Promise<void> {
const tokens = await this.deviceTokens.findByUserId(params.userId);
if (tokens.length === 0) {
throw new Error(`No device tokens registered for user ${params.userId}`);
}
this.logger.warn(
`Push provider is not configured yet, skipping actual delivery to ${tokens.length} device(s): ${params.content}`,
);
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { DEVICE_TOKEN_REPOSITORY } from './domain/repositories/device-token.repository.interface.js';
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository.interface.js';
import { NOTIFICATION_MESSAGE_REPOSITORY } from './domain/repositories/notification-message.repository.interface.js';
import { NOTIFICATION_CHANNEL_SENDERS } from './domain/channel-sender.interface.js';
import { DeviceTokenPrismaRepository } from './infrastructure/persistence/prisma/repositories/device-token.prisma-repository.js';
import { NotificationPreferencePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-preference.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 { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
import { NotificationsService } from './application/services/notifications.service.js';
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
import { NotificationController } from './presentation/controllers/notification.controller.js';
@Module({
imports: [PrismaModule],
controllers: [NotificationController],
providers: [
{
// Domain видит только интерфейс, конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: DEVICE_TOKEN_REPOSITORY,
useClass: DeviceTokenPrismaRepository,
},
{
provide: NOTIFICATION_PREFERENCE_REPOSITORY,
useClass: NotificationPreferencePrismaRepository,
},
{
provide: NOTIFICATION_MESSAGE_REPOSITORY,
useClass: NotificationMessagePrismaRepository,
},
EmailChannelSender,
PushChannelSender,
{
// Диспетчер обращается к списку каналов, не завязываясь на конкретные классы
provide: NOTIFICATION_CHANNEL_SENDERS,
useFactory: (email: EmailChannelSender, push: PushChannelSender) => [email, push],
inject: [EmailChannelSender, PushChannelSender],
},
NotificationsService,
NotificationDispatcherService,
],
exports: [NotificationsService],
})
export class NotificationModule {}
@@ -0,0 +1,59 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Put,
Post,
} from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { NotificationsService } from '../../application/services/notifications.service.js';
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js';
import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js';
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notifications: NotificationsService) {}
@Post('device-tokens')
registerDeviceToken(@Body() dto: RegisterDeviceTokenDto, @Session() session: UserSession) {
return this.notifications.registerDeviceToken(session.user.id, dto.token, dto.platform);
}
/** Токен передаётся в теле, а не в URL — может содержать символы, небезопасные для path-сегмента */
@Delete('device-tokens')
@HttpCode(HttpStatus.NO_CONTENT)
async removeDeviceToken(@Body() dto: RemoveDeviceTokenDto, @Session() session: UserSession): Promise<void> {
await this.notifications.removeDeviceToken(session.user.id, dto.token);
}
@Get('preferences')
getPreferences(@Session() session: UserSession) {
return this.notifications.getPreferences(session.user.id);
}
@Put('preferences/:channel')
setPreference(
@Param('channel') channelParam: string,
@Body() dto: SetNotificationPreferenceDto,
@Session() session: UserSession,
) {
const channel = this.parseChannel(channelParam);
return this.notifications.setPreference(session.user.id, channel, dto.enabled);
}
/** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */
private parseChannel(value: string) {
try {
return NotificationChannel.create(value).value;
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : 'Invalid channel');
}
}
}
@@ -0,0 +1,12 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
const PLATFORMS = ['ios', 'android', 'web'] as const;
export class RegisterDeviceTokenDto {
@IsString()
@IsNotEmpty()
token: string;
@IsIn(PLATFORMS)
platform: (typeof PLATFORMS)[number];
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RemoveDeviceTokenDto {
@IsString()
@IsNotEmpty()
token: string;
}
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetNotificationPreferenceDto {
@IsBoolean()
enabled: boolean;
}
@@ -7,11 +7,7 @@ export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
export interface IProjectRepository { export interface IProjectRepository {
/** Проект виден владельцу и участникам проекта (ProjectMember) */ /** Проект виден владельцу и участникам проекта (ProjectMember) */
findById(id: string, userId: string): Promise<Project | null>; findById(id: string, userId: string): Promise<Project | null>;
findAll(params: { findAll(params: { limit: number; page: number; userId: string }): Promise<{ list: Project[]; meta: PaginationMeta }>;
limit: number;
page: number;
userId: string;
}): Promise<{ list: Project[]; meta: PaginationMeta }>;
create(project: Project): Promise<Project>; create(project: Project): Promise<Project>;
update(project: Project): Promise<void>; update(project: Project): Promise<void>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
+366
View File
@@ -0,0 +1,366 @@
нужна помощь по архитектуре, есть основной сервис заявок и сервис уведомлений, уведомления должны обладать категориями и типами (пуш, емеил). Связываются сервисы через брокер (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 об этом не знает вообще, только публикует факт события.
-1
View File
@@ -1 +0,0 @@
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2023.full.d.ts","./node_modules/reflect-metadata/index.d.ts","./node_modules/@nestjs/common/decorators/core/bind.decorator.d.ts","./node_modules/@nestjs/common/interfaces/abstract.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller-metadata.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller.interface.d.ts","./node_modules/@nestjs/common/interfaces/features/arguments-host.interface.d.ts","./node_modules/@nestjs/common/interfaces/exceptions/exception-filter.interface.d.ts","./node_modules/rxjs/dist/types/internal/subscription.d.ts","./node_modules/rxjs/dist/types/internal/subscriber.d.ts","./node_modules/rxjs/dist/types/internal/operator.d.ts","./node_modules/rxjs/dist/types/internal/observable.d.ts","./node_modules/rxjs/dist/types/internal/types.d.ts","./node_modules/rxjs/dist/types/internal/operators/audit.d.ts","./node_modules/rxjs/dist/types/internal/operators/audittime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffer.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffercount.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertoggle.d.tsLine truncated