Compare commits
7
Commits
382cafa152
...
5cfc8986d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cfc8986d7 | ||
|
|
4b6ecfbf5d | ||
|
|
9e0bef2527 | ||
|
|
3272c75512 | ||
|
|
2696f33a7e | ||
|
|
4b8c855855 | ||
|
|
60391ee123 |
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
/build
|
/build
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
|||||||
Generated
+23
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
+93
-16
@@ -8,19 +8,23 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
email String
|
email String
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
assigneeIssues Issue[] @relation(name: "assigneeIssues")
|
assigneeIssues Issue[] @relation(name: "assigneeIssues")
|
||||||
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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+57
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+68
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+46
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+85
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
export class WatchIssueCommentsQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly issueId: number,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+42
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+31
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('Issue', () => {
|
|||||||
assignee: 'user-3',
|
assignee: 'user-3',
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -95,7 +95,7 @@ describe('Issue', () => {
|
|||||||
assignee: null,
|
assignee: null,
|
||||||
reporter: 'user-1',
|
reporter: 'user-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
dueDate: null,
|
dueDate: null,
|
||||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
+16
@@ -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>;
|
||||||
|
}
|
||||||
+10
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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 } });
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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>;
|
||||||
|
|||||||
@@ -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 +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
|
|
||||||
Reference in new issue
Block a user