Compare commits

Author SHA1 Message Date
vadim 46903e9ec5 avatar
Gitea Actions / Deploy (push) Successful in 1m52s
2026-10-23 19:26:26 +03:00
vadim f085517732 email 2026-10-23 19:26:26 +03:00
vadim de1b3754df otp code, packages update
Gitea Actions / Deploy (push) Successful in 1m34s
2026-10-19 17:23:50 +03:00
vadim c5691da2b8 issue type
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:37:58 +03:00
vadim afc37f8fdb send push to assignee
Gitea Actions / Deploy (push) Successful in 32s
2026-10-18 20:19:57 +03:00
vadim 8d02d25da0 firebase messaging
Gitea Actions / Deploy (push) Successful in 2m1s
2026-10-16 16:51:55 +03:00
vadim 5ad0ecdf4c typo
Gitea Actions / Deploy (push) Successful in 36s
2026-10-14 21:26:15 +03:00
vadim 7aecc6f96e calendar due date
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-12 11:08:27 +03:00
97 changed files with 3970 additions and 1518 deletions

No files matched your search

+2
View File
@@ -11,3 +11,5 @@ coverage
tsconfig.build.tsbuildinfo tsconfig.build.tsbuildinfo
src/generated src/generated
storage storage
configs
service-account.json
+1 -1
View File
@@ -1,4 +1,4 @@
name: Gitea Actions Demo name: Gitea Actions
run-name: Deploy 🚀 run-name: Deploy 🚀
on: on:
push: push:
+2
View File
@@ -61,3 +61,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Local file storage (FILE_STORAGE=local) # Local file storage (FILE_STORAGE=local)
/storage /storage
/configs
configs/service-account.json
+2412 -1054
View File
File diff suppressed because it is too large. Load diff
+18 -19
View File
@@ -24,48 +24,47 @@
"dependencies": { "dependencies": {
"@apollo/server": "^5.5.1", "@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2", "@as-integrations/express5": "^1.1.2",
"@aws-sdk/client-s3": "^3.1145.0", "@aws-sdk/client-s3": "^3.1152.0",
"@better-auth/prisma-adapter": "^1.7.2", "@better-auth/prisma-adapter": "^1.7.11",
"@nestjs/apollo": "^14.0.0", "@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.2.0",
"@nestjs/config": "^12.0.0", "@nestjs/config": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.2.0",
"@nestjs/event-emitter": "^12.0.1", "@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0", "@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1", "@nestjs/platform-express": "^12.2.0",
"@nestjs/schedule": "^12.0.2", "@nestjs/schedule": "^12.0.2",
"@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0", "@prisma/client": "^7.10.0",
"@thallesp/nestjs-better-auth": "^2.7.0", "@thallesp/nestjs-better-auth": "^2.8.0",
"better-auth": "^1.7.2", "better-auth": "^1.7.11",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"file-type": "^22.1.1", "file-type": "^22.2.0",
"firebase-admin": "^14.5.0",
"graphql": "^16.14.2", "graphql": "^16.14.2",
"nodemailer": "^10.0.0", "nodemailer": "^10.1.7",
"pg": "^8.23.0", "pg": "^8.23.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^1.1.1", "valibot": "^1.5.0"
"valibot": "^1.4.2"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^12.0.0", "@nestjs/cli": "^12.0.0",
"@nestjs/mau": "^0.2.6", "@nestjs/mau": "^0.2.8",
"@nestjs/schematics": "^12.0.0", "@nestjs/schematics": "^12.0.0",
"@nestjs/testing": "^12.0.1", "@nestjs/testing": "^12.2.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/multer": "^2.3.0", "@types/multer": "^2.3.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/supertest": "^7.0.0", "@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^4.1.2",
"oxlint": "^1.58.0", "oxlint": "^1.88.0",
"prettier": "^3.4.2", "prettier": "^3.9.10",
"prisma": "^7.10.0", "prisma": "^7.10.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.3.1",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.2" "vitest": "^4.1.2"
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "issue_projectId_dueDate_idx" ON "issue"("projectId", "dueDate");
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "issue" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'TASK';
-- CreateIndex
CREATE INDEX "issue_projectId_type_idx" ON "issue"("projectId", "type");
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "user" ADD COLUMN "avatarFileId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "user_avatarFileId_key" ON "user"("avatarFileId");
-- AddForeignKey
ALTER TABLE "user" ADD CONSTRAINT "user_avatarFileId_fkey" FOREIGN KEY ("avatarFileId") REFERENCES "file"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+10 -2
View File
@@ -13,6 +13,10 @@ model User {
email String email String
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
/// Файл аватара; image хранит его URL для better-auth и клиента.
/// Меняется только через PUT /users/me/avatar — там проверяется, что файл свой.
avatarFileId String? @unique
avatarFile File? @relation(name: "userAvatar", fields: [avatarFileId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
@@ -25,7 +29,7 @@ model User {
notificationPreferences NotificationPreference[] notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[] notificationMessages NotificationMessage[]
comments Comment[] comments Comment[]
uploadedFiles File[] uploadedFiles File[] @relation(name: "fileUploader")
issueChanges IssueChange[] issueChanges IssueChange[]
@@unique([email]) @@unique([email])
@@ -88,6 +92,7 @@ model Issue {
content String? content String?
status String status String
priority Int @default(0) priority Int @default(0)
type String @default("TASK")
assigneeId String? assigneeId String?
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull) assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull)
reporterId String? reporterId String?
@@ -108,6 +113,8 @@ model Issue {
@@index([createdAt]) @@index([createdAt])
@@index([projectId, createdAt]) @@index([projectId, createdAt])
@@index([dueDate]) @@index([dueDate])
@@index([projectId, dueDate])
@@index([projectId, type])
@@map("issue") @@map("issue")
} }
@@ -226,11 +233,12 @@ model File {
mimeType String mimeType String
size Int size Int
uploaderId String? uploaderId String?
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) uploader User? @relation(name: "fileUploader", fields: [uploaderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[] issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[] commentAttachments CommentAttachment[]
avatarOf User? @relation(name: "userAvatar")
@@unique([storageKey]) @@unique([storageKey])
@@index([uploaderId]) @@index([uploaderId])
+2 -8
View File
@@ -5,6 +5,7 @@ import { IssueModule } from './modules/issue/issue.module.js';
import { ProjectModule } from './modules/project/project.module.js'; import { ProjectModule } from './modules/project/project.module.js';
import { UserModule } from './modules/user/user.module.js'; import { UserModule } from './modules/user/user.module.js';
import { StatusModule } from './modules/status/status.module.js'; import { StatusModule } from './modules/status/status.module.js';
import { IssueTypeModule } from './modules/issue-type/issue-type.module.js';
import { NotificationModule } from './modules/notification/notification.module.js'; import { NotificationModule } from './modules/notification/notification.module.js';
import { CommentModule } from './modules/comment/comment.module.js'; import { CommentModule } from './modules/comment/comment.module.js';
import { FileModule } from './modules/file/file.module.js'; import { FileModule } from './modules/file/file.module.js';
@@ -25,21 +26,13 @@ import { join } from 'node:path';
isGlobal: true, isGlobal: true,
cache: true, cache: true,
}), }),
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
// Периодические задачи (@Cron), например сборка файлов-сирот в FileModule
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({ GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver, driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'), autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true, sortSchema: true,
}), }),
// TypeOrmModule.forRootAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
// useFactory: pgConfig,
// }),
AuthModule.forRoot({ AuthModule.forRoot({
auth: auth, auth: auth,
bodyParser: { bodyParser: {
@@ -52,6 +45,7 @@ import { join } from 'node:path';
ProjectModule, ProjectModule,
UserModule, UserModule,
StatusModule, StatusModule,
IssueTypeModule,
NotificationModule, NotificationModule,
CommentModule, CommentModule,
FileModule, FileModule,
+19 -1
View File
@@ -1,5 +1,5 @@
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
import { applyDecorators } from '@nestjs/common'; import { applyDecorators } from '@nestjs/common';
interface ToStringOptions { interface ToStringOptions {
@@ -11,6 +11,7 @@ interface BaseDecoratorParams {
} }
export interface StringDecoratorParams extends BaseDecoratorParams {} export interface StringDecoratorParams extends BaseDecoratorParams {}
export interface BooleanDecoratorParams extends BaseDecoratorParams {}
export interface IntDecoratorParams extends BaseDecoratorParams { export interface IntDecoratorParams extends BaseDecoratorParams {
min?: number; min?: number;
max?: number; max?: number;
@@ -24,6 +25,15 @@ const ToNumber = () =>
return Number.isNaN(parsed) ? undefined : parsed; return Number.isNaN(parsed) ? undefined : parsed;
}); });
/** Query-параметры приходят строками: 'true'/'false' → boolean, остальное оставляем как есть для IsBoolean */
const ToBoolean = () =>
Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
});
const ToString = (options?: ToStringOptions) => const ToString = (options?: ToStringOptions) =>
Transform(({ value }: TransformFnParams) => { Transform(({ value }: TransformFnParams) => {
if (value === undefined || value === null) return undefined; if (value === undefined || value === null) return undefined;
@@ -52,3 +62,11 @@ export function IntDecorator(params?: IntDecoratorParams) {
return applyDecorators(...args); return applyDecorators(...args);
} }
export function BooleanDecorator(params?: BooleanDecoratorParams) {
const args = [IsBoolean(), ToBoolean()];
if (params?.isOptional) args.push(IsOptional());
return applyDecorators(...args);
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export function pgConfig(config: ConfigService): TypeOrmModuleOptions { export function pgConfig(config: ConfigService): unknown {
return { return {
type: 'postgres', type: 'postgres',
url: config.getOrThrow<string>('DATABASE_URL'), url: config.getOrThrow<string>('DATABASE_URL'),
+28 -11
View File
@@ -2,12 +2,35 @@ import { betterAuth } from 'better-auth';
import { prismaAdapter } from '@better-auth/prisma-adapter'; import { prismaAdapter } from '@better-auth/prisma-adapter';
import prisma from './prisma.js'; import prisma from './prisma.js';
import EmailSender from './email-sender.js'; import EmailSender from './email-sender.js';
import { emailOTP } from 'better-auth/plugins';
export const auth = betterAuth({ export const auth = betterAuth({
appName: process.env.APP_NAME ?? 'app', appName: process.env.APP_NAME ?? 'app',
basePath: '/api/auth', basePath: '/api/auth',
plugins: [ plugins: [
// authTransformResponse() // authTransformResponse()
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
if (type === 'sign-in') {
EmailSender.sendInBackground({
to: email,
subject: `Your OTP code is: ${otp}`,
text: `Your OTP code is: ${otp}`,
});
} else if (type === 'email-verification') {
// Send the OTP for email verification
console.log({ email, otp, type });
} else if (type === 'forget-password') {
EmailSender.sendInBackground({
to: email,
subject: `Your password reset code is: ${otp}`,
text: `Your password reset code is: ${otp}`,
});
} else {
console.log({ email, otp, type });
}
},
}),
], ],
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: 'postgresql', provider: 'postgresql',
@@ -24,28 +47,23 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
autoSignIn: false, autoSignIn: false,
requireEmailVerification: false, requireEmailVerification: true,
onExistingUserSignUp: async ({ user }, request) => { onExistingUserSignUp: async ({ user }, request) => {
console.log('onExistingUserSignUp', { user }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Sign-up attempt with your email', subject: 'Sign-up attempt with your email',
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.', text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
}); });
}, },
sendResetPassword: async ({ user, url, token }, request) => { sendResetPassword: async ({ user, url, token }, request) => {
console.log('sendResetPassword', { user, url, token }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Reset your password', subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`, text: `Click the link to reset your password: ${url}`,
}); });
}, },
onPasswordReset: async ({ user }, request) => { onPasswordReset: async ({ user }, request) => {
// your logic here EmailSender.sendInBackground({
console.log('onPasswordReset', { user });
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Reset your password result', subject: 'Reset your password result',
text: `Password for user ${user.email} has been reset.`, text: `Password for user ${user.email} has been reset.`,
@@ -56,8 +74,7 @@ export const auth = betterAuth({
sendOnSignIn: true, sendOnSignIn: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => { sendVerificationEmail: async ({ user, url }) => {
console.log('emailVerification', { user, url }); EmailSender.sendInBackground({
void EmailSender.getTransporter().sendMail({
to: user.email, to: user.email,
subject: 'Verify your email address', subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`, text: `Click the link to verify your email: ${url}`,
+33 -2
View File
@@ -1,13 +1,44 @@
import { Logger } from '@nestjs/common';
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer'; import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
export default class EmailSender { export default class EmailSender {
static getTransporter() { private static readonly logger = new Logger(EmailSender.name);
return nodemailer.createTransport({ private static transporter?: Transporter;
private static isLocal() {
return process.env.NODE_ENV === 'local';
}
private static getTransporter() {
this.transporter ??= nodemailer.createTransport({
service: 'gmail', service: 'gmail',
auth: { auth: {
user: process.env.GOOGLE_APP_USER, user: process.env.GOOGLE_APP_USER,
pass: process.env.GOOGLE_APP_PASSWORD, pass: process.env.GOOGLE_APP_PASSWORD,
}, },
}); });
return this.transporter;
}
/** Sends an email; throws on failure. In local env only logs the message. */
static async send(options: SendMailOptions): Promise<void> {
if (this.isLocal()) {
this.logger.log(
`[local] email not sent\n to: ${String(options.to)}\n subject: ${options.subject}\n text: ${String(options.text)}`,
);
return;
}
await this.getTransporter().sendMail(options);
}
/** Fire-and-forget variant: never throws, errors are logged. */
static sendInBackground(options: SendMailOptions): void {
this.send(options).catch((error: unknown) => {
this.logger.error(
`Failed to send email "${options.subject}" to ${String(options.to)}`,
error instanceof Error ? error.stack : String(error),
);
});
} }
} }
-5
View File
@@ -16,12 +16,7 @@ async function bootstrap() {
logger: new CustomLogger(), logger: new CustomLogger(),
}); });
// app.useGlobalPipes(new StandardSchemaValidationPipe());
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
// Глобальный body-parser отключён (better-auth сам парсит своё тело),
// поэтому для GraphQL включаем JSON-парсинг точечно на его пути.
app.use('/graphql', json()); app.use('/graphql', json());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalInterceptors(new TransformResponseInterceptor()); app.useGlobalInterceptors(new TransformResponseInterceptor());
@@ -29,6 +29,7 @@ export const buildIssue = () =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -50,6 +51,7 @@ export const mockIssueRepository = (): IIssueRepository => ({
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -19,6 +19,7 @@ const buildIssue = () =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -54,6 +55,7 @@ describe('GetIssueHistoryUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
import { ISSUE_TYPES } from '../../../../issue/domain/value-objects/issue-type.vo.js';
@Injectable()
export class ListIssueTypesUseCase {
execute(): typeof ISSUE_TYPES {
return ISSUE_TYPES;
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IssueTypeController } from './presentation/controllers/issue-type.controller.js';
import { IssueTypeResolver } from './presentation/resolvers/issue-type.resolver.js';
import { ListIssueTypesUseCase } from './application/use-cases/list-issue-types/list-issue-types.use-case.js';
@Module({
controllers: [IssueTypeController],
providers: [ListIssueTypesUseCase, IssueTypeResolver],
})
export class IssueTypeModule {}
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
@Controller('issue-types')
export class IssueTypeController {
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
@Get()
list(): typeof ISSUE_TYPES {
return this.listIssueTypesUseCase.execute();
}
}
@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
/** Не `IssueType` — это имя занято value-объектом типа задачи */
@ObjectType('IssueTypeDefinition')
export class IssueTypeDefinitionType {
@Field()
name: string;
@Field()
alias: string;
@Field()
color: string;
}
@@ -0,0 +1,14 @@
import { Query, Resolver } from '@nestjs/graphql';
import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js';
import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js';
import { IssueTypeDefinitionType } from '../graphql/issue-type-definition.type.js';
@Resolver()
export class IssueTypeResolver {
constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {}
@Query(() => [IssueTypeDefinitionType], { name: 'issueTypes' })
list(): typeof ISSUE_TYPES {
return this.listIssueTypesUseCase.execute();
}
}
@@ -0,0 +1,7 @@
import { IssueWithUsersDto } from './issue-with-users.dto.js';
export class CalendarIssuesDto {
list: IssueWithUsersDto[];
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
truncated: boolean;
}
@@ -7,6 +7,7 @@ export class IssueWithUsersDto {
content: string | null; content: string | null;
status: string; status: string;
priority: number; priority: number;
type: string;
assigneeId: string | null; assigneeId: string | null;
reporterId: string | null; reporterId: string | null;
assignee: UserDto | null; assignee: UserDto | null;
@@ -5,6 +5,7 @@ export class IssueDto {
content: string | null; content: string | null;
status: string; status: string;
priority: number; priority: number;
type: string;
assigneeId: string | null; assigneeId: string | null;
reporterId: string | null; reporterId: string | null;
projectId: string; projectId: string;
@@ -12,6 +12,7 @@ export class IssueApplicationMapper {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
projectId: issue.projectId, projectId: issue.projectId,
@@ -30,6 +31,7 @@ export class IssueApplicationMapper {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null, assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.js';
import type { Issue } from '../../domain/entities/issue.entity.js';
/**
* Уведомляет нового исполнителя задачи (в т.ч. при самоназначении)
* по всем включённым у него каналам.
*/
@Injectable()
export class IssueAssignmentNotifierService {
constructor(private readonly notificationsService: NotificationsService) {}
notify(issue: Issue, domainEvents: unknown[]): void {
const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent);
// Последнее назначение могло быть отменено снятием исполнителя в той же операции
if (!assigned || assigned.assignee !== issue.assignee) return;
this.notificationsService.notify({
userId: assigned.assignee,
subject: `You have been assigned to ${issue.key}`,
content: issue.title,
});
}
}
@@ -24,6 +24,7 @@ const buildIssue = (overrides: Partial<{ assignee: string | null; reporter: stri
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2', assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2',
reporter: overrides.reporter ?? 'user-1', reporter: overrides.reporter ?? 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -8,5 +8,6 @@ export class CreateIssueCommand {
public readonly priority?: number, public readonly priority?: number,
public readonly assignee?: string, public readonly assignee?: string,
public readonly dueDate?: Date, public readonly dueDate?: Date,
public readonly type?: string,
) {} ) {}
} }
@@ -7,12 +7,15 @@ import { CreateIssueCommand } from './create-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
describe('CreateIssueUseCase', () => { describe('CreateIssueUseCase', () => {
let repository: IIssueRepository; let repository: IIssueRepository;
let projectRepository: IProjectRepository; let projectRepository: IProjectRepository;
let issueEventsService: IssueEventsService; let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService; let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let useCase: CreateIssueUseCase; let useCase: CreateIssueUseCase;
beforeEach(() => { beforeEach(() => {
@@ -20,6 +23,7 @@ describe('CreateIssueUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -41,7 +45,14 @@ describe('CreateIssueUseCase', () => {
reporter: null, reporter: null,
})), })),
} as unknown as IssueUserExpanderService; } as unknown as IssueUserExpanderService;
useCase = new CreateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander); notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
useCase = new CreateIssueUseCase(
repository,
projectRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
);
}); });
it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => { it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => {
@@ -53,6 +64,7 @@ describe('CreateIssueUseCase', () => {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assignee: issue.assignee, assignee: issue.assignee,
reporter: issue.reporter, reporter: issue.reporter,
projectId: issue.projectId, projectId: issue.projectId,
@@ -71,6 +83,7 @@ describe('CreateIssueUseCase', () => {
title: 'Fix login bug', title: 'Fix login bug',
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
reporterId: 'user-1', reporterId: 'user-1',
}); });
}); });
@@ -102,6 +115,7 @@ describe('CreateIssueUseCase', () => {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assignee: issue.assignee, assignee: issue.assignee,
reporter: issue.reporter, reporter: issue.reporter,
projectId: issue.projectId, projectId: issue.projectId,
@@ -118,4 +132,58 @@ describe('CreateIssueUseCase', () => {
expect(received).toEqual([dto]); expect(received).toEqual([dto]);
}); });
it('notifies the assignee of a new issue', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-2'),
);
expect(notificationsService.notify).toHaveBeenCalledWith({
userId: 'user-2',
subject: 'You have been assigned to ISSUE-2',
content: 'Improve onboarding',
});
});
it('notifies the reporter who assigns the issue to themselves', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-1'),
);
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
});
it('does not notify when the issue is created unassigned', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(new CreateIssueCommand('ISSUE-3', 'Improve onboarding', 'user-1', 'project-1'));
expect(notificationsService.notify).not.toHaveBeenCalled();
});
it('creates a TASK by default and accepts an explicit type', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
const task = await useCase.execute(new CreateIssueCommand('ISSUE-4', 'Something', 'user-1', 'project-1'));
const bug = await useCase.execute(
new CreateIssueCommand(
'ISSUE-5',
'Crash on start',
'user-1',
'project-1',
undefined,
undefined,
undefined,
undefined,
'BUG',
),
);
expect(task.type).toBe('TASK');
expect(bug.type).toBe('BUG');
});
}); });
@@ -9,6 +9,7 @@ import { CreateIssueCommand } from './create-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js'; import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
@Injectable() @Injectable()
@@ -20,6 +21,7 @@ export class CreateIssueUseCase {
private readonly projectRepository: IProjectRepository, private readonly projectRepository: IProjectRepository,
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService, private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
) {} ) {}
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> { async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
@@ -28,6 +30,7 @@ export class CreateIssueUseCase {
title: command.title, title: command.title,
content: command.content, content: command.content,
priority: command.priority, priority: command.priority,
type: command.type,
assignee: command.assignee, assignee: command.assignee,
reporter: command.reporter, reporter: command.reporter,
projectId: command.projectId, projectId: command.projectId,
@@ -42,6 +45,7 @@ export class CreateIssueUseCase {
} }
const domainEvents = draft.pullDomainEvents(); const domainEvents = draft.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents);
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) { if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
this.issueEventsService.emitIssueCreated(dto); this.issueEventsService.emitIssueCreated(dto);
} }
@@ -14,6 +14,7 @@ const buildIssue = () =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -32,6 +33,7 @@ describe('DeleteIssueUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -15,6 +15,7 @@ const buildIssue = () =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: 'user-2', assignee: 'user-2',
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -33,6 +34,7 @@ describe('GetIssueByCkeyUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -11,6 +11,7 @@ describe('GetIssueStatsUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -15,6 +15,7 @@ const buildIssue = () =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: 'user-2', assignee: 'user-2',
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -33,6 +34,7 @@ describe('GetIssueUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -0,0 +1,10 @@
export class ListCalendarIssuesQuery {
constructor(
public readonly userId: string,
public readonly from: Date,
public readonly to: Date,
public readonly projectId?: string,
public readonly status?: string[],
public readonly assigneeId?: string,
) {}
}
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js';
import { InvalidCalendarRangeException } from '../../../domain/exceptions/invalid-calendar-range.exception.js';
import { ListCalendarIssuesUseCase } from './list-calendar-issues.use-case.js';
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
const FROM = new Date('2026-09-28T00:00:00+03:00');
const TO = new Date('2026-11-09T00:00:00+03:00');
const buildIssue = (id: number) =>
Issue.reconstitute({
id,
key: `ISSUE-${id}`,
title: `Issue ${id}`,
content: null,
status: 'OPEN',
priority: 0,
type: 'TASK',
assignee: null,
reporter: 'user-1',
projectId: 'project-1',
dueDate: new Date('2026-10-15T12:00:00Z'),
createdAt: new Date(),
updatedAt: new Date(),
});
describe('ListCalendarIssuesUseCase', () => {
let repository: IIssueRepository;
let issueUserExpander: IssueUserExpanderService;
let useCase: ListCalendarIssuesUseCase;
beforeEach(() => {
repository = {
findById: vi.fn(),
findByCKey: vi.fn(),
findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
issueUserExpander = {
expandAll: vi.fn(async (issues: Issue[]) => issues.map((issue) => ({ id: issue.id }) as IssueWithUsersDto)),
} as unknown as IssueUserExpanderService;
useCase = new ListCalendarIssuesUseCase(repository, issueUserExpander);
});
it('forwards the range and filters, asking for one issue more than the limit', async () => {
vi.mocked(repository.findByDueDateRange).mockResolvedValue([buildIssue(1), buildIssue(2)]);
const result = await useCase.execute(
new ListCalendarIssuesQuery('user-1', FROM, TO, 'project-1', ['OPEN'], 'user-2'),
);
expect(repository.findByDueDateRange).toHaveBeenCalledWith({
from: FROM,
to: TO,
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
userId: 'user-1',
projectId: 'project-1',
status: ['OPEN'],
assigneeId: 'user-2',
});
expect(result).toEqual({ list: [{ id: 1 }, { id: 2 }], truncated: false });
});
it('cuts the list to the limit and marks it as truncated', async () => {
const issues = Array.from({ length: ListCalendarIssuesUseCase.MAX_ISSUES + 1 }, (_, index) =>
buildIssue(index + 1),
);
vi.mocked(repository.findByDueDateRange).mockResolvedValue(issues);
const result = await useCase.execute(new ListCalendarIssuesQuery('user-1', FROM, TO));
expect(result.truncated).toBe(true);
expect(result.list).toHaveLength(ListCalendarIssuesUseCase.MAX_ISSUES);
});
it('rejects an invalid range without querying the repository', async () => {
await expect(useCase.execute(new ListCalendarIssuesQuery('user-1', TO, FROM))).rejects.toThrow(
InvalidCalendarRangeException,
);
expect(repository.findByDueDateRange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,42 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import { CalendarRange } from '../../../domain/value-objects/calendar-range.vo.js';
import { CalendarIssuesDto } from '../../dto/calendar-issues.dto.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { ListCalendarIssuesQuery } from './list-calendar-issues.query.js';
/**
* Задачи для календаря: все задачи с dueDate в [from, to) без пагинации,
* но не больше MAX_ISSUES — иначе ответ помечается как truncated.
* Задачи без dueDate в календарь не попадают.
*/
@Injectable()
export class ListCalendarIssuesUseCase {
static readonly MAX_ISSUES = 500;
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly issueUserExpander: IssueUserExpanderService,
) {}
async execute(query: ListCalendarIssuesQuery): Promise<CalendarIssuesDto> {
const range = CalendarRange.create({ from: query.from, to: query.to });
// Берём на одну больше лимита, чтобы узнать, есть ли что-то за ним
const issues = await this.issueRepository.findByDueDateRange({
from: range.from,
to: range.to,
limit: ListCalendarIssuesUseCase.MAX_ISSUES + 1,
userId: query.userId,
projectId: query.projectId,
status: query.status,
assigneeId: query.assigneeId,
});
const truncated = issues.length > ListCalendarIssuesUseCase.MAX_ISSUES;
const list = truncated ? issues.slice(0, ListCalendarIssuesUseCase.MAX_ISSUES) : issues;
return { list: await this.issueUserExpander.expandAll(list), truncated };
}
}
@@ -7,5 +7,7 @@ export class ListIssuesQuery {
public readonly status?: string[], public readonly status?: string[],
public readonly text?: string, public readonly text?: string,
public readonly assigneeId?: string, public readonly assigneeId?: string,
public readonly hasDueDate?: boolean,
public readonly type?: string[],
) {} ) {}
} }
@@ -14,6 +14,7 @@ const buildIssue = (id: number) =>
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -32,6 +33,7 @@ describe('ListIssuesUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -61,6 +63,20 @@ describe('ListIssuesUseCase', () => {
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 }); expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
}); });
it('forwards the hasDueDate filter', async () => {
vi.mocked(repository.findAll).mockResolvedValue({
list: [],
meta: { totalCount: 0, page: 1, totalPages: 0 },
});
vi.mocked(issueUserExpander.expandAll).mockResolvedValue([]);
await useCase.execute(new ListIssuesQuery(20, 1, 'user-1', 'project-1', undefined, undefined, undefined, false));
expect(repository.findAll).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'project-1', hasDueDate: false }),
);
});
it('returns an empty array when there are no issues', async () => { it('returns an empty array when there are no issues', async () => {
vi.mocked(repository.findAll).mockResolvedValue({ vi.mocked(repository.findAll).mockResolvedValue({
list: [], list: [],
@@ -22,6 +22,8 @@ export class ListIssuesUseCase {
status: query.status, status: query.status,
text: query.text, text: query.text,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
hasDueDate: query.hasDueDate,
type: query.type,
}); });
return { list: await this.issueUserExpander.expandAll(list), meta }; return { list: await this.issueUserExpander.expandAll(list), meta };
@@ -8,5 +8,6 @@ export class UpdateIssueCommand {
public readonly priority?: number, public readonly priority?: number,
public readonly assignee?: string | null, public readonly assignee?: string | null,
public readonly dueDate?: Date | null, public readonly dueDate?: Date | null,
public readonly type?: string,
) {} ) {}
} }
@@ -8,6 +8,8 @@ import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js'; import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
const buildIssue = () => const buildIssue = () =>
Issue.reconstitute({ Issue.reconstitute({
@@ -17,6 +19,7 @@ const buildIssue = () =>
content: 'Original content', content: 'Original content',
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -30,6 +33,7 @@ describe('UpdateIssueUseCase', () => {
let projectRepository: IProjectRepository; let projectRepository: IProjectRepository;
let issueEventsService: IssueEventsService; let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService; let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let useCase: UpdateIssueUseCase; let useCase: UpdateIssueUseCase;
beforeEach(() => { beforeEach(() => {
@@ -37,6 +41,7 @@ describe('UpdateIssueUseCase', () => {
findById: vi.fn(), findById: vi.fn(),
findByCKey: vi.fn(), findByCKey: vi.fn(),
findAll: vi.fn(), findAll: vi.fn(),
findByDueDateRange: vi.fn(),
countByStatus: vi.fn(), countByStatus: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -58,7 +63,14 @@ describe('UpdateIssueUseCase', () => {
reporter: null, reporter: null,
})), })),
} as unknown as IssueUserExpanderService; } as unknown as IssueUserExpanderService;
useCase = new UpdateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander); notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
useCase = new UpdateIssueUseCase(
repository,
projectRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
);
}); });
it('applies only the fields provided in the command', async () => { it('applies only the fields provided in the command', async () => {
@@ -114,4 +126,44 @@ describe('UpdateIssueUseCase', () => {
expect(received).toEqual([dto]); expect(received).toEqual([dto]);
}); });
it('notifies the new assignee', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-9'));
expect(notificationsService.notify).toHaveBeenCalledWith({
userId: 'user-9',
subject: 'You have been assigned to ISSUE-1',
content: 'Original title',
});
});
it('notifies the user who assigns the issue to themselves', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-1'));
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
});
it('does not notify on unassignment or other field changes', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, null));
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
expect(notificationsService.notify).not.toHaveBeenCalled();
});
it('changes the issue type', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
const dto = await useCase.execute(
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, undefined, undefined, 'BUG'),
);
expect(dto.type).toBe('BUG');
});
}); });
@@ -9,6 +9,7 @@ import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js'; import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js'; import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js'; import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js'; import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
@Injectable() @Injectable()
@@ -20,6 +21,7 @@ export class UpdateIssueUseCase {
private readonly projectRepository: IProjectRepository, private readonly projectRepository: IProjectRepository,
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService, private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
) {} ) {}
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> { async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
@@ -45,6 +47,10 @@ export class UpdateIssueUseCase {
issue.changePriority(command.priority); issue.changePriority(command.priority);
} }
if (command.type !== undefined) {
issue.changeType(command.type);
}
if (command.assignee !== undefined) { if (command.assignee !== undefined) {
if (command.assignee === null || command.assignee === '') { if (command.assignee === null || command.assignee === '') {
issue.unassign(); issue.unassign();
@@ -65,6 +71,7 @@ export class UpdateIssueUseCase {
} }
const domainEvents = issue.pullDomainEvents(); const domainEvents = issue.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents);
if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) { if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) {
this.issueEventsService.emitIssueUpdated(dto); this.issueEventsService.emitIssueUpdated(dto);
} }
@@ -8,6 +8,7 @@ export const ISSUE_CHANGE_FIELDS = [
'content', 'content',
'status', 'status',
'priority', 'priority',
'type',
'assignee', 'assignee',
'dueDate', 'dueDate',
] as const; ] as const;
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Issue } from './issue.entity.js'; import { Issue } from './issue.entity.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
describe('Issue', () => { describe('Issue', () => {
describe('create', () => { describe('create', () => {
@@ -18,6 +19,7 @@ describe('Issue', () => {
expect(issue.content).toBeNull(); expect(issue.content).toBeNull();
expect(issue.status).toBe('OPEN'); expect(issue.status).toBe('OPEN');
expect(issue.priority).toBe(0); expect(issue.priority).toBe(0);
expect(issue.type).toBe('TASK');
expect(issue.assignee).toBeNull(); expect(issue.assignee).toBeNull();
expect(issue.reporter).toBe('user-1'); expect(issue.reporter).toBe('user-1');
}); });
@@ -30,11 +32,13 @@ describe('Issue', () => {
projectId: 'project-1', projectId: 'project-1',
content: 'Details here', content: 'Details here',
priority: 3, priority: 3,
type: 'BUG',
assignee: 'user-2', assignee: 'user-2',
}); });
expect(issue.content).toBe('Details here'); expect(issue.content).toBe('Details here');
expect(issue.priority).toBe(3); expect(issue.priority).toBe(3);
expect(issue.type).toBe('BUG');
expect(issue.assignee).toBe('user-2'); expect(issue.assignee).toBe('user-2');
}); });
@@ -69,6 +73,7 @@ describe('Issue', () => {
content: null, content: null,
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
priority: 2, priority: 2,
type: 'TASK',
assignee: 'user-3', assignee: 'user-3',
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -92,6 +97,7 @@ describe('Issue', () => {
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -138,6 +144,15 @@ describe('Issue', () => {
expect(() => issue.changePriority(10)).toThrow(); expect(() => issue.changePriority(10)).toThrow();
}); });
it('changes the type through the value object rules', () => {
const issue = build();
issue.changeType('FEATURE');
expect(issue.type).toBe('FEATURE');
expect(() => issue.changeType('EPIC')).toThrow();
});
it('assigns and unassigns an issue', () => { it('assigns and unassigns an issue', () => {
const issue = build(); const issue = build();
@@ -147,6 +162,16 @@ describe('Issue', () => {
issue.unassign(); issue.unassign();
expect(issue.assignee).toBeNull(); expect(issue.assignee).toBeNull();
}); });
it('raises IssueAssignedEvent only when the assignee actually changes', () => {
const issue = build();
issue.assignTo('user-9');
issue.assignTo('user-9');
const assigned = issue.pullDomainEvents().filter((event) => event instanceof IssueAssignedEvent);
expect(assigned).toEqual([expect.objectContaining({ issueKey: issue.key, assignee: 'user-9' })]);
});
}); });
describe('change tracking', () => { describe('change tracking', () => {
@@ -158,6 +183,7 @@ describe('Issue', () => {
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assignee: null, assignee: null,
reporter: 'user-1', reporter: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -182,6 +208,7 @@ describe('Issue', () => {
issue.updateContent('Text'); issue.updateContent('Text');
issue.changeStatus('IN_PROGRESS'); issue.changeStatus('IN_PROGRESS');
issue.changePriority(3); issue.changePriority(3);
issue.changeType('BUG');
issue.assignTo('user-2'); issue.assignTo('user-2');
issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z')); issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z'));
@@ -190,6 +217,7 @@ describe('Issue', () => {
{ field: 'content', oldValue: null, newValue: 'Text' }, { field: 'content', oldValue: null, newValue: 'Text' },
{ field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' }, { field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' },
{ field: 'priority', oldValue: '0', newValue: '3' }, { field: 'priority', oldValue: '0', newValue: '3' },
{ field: 'type', oldValue: 'TASK', newValue: 'BUG' },
{ field: 'assignee', oldValue: null, newValue: 'user-2' }, { field: 'assignee', oldValue: null, newValue: 'user-2' },
{ field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' }, { field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' },
]); ]);
@@ -1,8 +1,10 @@
import { IssueTitle } from '../value-objects/issue-title.vo.js'; import { IssueTitle } from '../value-objects/issue-title.vo.js';
import { IssueStatus } from '../value-objects/issue-status.vo.js'; import { IssueStatus } from '../value-objects/issue-status.vo.js';
import { IssuePriority } from '../value-objects/issue-priority.vo.js'; import { IssuePriority } from '../value-objects/issue-priority.vo.js';
import { IssueType } from '../value-objects/issue-type.vo.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueUpdatedEvent } from '../events/issue-updated.event.js'; import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
import type { IssueChangeField, IssueFieldChange } from './issue-change.js'; import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
interface IssueProps { interface IssueProps {
@@ -12,6 +14,7 @@ interface IssueProps {
content: string | null; content: string | null;
status: IssueStatus; status: IssueStatus;
priority: IssuePriority; priority: IssuePriority;
type: IssueType;
assignee: string | null; assignee: string | null;
/** null — автор задачи удалил аккаунт */ /** null — автор задачи удалил аккаунт */
reporter: string | null; reporter: string | null;
@@ -45,6 +48,7 @@ export class Issue {
title: string; title: string;
content?: string | null; content?: string | null;
priority?: number; priority?: number;
type?: string;
assignee?: string | null; assignee?: string | null;
reporter: string; reporter: string;
projectId: string; projectId: string;
@@ -57,6 +61,7 @@ export class Issue {
content: params.content ?? null, content: params.content ?? null,
status: IssueStatus.open(), status: IssueStatus.open(),
priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(), priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(),
type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(),
assignee: params.assignee ?? null, assignee: params.assignee ?? null,
reporter: params.reporter, reporter: params.reporter,
projectId: params.projectId, projectId: params.projectId,
@@ -68,6 +73,9 @@ export class Issue {
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null }); issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status }); issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status });
issue.addDomainEvent(new IssueCreatedEvent(issue.key)); issue.addDomainEvent(new IssueCreatedEvent(issue.key));
if (issue.assignee) {
issue.addDomainEvent(new IssueAssignedEvent(issue.key, issue.assignee));
}
return issue; return issue;
} }
@@ -80,6 +88,7 @@ export class Issue {
content: string | null; content: string | null;
status: string; status: string;
priority: number; priority: number;
type: string;
assignee: string | null; assignee: string | null;
reporter: string | null; reporter: string | null;
projectId: string; projectId: string;
@@ -94,6 +103,7 @@ export class Issue {
content: props.content, content: props.content,
status: IssueStatus.create(props.status), status: IssueStatus.create(props.status),
priority: IssuePriority.create(props.priority), priority: IssuePriority.create(props.priority),
type: IssueType.create(props.type),
assignee: props.assignee, assignee: props.assignee,
reporter: props.reporter, reporter: props.reporter,
projectId: props.projectId, projectId: props.projectId,
@@ -127,6 +137,10 @@ export class Issue {
return this.props.priority.value; return this.props.priority.value;
} }
get type(): string {
return this.props.type.value;
}
get assignee(): string | null { get assignee(): string | null {
return this.props.assignee; return this.props.assignee;
} }
@@ -174,9 +188,18 @@ export class Issue {
this.props.priority = next; this.props.priority = next;
} }
changeType(type: string): void {
const next = IssueType.create(type);
this.recordChange('type', this.type, next.value);
this.props.type = next;
}
assignTo(userId: string): void { assignTo(userId: string): void {
if (this.assignee === userId) return;
this.recordChange('assignee', this.assignee, userId); this.recordChange('assignee', this.assignee, userId);
this.props.assignee = userId; this.props.assignee = userId;
this.addDomainEvent(new IssueAssignedEvent(this.key, userId));
} }
unassign(): void { unassign(): void {
@@ -0,0 +1,7 @@
export class IssueAssignedEvent {
constructor(
public readonly issueKey: string,
public readonly assignee: string,
public readonly occurredAt: Date = new Date(),
) {}
}
@@ -0,0 +1,6 @@
export class InvalidCalendarRangeException extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidCalendarRangeException';
}
}
@@ -16,7 +16,19 @@ export interface IIssueRepository {
text?: string; text?: string;
assigneeId?: string; assigneeId?: string;
projectId?: string; projectId?: string;
hasDueDate?: boolean;
type?: string[];
}): Promise<{ list: Issue[]; meta: PaginationMeta }>; }): Promise<{ list: Issue[]; meta: PaginationMeta }>;
/** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */
findByDueDateRange(params: {
from: Date;
to: Date;
limit: number;
userId: string;
status?: string[];
assigneeId?: string;
projectId?: string;
}): Promise<Issue[]>;
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>; countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */ /** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
create(issue: Issue): Promise<Issue>; create(issue: Issue): Promise<Issue>;
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { CalendarRange } from './calendar-range.vo.js';
import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js';
describe('CalendarRange', () => {
it('accepts a 6-week month grid', () => {
const from = new Date('2026-09-28T00:00:00+03:00');
const to = new Date('2026-11-09T00:00:00+03:00');
const range = CalendarRange.create({ from, to });
expect(range.from).toBe(from);
expect(range.to).toBe(to);
});
it('rejects an invalid date', () => {
expect(() => CalendarRange.create({ from: new Date('nope'), to: new Date() })).toThrow(
InvalidCalendarRangeException,
);
});
it('rejects from equal to or after to', () => {
const date = new Date('2026-10-01T00:00:00Z');
expect(() => CalendarRange.create({ from: date, to: date })).toThrow(InvalidCalendarRangeException);
expect(() => CalendarRange.create({ from: new Date('2026-10-02T00:00:00Z'), to: date })).toThrow(
InvalidCalendarRangeException,
);
});
it(`rejects a range longer than ${CalendarRange.MAX_DAYS} days`, () => {
const from = new Date('2026-10-01T00:00:00Z');
const to = new Date(from.getTime() + (CalendarRange.MAX_DAYS * 24 * 60 * 60 * 1000 + 1));
expect(() => CalendarRange.create({ from, to })).toThrow(InvalidCalendarRangeException);
});
});
@@ -0,0 +1,33 @@
import { InvalidCalendarRangeException } from '../exceptions/invalid-calendar-range.exception.js';
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Диапазон календаря по dueDate — полуоткрытый интервал [from, to).
* Границы — моменты времени: клиент сам считает начало/конец видимой сетки
* в своём часовом поясе, сервер про месяцы и часовые пояса не знает.
*/
export class CalendarRange {
/** Сетка месяца — 6 недель (42 дня), плюс запас на переход на летнее/зимнее время */
static readonly MAX_DAYS = 45;
private constructor(
public readonly from: Date,
public readonly to: Date,
) {}
static create(params: { from: Date; to: Date }): CalendarRange {
const { from, to } = params;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new InvalidCalendarRangeException('Некорректная дата from или to');
}
if (from >= to) {
throw new InvalidCalendarRangeException('Дата from должна быть раньше даты to');
}
if (to.getTime() - from.getTime() > CalendarRange.MAX_DAYS * DAY_MS) {
throw new InvalidCalendarRangeException(`Диапазон больше ${CalendarRange.MAX_DAYS} дней`);
}
return new CalendarRange(from, to);
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { IssueType, ISSUE_TYPES } from './issue-type.vo.js';
describe('IssueType', () => {
it.each(ISSUE_TYPES.map((type) => type.alias))('accepts the valid type "%s"', (alias) => {
expect(IssueType.create(alias).value).toBe(alias);
});
it('throws for an unknown type', () => {
expect(() => IssueType.create('EPIC')).toThrow(/Недопустимый тип задачи/);
});
it('defaults to TASK', () => {
expect(IssueType.default().value).toBe('TASK');
});
it('compares two types by value', () => {
expect(IssueType.create('BUG').equals(IssueType.create('BUG'))).toBe(true);
expect(IssueType.create('BUG').equals(IssueType.create('TASK'))).toBe(false);
});
});
@@ -0,0 +1,33 @@
export const ISSUE_TYPES = [
{ name: 'Task', alias: 'TASK', color: 'primary' },
{ name: 'Bug', alias: 'BUG', color: 'error' },
{ name: 'Feature', alias: 'FEATURE', color: 'success' },
] as const;
export type IssueTypeValue = (typeof ISSUE_TYPES)[number]['alias'];
export const ISSUE_TYPE_ALIASES: readonly IssueTypeValue[] = ISSUE_TYPES.map((type) => type.alias);
export class IssueType {
private constructor(private readonly _value: IssueTypeValue) {}
static create(value: string): IssueType {
if (!ISSUE_TYPE_ALIASES.includes(value as IssueTypeValue)) {
throw new Error(`Недопустимый тип задачи: "${value}". Разрешены: ${ISSUE_TYPE_ALIASES.join(', ')}`);
}
return new IssueType(value as IssueTypeValue);
}
static default(): IssueType {
return new IssueType('TASK');
}
get value(): IssueTypeValue {
return this._value;
}
equals(other: IssueType): boolean {
return this._value === other.value;
}
}
@@ -10,6 +10,7 @@ describe('IssuePrismaMapper', () => {
content: 'Details', content: 'Details',
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
priority: 2, priority: 2,
type: 'BUG',
assigneeId: 'user-2', assigneeId: 'user-2',
reporterId: 'user-1', reporterId: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -26,6 +27,7 @@ describe('IssuePrismaMapper', () => {
expect(issue.title).toBe('Fix login bug'); expect(issue.title).toBe('Fix login bug');
expect(issue.status).toBe('IN_PROGRESS'); expect(issue.status).toBe('IN_PROGRESS');
expect(issue.priority).toBe(2); expect(issue.priority).toBe(2);
expect(issue.type).toBe('BUG');
expect(issue.assignee).toBe('user-2'); expect(issue.assignee).toBe('user-2');
}); });
@@ -45,6 +47,7 @@ describe('IssuePrismaMapper', () => {
content: null, content: null,
status: 'OPEN', status: 'OPEN',
priority: 0, priority: 0,
type: 'TASK',
assigneeId: null, assigneeId: null,
reporterId: 'user-1', reporterId: 'user-1',
projectId: 'project-1', projectId: 'project-1',
@@ -61,6 +64,7 @@ describe('IssuePrismaMapper', () => {
title: 'Updated title', title: 'Updated title',
status: 'IN_PROGRESS', status: 'IN_PROGRESS',
priority: 2, priority: 2,
type: 'BUG',
assigneeId: 'user-2', assigneeId: 'user-2',
}); });
expect(input).not.toHaveProperty('id'); expect(input).not.toHaveProperty('id');
@@ -11,6 +11,7 @@ export class IssuePrismaMapper {
content: record.content, content: record.content,
status: record.status, status: record.status,
priority: record.priority, priority: record.priority,
type: record.type,
assignee: record.assigneeId, assignee: record.assigneeId,
reporter: record.reporterId, reporter: record.reporterId,
projectId: record.projectId, projectId: record.projectId,
@@ -27,6 +28,7 @@ export class IssuePrismaMapper {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
reporterId: issue.reporter, reporterId: issue.reporter,
projectId: issue.projectId, projectId: issue.projectId,
@@ -42,6 +44,7 @@ export class IssuePrismaMapper {
content: issue.content, content: issue.content,
status: issue.status, status: issue.status,
priority: issue.priority, priority: issue.priority,
type: issue.type,
assigneeId: issue.assignee, assigneeId: issue.assignee,
dueDate: issue.dueDate, dueDate: issue.dueDate,
updatedAt: issue.updatedAt, updatedAt: issue.updatedAt,
@@ -18,6 +18,21 @@ function ownedByUser(userId: string) {
return [{ assigneeId: userId }, { reporterId: userId }]; return [{ assigneeId: userId }, { reporterId: userId }];
} }
/** Видимость и фильтры, общие для списка и календаря */
function commonFilters(params: {
userId: string;
status?: string[];
assigneeId?: string;
projectId?: string;
}): Prisma.IssueWhereInput[] {
return [
{ OR: ownedByUser(params.userId) },
...(params.status?.length ? [{ status: { in: params.status } }] : []),
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []),
...(params.projectId ? [{ projectId: params.projectId }] : []),
];
}
/** Строки истории одного сохранения задачи — с общим changeId */ /** Строки истории одного сохранения задачи — с общим changeId */
function toChangeRows( function toChangeRows(
issueId: number, issueId: number,
@@ -57,6 +72,8 @@ export class IssuePrismaRepository implements IIssueRepository {
text?: string; text?: string;
assigneeId?: string; assigneeId?: string;
projectId?: string; projectId?: string;
hasDueDate?: boolean;
type?: string[];
}): Promise<{ list: Issue[]; meta: PaginationMeta }> { }): Promise<{ list: Issue[]; meta: PaginationMeta }> {
const page = Math.max(1, params?.page); const page = Math.max(1, params?.page);
const limit = Math.max(1, params?.limit); const limit = Math.max(1, params?.limit);
@@ -64,10 +81,9 @@ export class IssuePrismaRepository implements IIssueRepository {
const queryArgs: IssueFindManyArgs = { const queryArgs: IssueFindManyArgs = {
where: { where: {
AND: [ AND: [
{ OR: ownedByUser(params.userId) }, ...commonFilters(params),
...(params.status?.length ? [{ status: { in: params.status } }] : []), ...(params.type?.length ? [{ type: { in: params.type } }] : []),
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []), ...(params.hasDueDate !== undefined ? [{ dueDate: params.hasDueDate ? { not: null } : null }] : []),
...(params.projectId ? [{ projectId: params.projectId }] : []),
...(params.text ...(params.text
? [ ? [
{ {
@@ -98,6 +114,26 @@ export class IssuePrismaRepository implements IIssueRepository {
}; };
} }
async findByDueDateRange(params: {
from: Date;
to: Date;
limit: number;
userId: string;
status?: string[];
assigneeId?: string;
projectId?: string;
}): Promise<Issue[]> {
const records = await this.prisma.issue.findMany({
where: {
AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }],
},
take: Math.max(1, params.limit),
orderBy: [{ dueDate: 'asc' }, { id: 'asc' }],
});
return records.map(IssuePrismaMapper.toDomain);
}
async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> { async countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>> {
const groups = await this.prisma.issue.groupBy({ const groups = await this.prisma.issue.groupBy({
by: ['status'], by: ['status'],
+6 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { ProjectModule } from '../project/project.module.js'; import { ProjectModule } from '../project/project.module.js';
import { UserModule } from '../user/user.module.js'; import { UserModule } from '../user/user.module.js';
import { NotificationModule } from '../notification/notification.module.js';
import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js'; import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js'; import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
import { IssueController } from './presentation/controllers/issue.controller.js'; import { IssueController } from './presentation/controllers/issue.controller.js';
@@ -14,10 +15,12 @@ import { IssueResolver } from './presentation/resolvers/issue.resolver.js';
import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js'; import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
import { IssueEventsService } from './application/services/issue-events.service.js'; import { IssueEventsService } from './application/services/issue-events.service.js';
import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js'; import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from './application/services/issue-assignment-notifier.service.js';
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
@Module({ @Module({
imports: [PrismaModule, ProjectModule, UserModule], imports: [PrismaModule, ProjectModule, UserModule, NotificationModule],
controllers: [IssueController], controllers: [IssueController],
providers: [ providers: [
CreateIssueUseCase, CreateIssueUseCase,
@@ -27,9 +30,11 @@ import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/ge
UpdateIssueUseCase, UpdateIssueUseCase,
DeleteIssueUseCase, DeleteIssueUseCase,
GetIssueStatsUseCase, GetIssueStatsUseCase,
ListCalendarIssuesUseCase,
IssueResolver, IssueResolver,
IssueEventsService, IssueEventsService,
IssueUserExpanderService, IssueUserExpanderService,
IssueAssignmentNotifierService,
{ {
// Domain видит только интерфейс IIssueRepository, // Domain видит только интерфейс IIssueRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь // конкретную реализацию (Prisma) подставляет DI-контейнер здесь
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -37,6 +38,10 @@ import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-
import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js'; import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js';
import { IssueEventsService } from '../../application/services/issue-events.service.js'; import { IssueEventsService } from '../../application/services/issue-events.service.js';
import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js';
import { CalendarIssuesQueryDto } from '../dto/calendar-issues-query.dto.js';
import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js';
@Controller('issues') @Controller('issues')
export class IssueController { export class IssueController {
@@ -48,6 +53,7 @@ export class IssueController {
private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly updateIssueUseCase: UpdateIssueUseCase,
private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase,
private readonly getIssueStatsUseCase: GetIssueStatsUseCase, private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
private readonly issueEventsService: IssueEventsService, private readonly issueEventsService: IssueEventsService,
) {} ) {}
@@ -64,6 +70,7 @@ export class IssueController {
dto.priority, dto.priority,
dto.assignee, dto.assignee,
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined, dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
dto.type,
), ),
), ),
); );
@@ -80,6 +87,25 @@ export class IssueController {
query.status, query.status,
query.text, query.text,
query.assigneeId, query.assigneeId,
query.hasDueDate,
query.type,
),
);
}
/** Задачи с dueDate в [from, to) — для календаря, без пагинации */
@Get('calendar')
calendar(@Query() query: CalendarIssuesQueryDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.listCalendarIssuesUseCase.execute(
new ListCalendarIssuesQuery(
session.user.id,
new Date(query.from),
new Date(query.to),
query.projectId,
query.status,
query.assigneeId,
),
), ),
); );
} }
@@ -143,6 +169,7 @@ export class IssueController {
dto.priority, dto.priority,
dto.assignee, dto.assignee,
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate), dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
dto.type,
), ),
), ),
); );
@@ -162,6 +189,9 @@ export class IssueController {
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof InvalidCalendarRangeException) {
throw new BadRequestException(error.message);
}
throw error; throw error;
} }
} }
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { CalendarIssuesQueryDto } from './calendar-issues-query.dto.js';
const parse = (query: Record<string, unknown>) => {
const dto = plainToInstance(CalendarIssuesQueryDto, query);
return { dto, errors: validateSync(dto) };
};
describe('CalendarIssuesQueryDto', () => {
it('accepts moments with an offset or Z', () => {
const { errors } = parse({ from: '2026-09-28T00:00:00+03:00', to: '2026-11-09T00:00:00.000Z' });
expect(errors).toHaveLength(0);
});
it('requires from and to', () => {
const { errors } = parse({});
expect(errors.map((el) => el.property)).toEqual(['from', 'to']);
});
it('rejects a moment without an offset', () => {
const { errors } = parse({ from: '2026-09-28T00:00:00', to: '2026-11-09T00:00:00Z' });
expect(errors.map((el) => el.property)).toEqual(['from']);
});
it('rejects a plain date', () => {
const { errors } = parse({ from: '2026-09-28', to: '2026-11-09T00:00:00Z' });
expect(errors.map((el) => el.property)).toEqual(['from']);
});
it('parses the status list', () => {
const { dto, errors } = parse({
from: '2026-09-28T00:00:00Z',
to: '2026-11-09T00:00:00Z',
status: 'OPEN,IN_PROGRESS',
});
expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']);
expect(errors).toHaveLength(0);
});
});
@@ -0,0 +1,28 @@
import { IsISO8601, Matches } from 'class-validator';
import { StringDecorator } from '../../../../common/decorators/validators.js';
import { IssueStatusListDecorator } from './issue-alias-list.decorator.js';
/** Без смещения дата трактовалась бы в часовом поясе сервера */
const WITH_OFFSET = /(Z|[+-]\d{2}:\d{2})$/;
const MOMENT_MESSAGE = '$property: ожидается ISO 8601 со смещением, например 2026-10-01T00:00:00+03:00';
export class CalendarIssuesQueryDto {
/** Начало видимой сетки календаря, включительно */
@IsISO8601({ strict: true }, { message: MOMENT_MESSAGE })
@Matches(WITH_OFFSET, { message: MOMENT_MESSAGE })
from: string;
/** Конец видимой сетки календаря, не включительно */
@IsISO8601({ strict: true }, { message: MOMENT_MESSAGE })
@Matches(WITH_OFFSET, { message: MOMENT_MESSAGE })
to: string;
@StringDecorator({ isOptional: true })
projectId?: string;
@StringDecorator({ isOptional: true })
assigneeId?: string;
@IssueStatusListDecorator()
status?: string[];
}
@@ -1,4 +1,5 @@
import { IsDateString, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
export class CreateIssueRequestDto { export class CreateIssueRequestDto {
@IsString() @IsString()
@@ -24,6 +25,10 @@ export class CreateIssueRequestDto {
@Max(4) @Max(4)
priority?: number; priority?: number;
@IsOptional()
@IsIn(ISSUE_TYPE_ALIASES)
type?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
assignee?: string; assignee?: string;
@@ -49,3 +49,28 @@ describe('FilterIssueDto.status', () => {
expect(errors.map((el) => el.property)).toEqual(['status']); expect(errors.map((el) => el.property)).toEqual(['status']);
}); });
}); });
describe('FilterIssueDto.hasDueDate', () => {
it('leaves hasDueDate undefined when absent', () => {
const { dto, errors } = parse({});
expect(dto.hasDueDate).toBeUndefined();
expect(errors).toHaveLength(0);
});
it.each([
['true', true],
['false', false],
])('parses "%s"', (value, expected) => {
const { dto, errors } = parse({ hasDueDate: value });
expect(dto.hasDueDate).toBe(expected);
expect(errors).toHaveLength(0);
});
it('rejects a non-boolean value', () => {
const { errors } = parse({ hasDueDate: 'yes' });
expect(errors.map((el) => el.property)).toEqual(['hasDueDate']);
});
});
@@ -1,7 +1,5 @@
import { Transform } from 'class-transformer'; import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator'; import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js';
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
export class FilterIssueDto { export class FilterIssueDto {
@IntDecorator({ isOptional: true }) @IntDecorator({ isOptional: true })
@@ -16,22 +14,16 @@ export class FilterIssueDto {
@StringDecorator({ isOptional: true }) @StringDecorator({ isOptional: true })
assigneeId?: string; assigneeId?: string;
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */ @IssueStatusListDecorator()
@IsOptional()
@Transform(({ value }) =>
value === undefined || value === null
? undefined
: ([] as unknown[])
.concat(value)
.flatMap((el) => String(el).split(','))
.map((el) => el.trim())
.filter(Boolean),
)
@IsArray()
@ArrayNotEmpty()
@IsIn(ISSUE_STATUSES.map((status) => status.alias), { each: true })
status?: string[]; status?: string[];
@IssueTypeListDecorator()
type?: string[];
@StringDecorator({ isOptional: true }) @StringDecorator({ isOptional: true })
projectId?: string; projectId?: string;
/** false — только задачи без срока (панель «без срока» рядом с календарём), true — только со сроком */
@BooleanDecorator({ isOptional: true })
hasDueDate?: boolean;
} }
@@ -0,0 +1,33 @@
import { applyDecorators } from '@nestjs/common';
import { Transform } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
function AliasListDecorator(aliases: readonly string[]) {
return applyDecorators(
IsOptional(),
Transform(({ value }) =>
value === undefined || value === null
? undefined
: ([] as unknown[])
.concat(value)
.flatMap((el) => String(el).split(','))
.map((el) => el.trim())
.filter(Boolean),
),
IsArray(),
ArrayNotEmpty(),
IsIn(aliases, { each: true }),
);
}
export function IssueStatusListDecorator() {
return AliasListDecorator(ISSUE_STATUSES.map((status) => status.alias));
}
/** `?type=BUG,FEATURE` или `?type=BUG&type=FEATURE` */
export function IssueTypeListDecorator() {
return AliasListDecorator(ISSUE_TYPE_ALIASES);
}
@@ -1,5 +1,6 @@
import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
export class UpdateIssueRequestDto { export class UpdateIssueRequestDto {
@IsOptional() @IsOptional()
@@ -21,6 +22,10 @@ export class UpdateIssueRequestDto {
@Max(4) @Max(4)
priority?: number; priority?: number;
@IsOptional()
@IsIn(ISSUE_TYPE_ALIASES)
type?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
assignee?: string | null; assignee?: string | null;
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IssueDetailType } from './issue-detail.type.js';
@ObjectType('CalendarIssues')
export class CalendarIssuesType {
@Field(() => [IssueDetailType])
list: IssueDetailType[];
/** В диапазоне задач больше, чем вернули — клиенту стоит сузить фильтр */
@Field()
truncated: boolean;
}
@@ -1,5 +1,6 @@
import { Field, InputType, Int } from '@nestjs/graphql'; import { Field, InputType, Int } from '@nestjs/graphql';
import { IsDate, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { IsDate, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
@InputType() @InputType()
export class CreateIssueInput { export class CreateIssueInput {
@@ -31,6 +32,11 @@ export class CreateIssueInput {
@Max(4) @Max(4)
priority?: number; priority?: number;
@Field({ nullable: true })
@IsOptional()
@IsIn(ISSUE_TYPE_ALIASES)
type?: string;
@Field({ nullable: true }) @Field({ nullable: true })
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -21,6 +21,9 @@ export class IssueDetailType {
@Field(() => Int) @Field(() => Int)
priority: number; priority: number;
@Field()
type: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
assigneeId: string | null; assigneeId: string | null;
@@ -20,6 +20,9 @@ export class IssueType {
@Field(() => Int) @Field(() => Int)
priority: number; priority: number;
@Field()
type: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
assigneeId: string | null; assigneeId: string | null;
@@ -1,6 +1,7 @@
import { Field, InputType, Int } from '@nestjs/graphql'; import { Field, InputType, Int } from '@nestjs/graphql';
import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js'; import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
@InputType() @InputType()
export class UpdateIssueInput { export class UpdateIssueInput {
@@ -27,6 +28,11 @@ export class UpdateIssueInput {
@Max(4) @Max(4)
priority?: number; priority?: number;
@Field({ nullable: true })
@IsOptional()
@IsIn(ISSUE_TYPE_ALIASES)
type?: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js'; import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
@@ -21,6 +21,11 @@ import { IssueDetailType } from '../graphql/issue-detail.type.js';
import { IssuePageType } from '../graphql/issue-page.type.js'; import { IssuePageType } from '../graphql/issue-page.type.js';
import { CreateIssueInput } from '../graphql/create-issue.input.js'; import { CreateIssueInput } from '../graphql/create-issue.input.js';
import { UpdateIssueInput } from '../graphql/update-issue.input.js'; import { UpdateIssueInput } from '../graphql/update-issue.input.js';
import { CalendarIssuesType } from '../graphql/calendar-issues.type.js';
import { ListCalendarIssuesUseCase } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
import { ListCalendarIssuesQuery } from '../../application/use-cases/list-calendar-issues/list-calendar-issues.query.js';
import { CalendarIssuesDto } from '../../application/dto/calendar-issues.dto.js';
import { InvalidCalendarRangeException } from '../../domain/exceptions/invalid-calendar-range.exception.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
@Resolver(() => IssueType) @Resolver(() => IssueType)
@@ -32,6 +37,7 @@ export class IssueResolver {
private readonly listIssuesUseCase: ListIssuesUseCase, private readonly listIssuesUseCase: ListIssuesUseCase,
private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly updateIssueUseCase: UpdateIssueUseCase,
private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase,
private readonly listCalendarIssuesUseCase: ListCalendarIssuesUseCase,
) {} ) {}
@Query(() => IssueDetailType, { name: 'issue' }) @Query(() => IssueDetailType, { name: 'issue' })
@@ -50,8 +56,39 @@ export class IssueResolver {
@Args('page', { type: () => Int, defaultValue: 0 }) page: number, @Args('page', { type: () => Int, defaultValue: 0 }) page: number,
@Session() session: UserSession, @Session() session: UserSession,
@Args('projectId', { type: () => String, nullable: true }) projectId?: string, @Args('projectId', { type: () => String, nullable: true }) projectId?: string,
@Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean,
@Args('type', { type: () => [String], nullable: true }) type?: string[],
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> { ): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page, session.user.id, projectId)); return this.listIssuesUseCase.execute(
new ListIssuesQuery(
limit,
page,
session.user.id,
projectId,
undefined,
undefined,
undefined,
hasDueDate ?? undefined,
type ?? undefined,
),
);
}
/** Задачи с dueDate в [from, to) — для календаря, без пагинации */
@Query(() => CalendarIssuesType, { name: 'calendarIssues' })
calendar(
@Args('from', { type: () => Date }) from: Date,
@Args('to', { type: () => Date }) to: Date,
@Session() session: UserSession,
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
@Args('status', { type: () => [String], nullable: true }) status?: string[],
@Args('assigneeId', { type: () => String, nullable: true }) assigneeId?: string,
): Promise<CalendarIssuesDto> {
return this.handleErrors(() =>
this.listCalendarIssuesUseCase.execute(
new ListCalendarIssuesQuery(session.user.id, from, to, projectId, status, assigneeId),
),
);
} }
@Mutation(() => IssueType, { name: 'createIssue' }) @Mutation(() => IssueType, { name: 'createIssue' })
@@ -67,6 +104,7 @@ export class IssueResolver {
input.priority, input.priority,
input.assignee, input.assignee,
input.dueDate, input.dueDate,
input.type,
), ),
), ),
); );
@@ -89,6 +127,7 @@ export class IssueResolver {
input.priority, input.priority,
input.assignee, input.assignee,
input.dueDate, input.dueDate,
input.type,
), ),
), ),
); );
@@ -108,6 +147,9 @@ export class IssueResolver {
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof InvalidCalendarRangeException) {
throw new BadRequestException(error.message);
}
throw error; throw error;
} }
} }
+17 -5
View File
@@ -73,10 +73,22 @@ in-process pub/sub уже используется в issue-модуле
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user` - `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
(общая БД, синхронный поход не нужен) и шлёт через существующий (общая БД, синхронный поход не нужен) и шлёт через существующий
`EmailSender`/nodemailer (`src/lib/email-sender.ts`). `EmailSender`/nodemailer (`src/lib/email-sender.ts`).
- `PushChannelSender` — читает токены устройств из `notification_device_token`, - `PushChannelSender` — читает токены устройств (Firebase Installation ID)
но сама отправка — заглушка (лог вместо реального вызова). Провайдер из `notification_device_token` и шлёт через Firebase Cloud Messaging
(FCM/APNs) ещё не подключён; когда появится, меняется только тело (`firebase-admin`, `sendEachForMulticast`, `subject` → title, `content` → body).
`send()`, остальной пайплайн — нет. Токены, которые FCM вернул как незарегистрированные, удаляются. Отправка
`FAILED`, если ни одно устройство не приняло сообщение.
### Конфигурация Firebase
`infrastructure/firebase/firebase-messaging.provider.ts`, источники по приоритету:
- `FIREBASE_SERVICE_ACCOUNT` — содержимое JSON-ключа сервисного аккаунта
(как есть или в base64). Основной вариант для деплоя.
- `GOOGLE_APPLICATION_CREDENTIALS` — путь к JSON-файлу ключа (удобно локально),
`FIREBASE_PROJECT_ID` — опционально.
Без них приложение стартует, но push-отправки получают статус `FAILED`.
## HTTP API ## HTTP API
@@ -90,6 +102,7 @@ in-process pub/sub уже используется в issue-модуле
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) | | `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` | | `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` | | `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
| `POST` | `/test-push` | `{ title?, body? }` | Тестовый push текущему пользователю на все его устройства → `202`. Результат доставки — в `notification_message` |
## Структура ## Структура
@@ -113,7 +126,6 @@ notification.module.ts — сборка DI
## Чего пока нет (сознательно, вне текущего объёма) ## Чего пока нет (сознательно, вне текущего объёма)
- Реального push-провайдера (FCM/APNs) — только заглушка с логированием.
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть. - Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
- SMS-канала — при необходимости добавляется как ещё одно значение в - SMS-канала — при необходимости добавляется как ещё одно значение в
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде. `NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
@@ -7,5 +7,7 @@ export interface IDeviceTokenRepository {
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */ /** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
upsert(userId: string, token: string, platform: string): Promise<void>; upsert(userId: string, token: string, platform: string): Promise<void>;
remove(userId: string, token: string): Promise<void>; remove(userId: string, token: string): Promise<void>;
/** Удаляет сразу несколько токенов пользователя, например протухшие после ответа FCM */
removeMany(userId: string, tokens: string[]): Promise<void>;
findByUserId(userId: string): Promise<NotificationDeviceToken[]>; findByUserId(userId: string): Promise<NotificationDeviceToken[]>;
} }
@@ -0,0 +1,49 @@
import { Logger, type Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { applicationDefault, cert, getApps, initializeApp, type App, type ServiceAccount } from 'firebase-admin/app';
import { getMessaging, type Messaging } from 'firebase-admin/messaging';
/** DI-токен для клиента FCM; значение null — Firebase не сконфигурирован */
export const FIREBASE_MESSAGING = Symbol('FIREBASE_MESSAGING');
const FIREBASE_APP_NAME = 'notifications';
/**
* Источники credentials по приоритету:
* 1. FIREBASE_SERVICE_ACCOUNT — содержимое JSON-ключа сервисного аккаунта
* (как есть или в base64), удобно для деплоя через переменные окружения/секреты.
* 2. GOOGLE_APPLICATION_CREDENTIALS — путь к JSON-файлу (стандартный механизм Google, удобно локально).
* Если нет ни того, ни другого, провайдер отдаёт null и push-канал помечает отправки как FAILED.
*/
export const firebaseMessagingProvider: Provider = {
provide: FIREBASE_MESSAGING,
inject: [ConfigService],
useFactory: (config: ConfigService): Messaging | null => {
const app = getApps().find((candidate) => candidate.name === FIREBASE_APP_NAME) ?? createApp(config);
return app ? getMessaging(app) : null;
},
};
function createApp(config: ConfigService): App | null {
const serviceAccountRaw = config.get<string>('FIREBASE_SERVICE_ACCOUNT');
if (serviceAccountRaw) {
return initializeApp({ credential: cert(parseServiceAccount(serviceAccountRaw)) }, FIREBASE_APP_NAME);
}
if (config.get<string>('GOOGLE_APPLICATION_CREDENTIALS')) {
return initializeApp(
{ credential: applicationDefault(), projectId: config.get<string>('FIREBASE_PROJECT_ID') },
FIREBASE_APP_NAME,
);
}
new Logger('FirebaseMessaging').warn(
'Neither FIREBASE_SERVICE_ACCOUNT nor GOOGLE_APPLICATION_CREDENTIALS is set, push delivery is disabled',
);
return null;
}
function parseServiceAccount(raw: string): ServiceAccount {
const json = raw.trim().startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf-8');
return JSON.parse(json) as ServiceAccount;
}
@@ -19,6 +19,10 @@ export class DeviceTokenPrismaRepository implements IDeviceTokenRepository {
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } }); await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
} }
async removeMany(userId: string, tokens: string[]): Promise<void> {
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token: { in: tokens } } });
}
findByUserId(userId: string): Promise<NotificationDeviceToken[]> { findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
return this.prisma.notificationDeviceToken.findMany({ where: { userId } }); return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
} }
@@ -16,7 +16,7 @@ export class EmailChannelSender implements NotificationChannelSender {
select: { email: true }, select: { email: true },
}); });
await EmailSender.getTransporter().sendMail({ await EmailSender.send({
to: user.email, to: user.email,
subject: params.subject ?? 'Уведомление', subject: params.subject ?? 'Уведомление',
text: params.content, text: params.content,
@@ -1,31 +1,79 @@
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { Messaging } from 'firebase-admin/messaging';
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js'; import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js'; import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
import { import {
DEVICE_TOKEN_REPOSITORY, DEVICE_TOKEN_REPOSITORY,
type IDeviceTokenRepository, type IDeviceTokenRepository,
} from '../../domain/repositories/device-token.repository.interface.js'; } from '../../domain/repositories/device-token.repository.interface.js';
import { FIREBASE_MESSAGING } from '../firebase/firebase-messaging.provider.js';
/** Лимит FCM на количество адресатов в одном sendEachForMulticast */
const FCM_MULTICAST_LIMIT = 500;
/** Коды ошибок FCM, после которых токен устройства больше не валиден и его можно удалить */
const STALE_TOKEN_ERROR_CODES = new Set([
'messaging/installation-id-not-registered',
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
]);
/** /**
* Провайдер push-доставки (FCM/APNs) пока не подключён — отправка логируется. * Отправка push через Firebase Cloud Messaging. В notification_device_token.token
* Токены уже читаются из БД, так что при подключении провайдера * хранится Firebase Installation ID (FID) устройства.
* меняется только тело send(), остальной пайплайн трогать не нужно. * Отправка считается успешной, если сообщение принято FCM хотя бы для одного устройства.
*/ */
@Injectable() @Injectable()
export class PushChannelSender implements NotificationChannelSender { export class PushChannelSender implements NotificationChannelSender {
private readonly logger = new Logger(PushChannelSender.name); private readonly logger = new Logger(PushChannelSender.name);
readonly channel: NotificationChannelValue = 'PUSH'; readonly channel: NotificationChannelValue = 'PUSH';
constructor(@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository) {} constructor(
@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository,
@Inject(FIREBASE_MESSAGING) private readonly messaging: Messaging | null,
) {}
async send(params: { userId: string; content: string }): Promise<void> { async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
const tokens = await this.deviceTokens.findByUserId(params.userId); if (!this.messaging) {
throw new Error('Push provider is not configured');
}
const tokens = (await this.deviceTokens.findByUserId(params.userId)).map((deviceToken) => deviceToken.token);
if (tokens.length === 0) { if (tokens.length === 0) {
throw new Error(`No device tokens registered for user ${params.userId}`); throw new Error(`No device tokens registered for user ${params.userId}`);
} }
this.logger.warn( let successCount = 0;
`Push provider is not configured yet, skipping actual delivery to ${tokens.length} device(s): ${params.content}`, const staleTokens: string[] = [];
const errors = new Set<string>();
for (let offset = 0; offset < tokens.length; offset += FCM_MULTICAST_LIMIT) {
const fids = tokens.slice(offset, offset + FCM_MULTICAST_LIMIT);
const response = await this.messaging.sendEachForMulticast({
fids,
notification: { title: params.subject, body: params.content },
});
successCount += response.successCount;
response.responses.forEach((result, index) => {
if (result.success || !result.error) return;
if (STALE_TOKEN_ERROR_CODES.has(result.error.code)) {
staleTokens.push(fids[index]);
} else {
errors.add(`${result.error.code}: ${result.error.message}`);
}
});
}
if (staleTokens.length > 0) {
this.logger.log(`Removing ${staleTokens.length} stale device token(s) of user ${params.userId}`);
await this.deviceTokens.removeMany(params.userId, staleTokens);
}
if (successCount === 0) {
throw new Error(
`Push was not delivered to any of ${tokens.length} device(s): ${[...errors].join('; ') || 'all tokens are stale'}`,
); );
} }
}
} }
@@ -9,6 +9,7 @@ import { NotificationPreferencePrismaRepository } from './infrastructure/persist
import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js'; import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js';
import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js'; import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js';
import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js'; import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
import { firebaseMessagingProvider } from './infrastructure/firebase/firebase-messaging.provider.js';
import { NotificationsService } from './application/services/notifications.service.js'; import { NotificationsService } from './application/services/notifications.service.js';
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js'; import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
import { NotificationController } from './presentation/controllers/notification.controller.js'; import { NotificationController } from './presentation/controllers/notification.controller.js';
@@ -30,6 +31,7 @@ import { NotificationController } from './presentation/controllers/notification.
provide: NOTIFICATION_MESSAGE_REPOSITORY, provide: NOTIFICATION_MESSAGE_REPOSITORY,
useClass: NotificationMessagePrismaRepository, useClass: NotificationMessagePrismaRepository,
}, },
firebaseMessagingProvider,
EmailChannelSender, EmailChannelSender,
PushChannelSender, PushChannelSender,
{ {
@@ -15,6 +15,7 @@ import { NotificationsService } from '../../application/services/notifications.s
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js'; import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js'; import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js';
import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js'; import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js';
import { SendTestPushDto } from '../dto/send-test-push.dto.js';
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js'; import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
@Controller('notifications') @Controller('notifications')
@@ -48,6 +49,21 @@ export class NotificationController {
return this.notifications.setPreference(session.user.id, channel, dto.enabled); return this.notifications.setPreference(session.user.id, channel, dto.enabled);
} }
/**
* Тестовый push текущему пользователю на все его зарегистрированные устройства.
* Идёт через обычный пайплайн (fire-and-forget), результат — в notification_message.
*/
@Post('test-push')
@HttpCode(HttpStatus.ACCEPTED)
sendTestPush(@Body() dto: SendTestPushDto, @Session() session: UserSession): void {
this.notifications.notify({
userId: session.user.id,
subject: dto.title ?? 'Тестовое уведомление',
content: dto.body ?? 'Push-уведомления работают',
channels: ['PUSH'],
});
}
/** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */ /** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */
private parseChannel(value: string) { private parseChannel(value: string) {
try { try {
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SendTestPushDto {
@IsOptional()
@IsString()
@IsNotEmpty()
title?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
body?: string;
}
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js';
import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js';
import { UserAvatarFileOwnerService } from './user-avatar-file-owner.service.js';
import { mockUserRepository } from '../use-cases/test-helpers.js';
describe('UserAvatarFileOwnerService', () => {
let userRepository: IUserRepository;
let registry: FileOwnerRegistry;
let owner: UserAvatarFileOwnerService;
beforeEach(() => {
userRepository = mockUserRepository();
registry = new FileOwnerRegistry();
owner = new UserAvatarFileOwnerService(userRepository, registry);
owner.onModuleInit();
});
it('registers itself, so GC keeps avatars', async () => {
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['a']);
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
});
it('makes an avatar visible to any user', async () => {
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue(['file-1']);
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
expect(userRepository.findAvatarFileIds).toHaveBeenCalledWith(['file-1']);
});
it('hides a file that is nobody’s avatar', async () => {
vi.mocked(userRepository.findAvatarFileIds).mockResolvedValue([]);
expect(await owner.isVisibleThrough('file-1')).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
import { USER_REPOSITORY, type IUserRepository } from '../../domain/repositories/user.repository.interface.js';
import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js';
/**
* Регистрирует аватары как владельца файлов: без этого GC file-модуля
* удалял бы их как сирот, а другие пользователи не видели бы чужие аватары.
*/
@Injectable()
export class UserAvatarFileOwnerService implements FileOwner, OnModuleInit {
readonly name = 'user-avatar';
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
private readonly fileOwners: FileOwnerRegistry,
) {}
onModuleInit(): void {
this.fileOwners.register(this);
}
findReferenced(fileIds: string[]): Promise<string[]> {
return this.userRepository.findAvatarFileIds(fileIds);
}
/** Аватары показываются рядом с задачами и комментариями, поэтому видны всем пользователям */
async isVisibleThrough(fileId: string): Promise<boolean> {
const referenced = await this.userRepository.findAvatarFileIds([fileId]);
return referenced.length > 0;
}
}
@@ -0,0 +1,3 @@
export class RemoveAvatarCommand {
constructor(public readonly userId: string) {}
}
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
import { RemoveAvatarUseCase } from './remove-avatar.use-case.js';
import { RemoveAvatarCommand } from './remove-avatar.command.js';
import { buildUser, mockUserRepository } from '../test-helpers.js';
describe('RemoveAvatarUseCase', () => {
let userRepository: IUserRepository;
let useCase: RemoveAvatarUseCase;
beforeEach(() => {
userRepository = mockUserRepository();
useCase = new RemoveAvatarUseCase(userRepository);
});
it('clears the avatar', async () => {
vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser());
const dto = await useCase.execute(new RemoveAvatarCommand('user-1'));
expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', null);
expect(dto.image).toBeNull();
});
it('throws UserNotFoundException when the user is gone', async () => {
vi.mocked(userRepository.setAvatar).mockResolvedValue(null);
await expect(useCase.execute(new RemoveAvatarCommand('user-1'))).rejects.toThrow(UserNotFoundException);
});
});
@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
import { RemoveAvatarCommand } from './remove-avatar.command.js';
import { UserDto } from '../../dto/user.dto.js';
import { UserApplicationMapper } from '../../mappers/user-application.mapper.js';
/** Снимает аватар; сам файл, оставшийся без владельца, удалит GC file-модуля */
@Injectable()
export class RemoveAvatarUseCase {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
) {}
async execute(command: RemoveAvatarCommand): Promise<UserDto> {
const user = await this.userRepository.setAvatar(command.userId, null);
if (!user) {
throw new UserNotFoundException(command.userId);
}
return UserApplicationMapper.toDto(user);
}
}
@@ -0,0 +1,6 @@
export class SetAvatarCommand {
constructor(
public readonly userId: string,
public readonly fileId: string,
) {}
}
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
import { buildFile } from '../../../../file/application/use-cases/test-helpers.js';
import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js';
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
import { SetAvatarUseCase } from './set-avatar.use-case.js';
import { SetAvatarCommand } from './set-avatar.command.js';
import { buildUser, mockUserRepository } from '../test-helpers.js';
describe('SetAvatarUseCase', () => {
let userRepository: IUserRepository;
let fileAccess: FileAccessService;
let useCase: SetAvatarUseCase;
beforeEach(() => {
userRepository = mockUserRepository();
fileAccess = { getVisibleFile: vi.fn() } as unknown as FileAccessService;
useCase = new SetAvatarUseCase(userRepository, fileAccess);
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile());
vi.mocked(userRepository.setAvatar).mockResolvedValue(buildUser({ image: '/api/files/file-1/download' }));
});
it('sets an own image as the avatar', async () => {
const dto = await useCase.execute(new SetAvatarCommand('user-1', 'file-1'));
expect(fileAccess.getVisibleFile).toHaveBeenCalledWith('file-1', 'user-1');
expect(userRepository.setAvatar).toHaveBeenCalledWith('user-1', {
fileId: 'file-1',
image: '/api/files/file-1/download',
});
expect(dto.image).toBe('/api/files/file-1/download');
});
it('rejects a file uploaded by someone else', async () => {
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ uploaderId: 'user-2' }));
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileForbiddenException);
expect(userRepository.setAvatar).not.toHaveBeenCalled();
});
it('rejects a file that is not an inline image', async () => {
vi.mocked(fileAccess.getVisibleFile).mockResolvedValue(buildFile({ mimeType: 'image/svg+xml' }));
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(AvatarNotImageException);
expect(userRepository.setAvatar).not.toHaveBeenCalled();
});
it('does not set an invisible file', async () => {
vi.mocked(fileAccess.getVisibleFile).mockRejectedValue(new FileNotFoundException('file-1'));
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(FileNotFoundException);
expect(userRepository.setAvatar).not.toHaveBeenCalled();
});
it('throws UserNotFoundException when the user is gone', async () => {
vi.mocked(userRepository.setAvatar).mockResolvedValue(null);
await expect(useCase.execute(new SetAvatarCommand('user-1', 'file-1'))).rejects.toThrow(UserNotFoundException);
});
});
@@ -0,0 +1,45 @@
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository.interface.js';
import { UserNotFoundException } from '../../../domain/exceptions/user-not-found.exception.js';
import { AvatarNotImageException } from '../../../domain/exceptions/avatar-not-image.exception.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
import { SetAvatarCommand } from './set-avatar.command.js';
import { UserDto } from '../../dto/user.dto.js';
import { UserApplicationMapper } from '../../mappers/user-application.mapper.js';
/** URL, по которому клиент показывает аватар; доступ проверяет GET /files/:id/download */
export const avatarUrl = (fileId: string) => `/api/files/${fileId}/download`;
/**
* Делает аватаром файл, заранее загруженный через POST /files.
* Только свой файл — аватар виден всем, иначе так можно было бы раскрыть
* чужое вложение. И только картинку, которую браузер покажет inline.
*
* Прежний аватар остаётся без владельца, его удалит GC file-модуля.
*/
@Injectable()
export class SetAvatarUseCase {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: SetAvatarCommand): Promise<UserDto> {
const file = await this.fileAccess.getVisibleFile(command.fileId, command.userId);
file.assertOwnedBy(command.userId);
if (!file.isInlineViewable) {
throw new AvatarNotImageException(command.fileId);
}
const user = await this.userRepository.setAvatar(command.userId, {
fileId: command.fileId,
image: avatarUrl(command.fileId),
});
if (!user) {
throw new UserNotFoundException(command.userId);
}
return UserApplicationMapper.toDto(user);
}
}
@@ -0,0 +1,22 @@
import { vi } from 'vitest';
import type { IUserRepository } from '../../domain/repositories/user.repository.interface.js';
import { User } from '../../domain/entities/user.entity.js';
export const buildUser = (overrides: Partial<{ id: string; image: string | null }> = {}) =>
User.reconstitute({
id: overrides.id ?? 'user-1',
name: 'Alice',
email: 'alice@example.com',
emailVerified: true,
image: overrides.image === undefined ? null : overrides.image,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
export const mockUserRepository = (): IUserRepository => ({
findById: vi.fn(),
findByIds: vi.fn(),
findAll: vi.fn(),
setAvatar: vi.fn(),
findAvatarFileIds: vi.fn(),
});
@@ -0,0 +1,6 @@
export class AvatarNotImageException extends Error {
constructor(fileId: string) {
super(`Файл id=${fileId} нельзя сделать аватаром: нужна картинка PNG, JPEG, GIF или WebP`);
this.name = 'AvatarNotImageException';
}
}
@@ -8,4 +8,8 @@ export interface IUserRepository {
findById(id: string): Promise<User | null>; findById(id: string): Promise<User | null>;
findByIds(ids: string[]): Promise<User[]>; findByIds(ids: string[]): Promise<User[]>;
findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>; findAll(params: { limit: number; page: number }): Promise<{ list: User[]; meta: PaginationMeta }>;
/** null снимает аватар. Возвращает null, если пользователя нет */
setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null>;
/** Подмножество fileIds, которые являются чьим-то аватаром */
findAvatarFileIds(fileIds: string[]): Promise<string[]>;
} }
@@ -38,4 +38,22 @@ export class UserPrismaRepository implements IUserRepository {
meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) }, meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) },
}; };
} }
async setAvatar(userId: string, avatar: { fileId: string; image: string } | null): Promise<User | null> {
const { count } = await this.prisma.user.updateMany({
where: { id: userId },
data: { avatarFileId: avatar?.fileId ?? null, image: avatar?.image ?? null },
});
return count > 0 ? this.findById(userId) : null;
}
async findAvatarFileIds(fileIds: string[]): Promise<string[]> {
if (fileIds.length === 0) return [];
const records = await this.prisma.user.findMany({
where: { avatarFileId: { in: fileIds } },
select: { avatarFileId: true },
});
return records.map((record) => record.avatarFileId as string);
}
} }
@@ -1,4 +1,15 @@
import { Controller, Get, NotFoundException, Param, Query } from '@nestjs/common'; import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Put,
Query,
} from '@nestjs/common';
import { AllowAnonymous, OptionalAuth, Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { AllowAnonymous, OptionalAuth, Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { GetUserUseCase } from '../../application/use-cases/get-user/get-user.use-case.js'; import { GetUserUseCase } from '../../application/use-cases/get-user/get-user.use-case.js';
import { ListUsersUseCase } from '../../application/use-cases/list-users/list-users.use-case.js'; import { ListUsersUseCase } from '../../application/use-cases/list-users/list-users.use-case.js';
@@ -6,12 +17,22 @@ import { GetUserQuery } from '../../application/use-cases/get-user/get-user.quer
import { ListUsersQuery } from '../../application/use-cases/list-users/list-users.query.js'; import { ListUsersQuery } from '../../application/use-cases/list-users/list-users.query.js';
import { FilterUserDto } from '../dto/filter-user.dto.js'; import { FilterUserDto } from '../dto/filter-user.dto.js';
import { UserNotFoundException } from '../../domain/exceptions/user-not-found.exception.js'; import { UserNotFoundException } from '../../domain/exceptions/user-not-found.exception.js';
import { AvatarNotImageException } from '../../domain/exceptions/avatar-not-image.exception.js';
import { SetAvatarUseCase } from '../../application/use-cases/set-avatar/set-avatar.use-case.js';
import { RemoveAvatarUseCase } from '../../application/use-cases/remove-avatar/remove-avatar.use-case.js';
import { SetAvatarCommand } from '../../application/use-cases/set-avatar/set-avatar.command.js';
import { RemoveAvatarCommand } from '../../application/use-cases/remove-avatar/remove-avatar.command.js';
import { SetAvatarRequestDto } from '../dto/set-avatar.dto.js';
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
@Controller('users') @Controller('users')
export class UserController { export class UserController {
constructor( constructor(
private readonly getUserUseCase: GetUserUseCase, private readonly getUserUseCase: GetUserUseCase,
private readonly listUsersUseCase: ListUsersUseCase, private readonly listUsersUseCase: ListUsersUseCase,
private readonly setAvatarUseCase: SetAvatarUseCase,
private readonly removeAvatarUseCase: RemoveAvatarUseCase,
) {} ) {}
@Get('me') @Get('me')
@@ -19,6 +40,17 @@ export class UserController {
return session; return session;
} }
/** Картинка загружается отдельно (POST /files), здесь — только привязка к пользователю */
@Put('me/avatar')
setAvatar(@Body() dto: SetAvatarRequestDto, @Session() session: UserSession) {
return this.handleNotFound(() => this.setAvatarUseCase.execute(new SetAvatarCommand(session.user.id, dto.fileId)));
}
@Delete('me/avatar')
removeAvatar(@Session() session: UserSession) {
return this.handleNotFound(() => this.removeAvatarUseCase.execute(new RemoveAvatarCommand(session.user.id)));
}
@Get('public') @Get('public')
@AllowAnonymous() // Allow anonymous access (no authentication required) @AllowAnonymous() // Allow anonymous access (no authentication required)
async publicRoute() { async publicRoute() {
@@ -41,14 +73,20 @@ export class UserController {
return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id))); return this.handleNotFound(() => this.getUserUseCase.execute(new GetUserQuery(id)));
} }
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */ /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> { private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try { try {
return await fn(); return await fn();
} catch (error) { } catch (error) {
if (error instanceof UserNotFoundException) { if (error instanceof UserNotFoundException || error instanceof FileNotFoundException) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof FileForbiddenException) {
throw new ForbiddenException(error.message);
}
if (error instanceof AvatarNotImageException) {
throw new BadRequestException(error.message);
}
throw error; throw error;
} }
} }
@@ -0,0 +1,9 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class SetAvatarRequestDto {
/** id картинки, загруженной через POST /files */
@IsString()
@IsNotEmpty()
@MaxLength(64)
fileId: string;
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { FileModule } from '../file/file.module.js';
import { UserPrismaRepository } from './infrastructure/persistence/prisma/repositories/user.prisma-repository.js'; import { UserPrismaRepository } from './infrastructure/persistence/prisma/repositories/user.prisma-repository.js';
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js'; import { USER_REPOSITORY } from './domain/repositories/user.repository.interface.js';
import { UserController } from './presentation/controllers/user.controller.js'; import { UserController } from './presentation/controllers/user.controller.js';
@@ -7,14 +8,20 @@ import { GetUserUseCase } from './application/use-cases/get-user/get-user.use-ca
import { GetUsersUseCase } from './application/use-cases/get-users/get-users.use-case.js'; import { GetUsersUseCase } from './application/use-cases/get-users/get-users.use-case.js';
import { ListUsersUseCase } from './application/use-cases/list-users/list-users.use-case.js'; import { ListUsersUseCase } from './application/use-cases/list-users/list-users.use-case.js';
import { UserResolver } from './presentation/resolvers/user.resolver.js'; import { UserResolver } from './presentation/resolvers/user.resolver.js';
import { SetAvatarUseCase } from './application/use-cases/set-avatar/set-avatar.use-case.js';
import { RemoveAvatarUseCase } from './application/use-cases/remove-avatar/remove-avatar.use-case.js';
import { UserAvatarFileOwnerService } from './application/services/user-avatar-file-owner.service.js';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, FileModule],
controllers: [UserController], controllers: [UserController],
providers: [ providers: [
GetUserUseCase, GetUserUseCase,
GetUsersUseCase, GetUsersUseCase,
ListUsersUseCase, ListUsersUseCase,
SetAvatarUseCase,
RemoveAvatarUseCase,
UserAvatarFileOwnerService,
UserResolver, UserResolver,
{ {
// Domain видит только интерфейс IUserRepository, // Domain видит только интерфейс IUserRepository,
-366
View File
@@ -1,366 +0,0 @@
нужна помощь по архитектуре, есть основной сервис заявок и сервис уведомлений, уведомления должны обладать категориями и типами (пуш, емеил). Связываются сервисы через брокер (nats). Как лучше организовать хранение данных. В каком сервисе ххранить категории, где хранить пуш токены, сами сообщения и тд. Что передавать через брокер в каком виде.
## Общий принцип
Каждый сервис — источник правды только для своих данных (database-per-service). Сервис заявок не должен знать о пуш-токенах, шаблонах писем и т.п. Сервис уведомлений не должен знать бизнес-логику заявок — только достаточно данных, чтобы отрендерить сообщение.
## Где что хранить
**Сервис заявок (source):**
- Сами заявки и их статусы — как обычно.
- Ничего про уведомления не хранит вообще (ни каналы, ни адреса, ни статусы доставки).
**Сервис уведомлений (owner всего, что касается доставки):**
- **Категории уведомлений** (`category`: code, name, важность, дефолтные каналы) — справочник здесь. Категория — это про то, *как доставлять*, а не бизнес-сущность заявок.
- **Типы/каналы** (push, email, позже sms) — тоже справочник/enum здесь.
- **Подписки/настройки пользователя** — какие категории по каким каналам получать (`user_id, category, channel, enabled`).
- **Push-токены устройств** — таблица `device_tokens (user_id, token, platform, updated_at)`. Регистрируются напрямую клиентским приложением через API notification-сервиса, а не через сервис заявок.
- **Email-адреса** — либо дублируются с синхронизацией из identity/user-сервиса (если он есть), либо приходят вместе с событием как поле payload'а (см. ниже). Дублировать с явной ревалидацией — обычно надёжнее, чем ходить синхронно в другой сервис при каждой отправке.
- **Шаблоны сообщений** — тоже здесь.
- **Сами сообщения/лог отправок** — таблица `notifications (id, correlation_id, category, channel, recipient, status, payload, sent_at, delivered_at, error)`. Это и есть история/аудит уведомлений.
## Что передавать через брокер и в каком виде
Через NATS должны идти **доменные события**, а не команды "отправь уведомление". Сервис заявок публикует факт бизнес-события, сервис уведомлений сам решает, нужно ли и как на него реагировать — это держит сервисы независимыми (заявки не знают о существовании уведомлений в принципе).
Рекомендации:
**Subject naming:**
```
orders.request.created
orders.request.status_changed
orders.request.cancelled
```
**Формат события (envelope):**
```json
{
"event_id": "uuid", // для идемпотентности/дедупликации
"event_type": "orders.request.status_changed",
"version": 1,
"occurred_at": "2026-09-22T09:00:00Z",
"aggregate_id": "request-uuid",
"actor_id": "user-uuid",
"payload": {
"status": "approved",
"recipient_user_id": "user-uuid"
// минимально необходимые поля для рендера, не вся заявка целиком
}
}
```
Важные моменты:
- **event_id** обязателен — по нему notification-сервис дедуплицирует (at-least-once доставка в NATS/JetStream — норма, а не баг).
- **"Полу-жирные" события**: не гоняйте туда-сюда синхронные запросы за деталями заявки при каждом уведомлении (это связывает сервисы и роняет доступность notification-сервиса при падении orders). Но и не пихайте в событие всю заявку целиком. Кладите ровно то, что нужно для шаблонов (статус, имя, сумма — по необходимости), остальное — по `aggregate_id` через API orders, если это редкий кейс.
- **Версионирование** payload'а (`version`) — пригодится, когда формат события будет меняться.
- Используйте **JetStream** (persistent streams), а не core NATS, чтобы события не терялись при недоступности notification-сервиса.
## Поток обработки в notification-сервисе
1. Consumer получает событие `orders.request.status_changed`.
2. По `event_type` находит маппинг → категорию уведомления (это конфигурация внутри notification-сервиса, orders о ней не знает).
3. Смотрит подписки пользователя по этой категории → определяет каналы (push/email).
4. Рендерит шаблон, создаёт запись в `notifications` со статусом `queued`.
5. Отправляет через провайдера (FCM/APNs, SMTP/SES и т.д.), обновляет статус (`sent`/`failed`/`delivered`).
6. Опционально публикует обратное событие `notifications.sent` / `notifications.failed` — полезно для аналитики/аудита, но orders-сервису это обычно не нужно подписывать.
## Итого — граница ответственности
| Данные | Где хранятся |
|---|---|
| Заявки | orders-сервис |
| Категории и каналы уведомлений | notification-сервис |
| Push-токены | notification-сервис |
| Настройки подписок пользователя | notification-сервис |
| Шаблоны и текст сообщений | notification-сервис |
| История/статусы отправленных уведомлений | notification-сервис |
| Событие через брокер | orders → notification, "тонкое" доменное событие с event_id |
Если понадобится — могу накидать примерную схему БД (таблицы notification-сервиса) или пример NATS consumer'а с dedup-логикой.
а как пуш токены появляются в сервисе уведомлений?
Клиентское приложение (мобильный/веб) регистрирует токен **напрямую в сервисе уведомлений**, минуя сервис заявок. Это нормальный паттерн — регистрация токена не имеет отношения к бизнес-логике заявок.
## Как это выглядит на практике
1. Клиент получает push-токен от FCM/APNs при старте приложения или логине.
2. Клиент шлёт его в notification-сервис через его собственный API (обычно REST/gRPC endpoint), не через orders-сервис:
```
POST /api/v1/devices
{
"user_id": "...",
"token": "...",
"platform": "ios" | "android" | "web"
}
```
3. Notification-сервис делает upsert в свою таблицу `device_tokens`.
## Нюансы, которые стоит сразу заложить
**Аутентификация запроса.** Notification-сервис должен уметь проверить, что `user_id` в запросе — это действительно тот пользователь, который прислал токен (JWT/сессия), иначе кто угодно сможет зарегистрировать чужой токен на себя. Значит, notification-сервису нужен доступ к тому же механизму аутентификации, что и остальным API (общий auth middleware/gateway, проверка JWT и т.д.) — это отдельный вопрос архитектуры, но обычно решается через API Gateway, который уже провалидировал токен до того, как запрос дошёл до сервиса.
**Один пользователь — несколько токенов.** Человек заходит с телефона и с двух браузеров — три токена, все живые. Ключ уникальности обычно `(user_id, token)`, а не `user_id` — просто добавляете новую запись, если токен новый.
**Протухание токенов.** FCM/APNs могут вернуть ошибку `Unregistered`/`InvalidToken` при попытке отправки — по этому ответу нужно удалять/помечать токен неактивным в `device_tokens`. Это внутренняя логика notification-сервиса, никак не завязанная на orders.
**Ре-регистрация при обновлении токена.** FCM/APNs периодически ротируют токены сами — клиент должен слать обновлённый токен заново тем же способом (upsert по старому+новому или просто upsert по user_id+platform, если храните один активный токен на платформу).
## А что насчёт удаления при логауте?
Тоже прямой вызов клиента в notification-сервис:
```
DELETE /api/v1/devices/{token}
```
чтобы не слать пуши на токен вышедшего пользователя.
## Итого
Путь такой: **клиент → API Gateway (auth) → notification-сервис**, без участия orders-сервиса и без NATS — это синхронная операция регистрации устройства, а не бизнес-событие. NATS остаётся только для доменных событий вроде "заявка изменила статус".
Если у вас пока нет отдельного API Gateway / auth-сервиса — как сейчас происходит аутентификация между клиентом и бэкендом в целом?
есть монолит в котором реализована проверка пользователя
Значит нужно решить, как notification-сервис (и вообще любой новый сервис) будет проверять, что запрос от легитимного пользователя, не дублируя логику авторизации монолита.
## Варианты
**1. JWT, который монолит уже выдаёт клиенту**
Если монолит при логине выдаёт клиенту JWT (даже если сессии где-то ещё используются внутри) — самый простой путь. Notification-сервис получает токен в заголовке, сам проверяет подпись (публичным ключом/секретом монолита) и достаёт `user_id` из claims, без похода в монолит по сети.
```
Client → notification-service
Authorization: Bearer <jwt from monolith>
```
Плюс: notification-сервис не зависит от доступности монолита на каждый запрос.
Минус: нужен способ отзывать токены (revocation list) и следить за синхронизацией ключей.
**2. Если сейчас сессии (cookie), а не JWT**
Тогда варианты:
- Монолит отдаёт эндпоинт **интроспекции**: `POST /internal/auth/verify` с cookie/session-id → возвращает `user_id`. Notification-сервис дергает его синхронно на каждый запрос регистрации токена (это редкая операция — регистрация устройства, не хайлоад, так что синхронный вызов ок).
- Либо ставите перед всеми сервисами (включая монолит) общий **API Gateway/reverse proxy**, который сам проверяет сессию/JWT и прокидывает дальше уже проверенный `X-User-Id` в заголовке. Это правильный долгосрочный путь при переходе на микросервисы, но требует инфраструктурных изменений сейчас.
## Что практичнее на вашем этапе
Раз у вас уже выделяется notification-сервис как первый шаг из монолита, я бы не городил Gateway ради одного сервиса. Практичнее:
1. Уточнить, **что уже выдаёт монолит клиенту** — JWT или cookie-сессию.
2. Если JWT — notification-сервис верифицирует его сам (шарите secret/public key).
3. Если сессия — добавить в монолите один internal-эндпоинт интроспекции токена, notification-сервис ходит туда синхронно только на операции регистрации/удаления устройства (низкая частота, некритично к задержке).
4. NATS-события от orders в этой части не участвуют — это чисто клиент↔notification-сервис путь.
Что у вас сейчас — JWT или серверные сессии?
jwt
С JWT всё просто — notification-сервису вообще не нужно ходить в монолит по сети для проверки.
## Как это работает
1. Монолит подписывает JWT своим ключом при логине (симметричным HMAC-секретом или, лучше, приватным ключом при RS256/ES256).
2. Notification-сервис получает тот же токен от клиента в заголовке `Authorization: Bearer <jwt>`.
3. Notification-сервис **сам** проверяет подпись и достаёт `user_id` (и что нужно) из claims — без обращения к монолиту.
```
POST /api/v1/devices
Authorization: Bearer <jwt>
{
"token": "...",
"platform": "ios"
}
```
`user_id` при этом берётся **из claims токена**, а не из тела запроса — иначе любой авторизованный пользователь сможет зарегистрировать токен на чужой `user_id`, просто подставив его в JSON. Тело запроса содержит только то, что клиент не может подделать в свою пользу (device token, platform), а personal identity всегда из верифицированного JWT.
## Что нужно расшарить между монолитом и notification-сервисом
- **Если HMAC (HS256)** — общий секрет. Хранить в секрет-менеджере (Vault/K8s secret/SSM), не в коде, раздать обоим сервисам.
- **Если RSA/EC (RS256/ES256)** — гораздо лучше для мультисервисной архитектуры: монолит держит приватный ключ и подписывает, notification-сервис знает только **публичный** ключ и может только проверять, но не подделывать токены. Публичный ключ можно даже раздавать через `/.well-known/jwks.json` эндпоинт на монолите — тогда новые сервисы просто подтягивают его при старте/по кэшу, без ручной синхронизации секретов.
Если сейчас HS256 — при росте числа сервисов стоит подумать о переходе на RS256/ES256 именно из соображений "каждому новому сервису для чтения не нужно доверять секрет, которым можно подписывать".
## Отзыв токенов (revocation)
Раз проверка теперь оффлайн (без похода в монолит), классическая проблема JWT — как обработать логаут/бан до истечения `exp`. Варианты, если это важно для вас:
- короткий TTL access-токена (5-15 мин) + refresh-токен — тогда отозванный доступ отваливается быстро сам по себе;
- либо блэклист отозванных `jti` в Redis, который проверяют все сервисы (но это уже добавляет сетевой поход и связанность).
Если у вас логаут не критичен к мгновенности (например, там просто короткий TTL) — можно не усложнять и оставить чисто оффлайн-проверку.
Какой у вас алгоритм подписи сейчас — HS256 или уже RS256/подобный?
а смс отправка, в сервисе уведомлений хранить телефоны
Да, по той же логике, что и с email — если SMS станет одним из каналов, номера телефонов хранятся в notification-сервисе, а не запрашиваются синхронно у монолита при каждой отправке.
## Варианты получения номера
**1. Дублирование с синхронизацией (рекомендую)**
- При регистрации/изменении профиля в монолите публикуется событие `user.profile_updated` (или `user.phone_changed`) в NATS.
- Notification-сервис подписывается, обновляет свою локальную копию `user_contacts (user_id, phone, email, updated_at)`.
- При отправке SMS notification-сервис берёт номер из своей БД — без синхронного похода в монолит.
Плюс: полная развязка, отправка не зависит от доступности монолита.
Минус: нуженource-of-truth-событие на каждое изменение контактных данных в монолите (если такого механизма пока нет — придётся добавить).
**2. Номер приходит вместе с доменным событием**
Если SMS шлётся не "вообще", а в ответ на конкретное действие (например, подтверждение заявки), можно не хранить номер вовсе, а прокидывать его в payload события:
```json
{
"event_type": "orders.request.status_changed",
"payload": {
"recipient_user_id": "...",
"recipient_phone": "+79991234567"
}
}
```
Плюс: не нужен отдельный механизм синхронизации контактов.
Минус: монолит должен знать, что нужно положить телефон в событие (небольшая связанность "по данным", не по вызовам) — но это всё равно значительно лучше, чем синхронный HTTP-запрос за телефоном на каждую отправку.
## Что выбрать
Если контактные данные меняются редко и на них завязано несколько каналов (email, sms, push-уведомления с персонализацией по имени) — вариант 1 чище: заводите таблицу контактов в notification-сервисе и синхронизируете событием при изменении профиля.
Если SMS — разовый узкий кейс (например, только "код подтверждения" или "статус заявки") — вариант 2 проще и не требует лишней инфраструктуры синхронизации.
## Общая таблица получается такая
| Данные | Где хранится | Как приходят |
|---|---|---|
| Push-токены | notification-сервис | напрямую от клиента, по своему API |
| Email/телефон | notification-сервис (копия) | событие `user.profile_updated` из монолита, либо в payload доменного события |
| Категории/каналы/шаблоны | notification-сервис | конфигурация сервиса |
| Заявки | монолит/orders | — |
Одна важная деталь: раз номера и email — персональные данные (ПДн), при дублировании в notification-сервисе стоит сразу продумать их шифрование at rest и политику хранения/удаления (например, по GDPR/152-ФЗ, если это применимо к вашему проекту) — то есть не просто copy-paste, а с тем же уровнем защиты, что и в монолите.
Возникает SMS-канал уже сейчас, или это на перспективу — просто закладываете архитектуру с запасом?
перспектива
Тогда не стоит сейчас городить полноценную синхронизацию контактов ради канала, которого ещё нет.
## Что делать сейчас
Заложите на будущее минимально — не более:
1. **Таблицу `user_contacts` сразу спроектируйте с запасом** (даже если пока заполняете только то, что нужно для push/email):
```sql
user_contacts (
user_id UUID PRIMARY KEY,
email TEXT,
phone TEXT,
updated_at TIMESTAMPTZ
)
```
Так при добавлении SMS не придётся переделывать схему — просто начнёте заполнять `phone`.
2. **Общий механизм синхронизации делайте один — для всех контактных полей сразу**, а не отдельно под email и отдельно под будущий phone. Т.е. событие `user.profile_updated` с обоими полями в payload:
```json
{
"event_type": "user.profile_updated",
"payload": {
"user_id": "...",
"email": "...",
"phone": "..."
}
}
```
Notification-сервис просто апсертит всё, что пришло, не заботясь о том, используется ли `phone` уже сейчас.
3. **Канал `sms` заведите как enum/значение в справочнике каналов уже сейчас**, даже неактивное — не придётся трогать миграции и код маппинга категорий→каналов, когда он реально понадобится.
## Чего делать не стоит
- Не подключайте SMS-провайдера (Twilio/SMS.ru/др.) заранее — это чистый YAGNI, только усложнит деплой и конфиги без пользы.
- Не делайте отдельную инфраструктуру верификации телефонов (подтверждение номера) заранее — это отдельная фича, привязывайте её к моменту, когда SMS реально понадобится.
- Не усложняйте событие `orders.request.status_changed` полем `recipient_phone`, если SMS туда пока не шлётся — лишнее поле без потребителя.
Итог: с минимальными изменениями в схеме и в контракте события `user.profile_updated` вы ничего не переделываете, когда дойдёте до SMS — просто добавляете провайдера и обработчик канала. Остальное — по факту появления реальной необходимости.
получается настройки пользователя по рассылке сообщений тоже хранить в сервисе уведомлений
Да, верно — это тоже часть зоны ответственности notification-сервиса: настройки касаются доставки уведомлений, а не бизнес-логики монолита.
## Схема хранения
```sql
notification_preferences (
user_id UUID,
category TEXT, -- 'request_status_changed', 'request_approved', ...
channel TEXT, -- 'push', 'email', 'sms'
enabled BOOLEAN DEFAULT true,
updated_at TIMESTAMPTZ,
PRIMARY KEY (user_id, category, channel)
)
```
Дефолт — если записи нет, считаете `enabled = true` (или прописываете дефолты явно при создании пользователя/по первому событию — так проще для аналитики "кто что выключил").
## Как пользователь меняет настройки
Тут два пути, зависит от того, как у вас организован фронтенд/UI:
**1. Фронт стучится напрямую в notification-сервис**
```
PUT /api/v1/preferences
Authorization: Bearer <jwt>
{
"category": "request_approved",
"channel": "email",
"enabled": false
}
```
Тот же принцип авторизации, что и с push-токенами — JWT валидируется notification-сервисом самостоятельно, `user_id` берётся из claims.
Плюс: чисто, без лишних хопов.
Минус: если UI настроек живёт внутри монолитного приложения (например, единая страница профиля), фронту нужно знать про два разных бэкенда — обычно решается тем, что API Gateway/reverse-proxy просто роутит `/api/notifications/*` в notification-сервис, а остальное — в монолит. Для фронта это прозрачно, один домен.
**2. Монолит проксирует запрос**
Если пока нет общего гейтвея и не хочется светить notification-сервис наружу отдельным доменом — монолит принимает запрос от фронта и просто форвардит его в notification-сервис (server-to-server, с сервисным токеном или доверенной сетью). Проще для фронта прямо сейчас, но добавляет лишний хоп и точку отказа.
Если планируете API Gateway в перспективе (вы уже говорили, что growth в сторону микросервисов) — я бы сразу шёл по варианту 1, чтобы не переделывать потом.
## Что ещё сюда относится
- **Глобальный opt-out** — отдельная настройка `user_id → all_notifications_disabled` полезна для "отписаться от всего" (часто требуется по закону для email-рассылок — unsubscribe-ссылка).
- **Read-модель для быстрого чтения** — так как эта таблица читается на **каждое** событие из NATS (проверка "слать ли"), убедитесь что там индекс по `user_id`, и в целом это должна быть быстрая точка доступа (можно и в Redis кэшировать активные подписки, если объёмы большие).
Итого зона ответственности notification-сервиса теперь выглядит так:
| Сущность | Таблица |
|---|---|
| Push-токены | `device_tokens` |
| Контакты (email/phone) | `user_contacts` |
| Настройки подписок | `notification_preferences` |
| Категории/каналы | справочник/enum |
| Шаблоны | `templates` |
| Лог отправок | `notifications` |
Всё, что касается "кому, как и хочет ли получать" — здесь, и нигде больше. Монолит/orders об этом не знает вообще, только публикует факт события.