notification

This commit is contained in:
vadim committed 2026-09-29 18:42:29 +03:00
1 parent 382cafa152
commit 60391ee123
26 files changed
+1064 -1

No files matched your search

+23
View File
@@ -16,6 +16,7 @@
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/typeorm": "^12.0.1",
@@ -1239,6 +1240,22 @@
}
}
},
"node_modules/@nestjs/event-emitter": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-12.0.1.tgz",
"integrity": "sha512-7MbpgNcxUcDlqcYY/qgZm1H7MfK3uGcRsd8lEjj9ZYvI9qTl6kdkkykpxZ6YjBRH3wlZK5dFLAei8h5qpsHzOw==",
"license": "MIT",
"dependencies": {
"eventemitter2": "6.4.9"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@nestjs/common": "^11.0.0 || ^12.0.0",
"@nestjs/core": "^11.0.0 || ^12.0.0"
}
},
"node_modules/@nestjs/graphql": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/graphql/-/graphql-14.0.2.tgz",
@@ -5005,6 +5022,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/event-emitter": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/typeorm": "^12.0.1",
+56
View File
@@ -21,6 +21,9 @@ model User {
reporterIssues Issue[] @relation(name: "reporterIssues")
projects Project[]
projectMemberships ProjectMember[]
notificationDeviceTokens NotificationDeviceToken[]
notificationPreferences NotificationPreference[]
notificationMessages NotificationMessage[]
@@unique([email])
@@map("user")
@@ -132,3 +135,56 @@ model ProjectMember {
@@index([userId])
@@map("project_member")
}
/// Push-токены устройств пользователя. Регистрируются напрямую клиентом,
/// без участия остального приложения.
model NotificationDeviceToken {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String
platform String // ios | android | web
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, token])
@@index([userId])
@@map("notification_device_token")
}
/// Включён ли у пользователя конкретный канал доставки уведомлений.
/// Категорий нет — настройка одна на канал.
/// Допустимые значения channel валидируются в коде (NotificationChannel VO),
/// а не типом БД — расширение списка каналов не требует миграции.
model NotificationPreference {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
enabled Boolean @default(true)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([userId, channel])
@@map("notification_preference")
}
/// Лог/история отправленных уведомлений — одна запись на попытку доставки
/// по одному каналу. channel/status — строки, валидируются в коде
/// (NotificationChannel/NotificationStatus VO), см. комментарий выше.
model NotificationMessage {
id String @id @default(uuid(7))
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel String
status String @default("PENDING")
subject String?
content String
error String?
sentAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@index([userId])
@@index([status])
@@index([createdAt])
@@map("notification_message")
}
+6
View File
@@ -5,9 +5,11 @@ import { IssueModule } from './modules/issue/issue.module.js';
import { ProjectModule } from './modules/project/project.module.js';
import { UserModule } from './modules/user/user.module.js';
import { StatusModule } from './modules/status/status.module.js';
import { NotificationModule } from './modules/notification/notification.module.js';
import { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path';
@@ -18,6 +20,9 @@ import { join } from 'node:path';
isGlobal: true,
cache: true,
}),
// Внутрипроцессная шина доменных событий. Notification-модуль слушает
// события через неё, не завязываясь на модули, которые их порождают.
EventEmitterModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
@@ -40,6 +45,7 @@ import { join } from 'node:path';
ProjectModule,
UserModule,
StatusModule,
NotificationModule,
],
controllers: [AppController],
providers: [AppService],
+119
View File
@@ -0,0 +1,119 @@
# Notification module
Отправка уведомлений пользователям по email и push. Живёт в монолите `ulitka`,
доставка происходит в том же процессе — без отдельного сервиса/брокера.
## Хранение данных
Таблицы в Prisma-схеме (`prisma/schema.prisma`), с префиксом `notification_`:
| Таблица | Модель | Назначение |
|---|---|---|
| `notification_device_token` | `NotificationDeviceToken` | push-токены устройств (`userId, token, platform`) |
| `notification_preference` | `NotificationPreference` | вкл/выкл канал для пользователя (`userId, channel, enabled`). Категорий уведомлений нет — настройка одна на канал. Если записи нет, канал считается включённым |
| `notification_message` | `NotificationMessage` | лог отправок: канал, статус, содержимое, ошибка, время отправки |
Миграции пока не применялись — только `prisma generate` для типов. Первая
`prisma migrate dev` создаст все три таблицы.
**Каналы и статусы — строки, а не enum БД.** `channel`/`status` — это
`String`-колонки, допустимые значения валидируются в коде через value-объекты
(`domain/value-objects/notification-channel.vo.ts`,
`.../notification-status.vo.ts`), а не типом Postgres. Это сознательный выбор
(тот же паттерн, что у `Issue.status` в issue-модуле): расширить список
каналов — правка константы в коде, без миграции БД. Убрать/переименовать
enum-значение в Postgres, наоборот, болезненно (`DROP VALUE` не
поддерживается).
## Как попросить отправить уведомление
Публичный вход — `NotificationsService.notify()`. Любой другой модуль зовёт
его и не знает, что уведомление уйдёт через email/push:
```ts
notificationsService.notify({
userId: 'user-id',
content: 'Заявка одобрена',
subject: 'Статус заявки изменился', // опционально, тема письма
channels: ['EMAIL'], // опционально; без указания — все включённые у пользователя каналы
});
```
Это fire-and-forget: вызывающий код не блокируется и не получает результат
доставки.
### Механизм передачи — EventEmitter2, не Redis
`notify()` эмитит внутрипроцессное событие `notification.notify`
(`@nestjs/event-emitter`, зарегистрирован в `AppModule` через
`EventEmitterModule.forRoot()`). `NotificationDispatcherService` слушает его
через `@OnEvent`.
Почему не Redis/очередь: `ulitka` — монолит в одном процессе, такой же
in-process pub/sub уже используется в issue-модуле
(`IssueEventsService` на RxJS `Subject`). Redis дал бы межпроцессность и
персистентность очереди с ретраями — сейчас это не нужно, и инфраструктуры
для него в проекте нет. Если понадобится (at-least-once, ретраи, вынос
уведомлений в отдельный сервис) — меняется реализация под `notify()`,
публичный контракт не трогается.
## Поток обработки (`NotificationDispatcherService`)
1. Получает событие `notification.notify`.
2. Определяет каналы: явные из `payload.channels`, либо (если не заданы) —
все каналы, для которых есть отправщик; из них исключаются явно
выключенные в `notification_preference`.
3. Для каждого канала создаёт запись в `notification_message` со статусом
`PENDING`.
4. Отправляет через `NotificationChannelSender` этого канала, обновляет
статус на `SENT`/`FAILED` (+ текст ошибки).
## Отправщики каналов
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
(общая БД, синхронный поход не нужен) и шлёт через существующий
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
- `PushChannelSender` — читает токены устройств из `notification_device_token`,
но сама отправка — заглушка (лог вместо реального вызова). Провайдер
(FCM/APNs) ещё не подключён; когда появится, меняется только тело
`send()`, остальной пайплайн — нет.
## HTTP API
Базовый путь — `/api/notifications` (глобальный prefix `api`), авторизация —
как у остальных контроллеров, через сессию (`@Session()`), `userId` берётся
из неё, а не из тела запроса.
| Метод | Путь | Тело | Действие |
|---|---|---|---|
| `POST` | `/device-tokens` | `{ token, platform: 'ios'\|'android'\|'web' }` | Регистрирует/обновляет push-токен |
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
| `PUT` | `/preferences/:channel` | `{ enabled: boolean }` | Включает/выключает канал. Невалидный `:channel` → `400` |
## Структура
```
domain/
events/notify.event.ts — событие + payload
value-objects/ — допустимые channel/status + валидация
repositories/*.interface.ts — интерфейсы + DI-токены (Symbol)
channel-sender.interface.ts — контракт отправщика канала
application/services/
notifications.service.ts — публичный фасад модуля
notification-dispatcher.service.ts — обработчик события, раскладка по каналам
infrastructure/
persistence/prisma/repositories/ — Prisma-реализации репозиториев
senders/ — EmailChannelSender, PushChannelSender
presentation/
controllers/notification.controller.ts
dto/ — DTO с class-validator для входящих запросов
notification.module.ts — сборка DI
```
## Чего пока нет (сознательно, вне текущего объёма)
- Реального push-провайдера (FCM/APNs) — только заглушка с логированием.
- Шаблонов сообщений — `subject`/`content` передаются вызывающим кодом как есть.
- SMS-канала — при необходимости добавляется как ещё одно значение в
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
@@ -0,0 +1,73 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NOTIFY_EVENT, type NotifyEventPayload } from '../../domain/events/notify.event.js';
import {
NOTIFICATION_CHANNEL_SENDERS,
type NotificationChannelSender,
} from '../../domain/channel-sender.interface.js';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
type INotificationPreferenceRepository,
} from '../../domain/repositories/notification-preference.repository.interface.js';
import {
NOTIFICATION_MESSAGE_REPOSITORY,
type INotificationMessageRepository,
} from '../../domain/repositories/notification-message.repository.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
/**
* Слушает события `notification.notify` и раскладывает их по каналам:
* смотрит подписки пользователя, шлёт через нужные NotificationChannelSender,
* пишет результат в лог notification_message.
*/
@Injectable()
export class NotificationDispatcherService {
private readonly logger = new Logger(NotificationDispatcherService.name);
constructor(
@Inject(NOTIFICATION_CHANNEL_SENDERS) private readonly senders: NotificationChannelSender[],
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY) private readonly preferences: INotificationPreferenceRepository,
@Inject(NOTIFICATION_MESSAGE_REPOSITORY) private readonly messages: INotificationMessageRepository,
) {}
@OnEvent(NOTIFY_EVENT)
async handle(payload: NotifyEventPayload): Promise<void> {
const channels = await this.resolveChannels(payload);
await Promise.all(channels.map((channel) => this.dispatchToChannel(channel, payload)));
}
/** Каналы из payload, отфильтрованные по подпискам; без явных подписок — все каналы включены по умолчанию */
private async resolveChannels(payload: NotifyEventPayload): Promise<NotificationChannelValue[]> {
const userPreferences = await this.preferences.findByUserId(payload.userId);
const disabledChannels = new Set(userPreferences.filter((preference) => !preference.enabled).map((preference) => preference.channel));
const candidateChannels = payload.channels ?? this.senders.map((sender) => sender.channel);
return candidateChannels.filter((channel) => !disabledChannels.has(channel));
}
private async dispatchToChannel(channel: NotificationChannelValue, payload: NotifyEventPayload): Promise<void> {
const sender = this.senders.find((candidate) => candidate.channel === channel);
if (!sender) {
this.logger.warn(`No sender registered for channel ${channel}`);
return;
}
const message = await this.messages.create({
userId: payload.userId,
channel,
subject: payload.subject,
content: payload.content,
});
try {
await sender.send({ userId: payload.userId, subject: payload.subject, content: payload.content });
await this.messages.markStatus(message.id, 'SENT');
} catch (error) {
this.logger.error(
`Failed to deliver notification ${message.id} via ${channel}`,
error instanceof Error ? error.stack : undefined,
);
await this.messages.markStatus(message.id, 'FAILED', error instanceof Error ? error.message : String(error));
}
}
}
@@ -0,0 +1,58 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { NOTIFY_EVENT, type NotifyEventPayload } from '../../domain/events/notify.event.js';
import {
DEVICE_TOKEN_REPOSITORY,
type IDeviceTokenRepository,
} from '../../domain/repositories/device-token.repository.interface.js';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
type INotificationPreferenceRepository,
} from '../../domain/repositories/notification-preference.repository.interface.js';
import { NOTIFICATION_CHANNELS, type NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
export interface ResolvedNotificationPreference {
channel: NotificationChannelValue;
enabled: boolean;
}
/** Публичный вход модуля для остальных частей приложения. */
@Injectable()
export class NotificationsService {
constructor(
private readonly eventEmitter: EventEmitter2,
@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository,
@Inject(NOTIFICATION_PREFERENCE_REPOSITORY) private readonly preferences: INotificationPreferenceRepository,
) {}
/**
* Просит отправить уведомление, не дожидаясь доставки (fire-and-forget):
* вызывающий код не должен блокироваться и не должен знать про email/push.
*/
notify(payload: NotifyEventPayload): void {
this.eventEmitter.emit(NOTIFY_EVENT, payload);
}
registerDeviceToken(userId: string, token: string, platform: string): Promise<void> {
return this.deviceTokens.upsert(userId, token, platform);
}
removeDeviceToken(userId: string, token: string): Promise<void> {
return this.deviceTokens.remove(userId, token);
}
/** Все каналы с эффективным enabled — если записи в БД нет, канал считается включённым */
async getPreferences(userId: string): Promise<ResolvedNotificationPreference[]> {
const saved = await this.preferences.findByUserId(userId);
const enabledByChannel = new Map(saved.map((preference) => [preference.channel, preference.enabled]));
return NOTIFICATION_CHANNELS.map((channel) => ({
channel,
enabled: enabledByChannel.get(channel) ?? true,
}));
}
setPreference(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void> {
return this.preferences.set(userId, channel, enabled);
}
}
@@ -0,0 +1,9 @@
import type { NotificationChannelValue } from './value-objects/notification-channel.vo.js';
/** DI-токен для набора отправщиков, по одному на каждый канал доставки */
export const NOTIFICATION_CHANNEL_SENDERS = Symbol('NOTIFICATION_CHANNEL_SENDERS');
export interface NotificationChannelSender {
readonly channel: NotificationChannelValue;
send(params: { userId: string; subject?: string; content: string }): Promise<void>;
}
@@ -0,0 +1,16 @@
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
/**
* Событие, которым любой модуль просит отправить уведомление, не зная,
* как именно notification-модуль его доставит (email/push, сейчас/потом).
*/
export const NOTIFY_EVENT = 'notification.notify';
export interface NotifyEventPayload {
userId: string;
/** Если не задано — уведомление уходит по всем каналам, включённым у пользователя */
channels?: NotificationChannelValue[];
/** Используется email-каналом как тема письма */
subject?: string;
content: string;
}
@@ -0,0 +1,11 @@
import type { NotificationDeviceToken } from '../../../../generated/prisma/client.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const DEVICE_TOKEN_REPOSITORY = Symbol('DEVICE_TOKEN_REPOSITORY');
export interface IDeviceTokenRepository {
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
upsert(userId: string, token: string, platform: string): Promise<void>;
remove(userId: string, token: string): Promise<void>;
findByUserId(userId: string): Promise<NotificationDeviceToken[]>;
}
@@ -0,0 +1,16 @@
import type { NotificationMessage } from '../../../../generated/prisma/client.js';
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
import type { NotificationStatusValue } from '../value-objects/notification-status.vo.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const NOTIFICATION_MESSAGE_REPOSITORY = Symbol('NOTIFICATION_MESSAGE_REPOSITORY');
export interface INotificationMessageRepository {
create(params: {
userId: string;
channel: NotificationChannelValue;
subject?: string;
content: string;
}): Promise<NotificationMessage>;
markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void>;
}
@@ -0,0 +1,10 @@
import type { NotificationPreference } from '../../../../generated/prisma/client.js';
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const NOTIFICATION_PREFERENCE_REPOSITORY = Symbol('NOTIFICATION_PREFERENCE_REPOSITORY');
export interface INotificationPreferenceRepository {
findByUserId(userId: string): Promise<NotificationPreference[]>;
set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void>;
}
@@ -0,0 +1,23 @@
export const NOTIFICATION_CHANNELS = ['EMAIL', 'PUSH'] as const;
export type NotificationChannelValue = (typeof NOTIFICATION_CHANNELS)[number];
export class NotificationChannel {
private constructor(private readonly _value: NotificationChannelValue) {}
static create(value: string): NotificationChannel {
if (!NOTIFICATION_CHANNELS.includes(value as NotificationChannelValue)) {
throw new Error(`Недопустимый канал уведомлений: "${value}". Разрешены: ${NOTIFICATION_CHANNELS.join(', ')}`);
}
return new NotificationChannel(value as NotificationChannelValue);
}
get value(): NotificationChannelValue {
return this._value;
}
equals(other: NotificationChannel): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,27 @@
export const NOTIFICATION_STATUSES = ['PENDING', 'SENT', 'FAILED'] as const;
export type NotificationStatusValue = (typeof NOTIFICATION_STATUSES)[number];
export class NotificationStatus {
private constructor(private readonly _value: NotificationStatusValue) {}
static create(value: string): NotificationStatus {
if (!NOTIFICATION_STATUSES.includes(value as NotificationStatusValue)) {
throw new Error(`Недопустимый статус уведомления: "${value}". Разрешены: ${NOTIFICATION_STATUSES.join(', ')}`);
}
return new NotificationStatus(value as NotificationStatusValue);
}
static pending(): NotificationStatus {
return new NotificationStatus('PENDING');
}
get value(): NotificationStatusValue {
return this._value;
}
equals(other: NotificationStatus): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationDeviceToken } from '../../../../../../generated/prisma/client.js';
import type { IDeviceTokenRepository } from '../../../../domain/repositories/device-token.repository.interface.js';
@Injectable()
export class DeviceTokenPrismaRepository implements IDeviceTokenRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async upsert(userId: string, token: string, platform: string): Promise<void> {
await this.prisma.notificationDeviceToken.upsert({
where: { userId_token: { userId, token } },
create: { userId, token, platform },
update: { platform },
});
}
async remove(userId: string, token: string): Promise<void> {
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
}
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationMessage } from '../../../../../../generated/prisma/client.js';
import type { INotificationMessageRepository } from '../../../../domain/repositories/notification-message.repository.interface.js';
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
import type { NotificationStatusValue } from '../../../../domain/value-objects/notification-status.vo.js';
@Injectable()
export class NotificationMessagePrismaRepository implements INotificationMessageRepository {
constructor(private readonly prisma: PrismaAdapter) {}
create(params: {
userId: string;
channel: NotificationChannelValue;
subject?: string;
content: string;
}): Promise<NotificationMessage> {
return this.prisma.notificationMessage.create({ data: params });
}
async markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void> {
await this.prisma.notificationMessage.update({
where: { id },
data: { status, error, sentAt: status === 'SENT' ? new Date() : undefined },
});
}
}
@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationPreference } from '../../../../../../generated/prisma/client.js';
import type { INotificationPreferenceRepository } from '../../../../domain/repositories/notification-preference.repository.interface.js';
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
@Injectable()
export class NotificationPreferencePrismaRepository implements INotificationPreferenceRepository {
constructor(private readonly prisma: PrismaAdapter) {}
findByUserId(userId: string): Promise<NotificationPreference[]> {
return this.prisma.notificationPreference.findMany({ where: { userId } });
}
async set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void> {
await this.prisma.notificationPreference.upsert({
where: { userId_channel: { userId, channel } },
create: { userId, channel, enabled },
update: { enabled },
});
}
}
@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import EmailSender from '../../../../lib/email-sender.js';
import { PrismaAdapter } from '../../../../common/prisma-adapter/prisma.adapter.js';
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
@Injectable()
export class EmailChannelSender implements NotificationChannelSender {
readonly channel: NotificationChannelValue = 'EMAIL';
constructor(private readonly prisma: PrismaAdapter) {}
async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: params.userId },
select: { email: true },
});
await EmailSender.getTransporter().sendMail({
to: user.email,
subject: params.subject ?? 'Уведомление',
text: params.content,
});
}
}
@@ -0,0 +1,31 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
import {
DEVICE_TOKEN_REPOSITORY,
type IDeviceTokenRepository,
} from '../../domain/repositories/device-token.repository.interface.js';
/**
* Провайдер push-доставки (FCM/APNs) пока не подключён — отправка логируется.
* Токены уже читаются из БД, так что при подключении провайдера
* меняется только тело send(), остальной пайплайн трогать не нужно.
*/
@Injectable()
export class PushChannelSender implements NotificationChannelSender {
private readonly logger = new Logger(PushChannelSender.name);
readonly channel: NotificationChannelValue = 'PUSH';
constructor(@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository) {}
async send(params: { userId: string; content: string }): Promise<void> {
const tokens = await this.deviceTokens.findByUserId(params.userId);
if (tokens.length === 0) {
throw new Error(`No device tokens registered for user ${params.userId}`);
}
this.logger.warn(
`Push provider is not configured yet, skipping actual delivery to ${tokens.length} device(s): ${params.content}`,
);
}
}
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { DEVICE_TOKEN_REPOSITORY } from './domain/repositories/device-token.repository.interface.js';
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository.interface.js';
import { NOTIFICATION_MESSAGE_REPOSITORY } from './domain/repositories/notification-message.repository.interface.js';
import { NOTIFICATION_CHANNEL_SENDERS } from './domain/channel-sender.interface.js';
import { DeviceTokenPrismaRepository } from './infrastructure/persistence/prisma/repositories/device-token.prisma-repository.js';
import { NotificationPreferencePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-preference.prisma-repository.js';
import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js';
import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js';
import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
import { NotificationsService } from './application/services/notifications.service.js';
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
import { NotificationController } from './presentation/controllers/notification.controller.js';
@Module({
imports: [PrismaModule],
controllers: [NotificationController],
providers: [
{
// Domain видит только интерфейс, конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: DEVICE_TOKEN_REPOSITORY,
useClass: DeviceTokenPrismaRepository,
},
{
provide: NOTIFICATION_PREFERENCE_REPOSITORY,
useClass: NotificationPreferencePrismaRepository,
},
{
provide: NOTIFICATION_MESSAGE_REPOSITORY,
useClass: NotificationMessagePrismaRepository,
},
EmailChannelSender,
PushChannelSender,
{
// Диспетчер обращается к списку каналов, не завязываясь на конкретные классы
provide: NOTIFICATION_CHANNEL_SENDERS,
useFactory: (email: EmailChannelSender, push: PushChannelSender) => [email, push],
inject: [EmailChannelSender, PushChannelSender],
},
NotificationsService,
NotificationDispatcherService,
],
exports: [NotificationsService],
})
export class NotificationModule {}
@@ -0,0 +1,48 @@
import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Post } from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { NotificationsService } from '../../application/services/notifications.service.js';
import { RegisterDeviceTokenDto } from '../dto/register-device-token.dto.js';
import { RemoveDeviceTokenDto } from '../dto/remove-device-token.dto.js';
import { SetNotificationPreferenceDto } from '../dto/set-notification-preference.dto.js';
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notifications: NotificationsService) {}
@Post('device-tokens')
registerDeviceToken(@Body() dto: RegisterDeviceTokenDto, @Session() session: UserSession) {
return this.notifications.registerDeviceToken(session.user.id, dto.token, dto.platform);
}
/** Токен передаётся в теле, а не в URL — может содержать символы, небезопасные для path-сегмента */
@Delete('device-tokens')
@HttpCode(HttpStatus.NO_CONTENT)
async removeDeviceToken(@Body() dto: RemoveDeviceTokenDto, @Session() session: UserSession): Promise<void> {
await this.notifications.removeDeviceToken(session.user.id, dto.token);
}
@Get('preferences')
getPreferences(@Session() session: UserSession) {
return this.notifications.getPreferences(session.user.id);
}
@Put('preferences/:channel')
setPreference(
@Param('channel') channelParam: string,
@Body() dto: SetNotificationPreferenceDto,
@Session() session: UserSession,
) {
const channel = this.parseChannel(channelParam);
return this.notifications.setPreference(session.user.id, channel, dto.enabled);
}
/** Транслирует ошибку валидации домена в HTTP-ответ — это забота presentation-слоя */
private parseChannel(value: string) {
try {
return NotificationChannel.create(value).value;
} catch (error) {
throw new BadRequestException(error instanceof Error ? error.message : 'Invalid channel');
}
}
}
@@ -0,0 +1,12 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
const PLATFORMS = ['ios', 'android', 'web'] as const;
export class RegisterDeviceTokenDto {
@IsString()
@IsNotEmpty()
token: string;
@IsIn(PLATFORMS)
platform: (typeof PLATFORMS)[number];
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RemoveDeviceTokenDto {
@IsString()
@IsNotEmpty()
token: string;
}
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SetNotificationPreferenceDto {
@IsBoolean()
enabled: boolean;
}
+366
View File
@@ -0,0 +1,366 @@
нужна помощь по архитектуре, есть основной сервис заявок и сервис уведомлений, уведомления должны обладать категориями и типами (пуш, емеил). Связываются сервисы через брокер (nats). Как лучше организовать хранение данных. В каком сервисе ххранить категории, где хранить пуш токены, сами сообщения и тд. Что передавать через брокер в каком виде.
## Общий принцип
Каждый сервис — источник правды только для своих данных (database-per-service). Сервис заявок не должен знать о пуш-токенах, шаблонах писем и т.п. Сервис уведомлений не должен знать бизнес-логику заявок — только достаточно данных, чтобы отрендерить сообщение.
## Где что хранить
**Сервис заявок (source):**
- Сами заявки и их статусы — как обычно.
- Ничего про уведомления не хранит вообще (ни каналы, ни адреса, ни статусы доставки).
**Сервис уведомлений (owner всего, что касается доставки):**
- **Категории уведомлений** (`category`: code, name, важность, дефолтные каналы) — справочник здесь. Категория — это про то, *как доставлять*, а не бизнес-сущность заявок.
- **Типы/каналы** (push, email, позже sms) — тоже справочник/enum здесь.
- **Подписки/настройки пользователя** — какие категории по каким каналам получать (`user_id, category, channel, enabled`).
- **Push-токены устройств** — таблица `device_tokens (user_id, token, platform, updated_at)`. Регистрируются напрямую клиентским приложением через API notification-сервиса, а не через сервис заявок.
- **Email-адреса** — либо дублируются с синхронизацией из identity/user-сервиса (если он есть), либо приходят вместе с событием как поле payload'а (см. ниже). Дублировать с явной ревалидацией — обычно надёжнее, чем ходить синхронно в другой сервис при каждой отправке.
- **Шаблоны сообщений** — тоже здесь.
- **Сами сообщения/лог отправок** — таблица `notifications (id, correlation_id, category, channel, recipient, status, payload, sent_at, delivered_at, error)`. Это и есть история/аудит уведомлений.
## Что передавать через брокер и в каком виде
Через NATS должны идти **доменные события**, а не команды "отправь уведомление". Сервис заявок публикует факт бизнес-события, сервис уведомлений сам решает, нужно ли и как на него реагировать — это держит сервисы независимыми (заявки не знают о существовании уведомлений в принципе).
Рекомендации:
**Subject naming:**
```
orders.request.created
orders.request.status_changed
orders.request.cancelled
```
**Формат события (envelope):**
```json
{
"event_id": "uuid", // для идемпотентности/дедупликации
"event_type": "orders.request.status_changed",
"version": 1,
"occurred_at": "2026-09-22T09:00:00Z",
"aggregate_id": "request-uuid",
"actor_id": "user-uuid",
"payload": {
"status": "approved",
"recipient_user_id": "user-uuid"
// минимально необходимые поля для рендера, не вся заявка целиком
}
}
```
Важные моменты:
- **event_id** обязателен — по нему notification-сервис дедуплицирует (at-least-once доставка в NATS/JetStream — норма, а не баг).
- **"Полу-жирные" события**: не гоняйте туда-сюда синхронные запросы за деталями заявки при каждом уведомлении (это связывает сервисы и роняет доступность notification-сервиса при падении orders). Но и не пихайте в событие всю заявку целиком. Кладите ровно то, что нужно для шаблонов (статус, имя, сумма — по необходимости), остальное — по `aggregate_id` через API orders, если это редкий кейс.
- **Версионирование** payload'а (`version`) — пригодится, когда формат события будет меняться.
- Используйте **JetStream** (persistent streams), а не core NATS, чтобы события не терялись при недоступности notification-сервиса.
## Поток обработки в notification-сервисе
1. Consumer получает событие `orders.request.status_changed`.
2. По `event_type` находит маппинг → категорию уведомления (это конфигурация внутри notification-сервиса, orders о ней не знает).
3. Смотрит подписки пользователя по этой категории → определяет каналы (push/email).
4. Рендерит шаблон, создаёт запись в `notifications` со статусом `queued`.
5. Отправляет через провайдера (FCM/APNs, SMTP/SES и т.д.), обновляет статус (`sent`/`failed`/`delivered`).
6. Опционально публикует обратное событие `notifications.sent` / `notifications.failed` — полезно для аналитики/аудита, но orders-сервису это обычно не нужно подписывать.
## Итого — граница ответственности
| Данные | Где хранятся |
|---|---|
| Заявки | orders-сервис |
| Категории и каналы уведомлений | notification-сервис |
| Push-токены | notification-сервис |
| Настройки подписок пользователя | notification-сервис |
| Шаблоны и текст сообщений | notification-сервис |
| История/статусы отправленных уведомлений | notification-сервис |
| Событие через брокер | orders → notification, "тонкое" доменное событие с event_id |
Если понадобится — могу накидать примерную схему БД (таблицы notification-сервиса) или пример NATS consumer'а с dedup-логикой.
а как пуш токены появляются в сервисе уведомлений?
Клиентское приложение (мобильный/веб) регистрирует токен **напрямую в сервисе уведомлений**, минуя сервис заявок. Это нормальный паттерн — регистрация токена не имеет отношения к бизнес-логике заявок.
## Как это выглядит на практике
1. Клиент получает push-токен от FCM/APNs при старте приложения или логине.
2. Клиент шлёт его в notification-сервис через его собственный API (обычно REST/gRPC endpoint), не через orders-сервис:
```
POST /api/v1/devices
{
"user_id": "...",
"token": "...",
"platform": "ios" | "android" | "web"
}
```
3. Notification-сервис делает upsert в свою таблицу `device_tokens`.
## Нюансы, которые стоит сразу заложить
**Аутентификация запроса.** Notification-сервис должен уметь проверить, что `user_id` в запросе — это действительно тот пользователь, который прислал токен (JWT/сессия), иначе кто угодно сможет зарегистрировать чужой токен на себя. Значит, notification-сервису нужен доступ к тому же механизму аутентификации, что и остальным API (общий auth middleware/gateway, проверка JWT и т.д.) — это отдельный вопрос архитектуры, но обычно решается через API Gateway, который уже провалидировал токен до того, как запрос дошёл до сервиса.
**Один пользователь — несколько токенов.** Человек заходит с телефона и с двух браузеров — три токена, все живые. Ключ уникальности обычно `(user_id, token)`, а не `user_id` — просто добавляете новую запись, если токен новый.
**Протухание токенов.** FCM/APNs могут вернуть ошибку `Unregistered`/`InvalidToken` при попытке отправки — по этому ответу нужно удалять/помечать токен неактивным в `device_tokens`. Это внутренняя логика notification-сервиса, никак не завязанная на orders.
**Ре-регистрация при обновлении токена.** FCM/APNs периодически ротируют токены сами — клиент должен слать обновлённый токен заново тем же способом (upsert по старому+новому или просто upsert по user_id+platform, если храните один активный токен на платформу).
## А что насчёт удаления при логауте?
Тоже прямой вызов клиента в notification-сервис:
```
DELETE /api/v1/devices/{token}
```
чтобы не слать пуши на токен вышедшего пользователя.
## Итого
Путь такой: **клиент → API Gateway (auth) → notification-сервис**, без участия orders-сервиса и без NATS — это синхронная операция регистрации устройства, а не бизнес-событие. NATS остаётся только для доменных событий вроде "заявка изменила статус".
Если у вас пока нет отдельного API Gateway / auth-сервиса — как сейчас происходит аутентификация между клиентом и бэкендом в целом?
есть монолит в котором реализована проверка пользователя
Значит нужно решить, как notification-сервис (и вообще любой новый сервис) будет проверять, что запрос от легитимного пользователя, не дублируя логику авторизации монолита.
## Варианты
**1. JWT, который монолит уже выдаёт клиенту**
Если монолит при логине выдаёт клиенту JWT (даже если сессии где-то ещё используются внутри) — самый простой путь. Notification-сервис получает токен в заголовке, сам проверяет подпись (публичным ключом/секретом монолита) и достаёт `user_id` из claims, без похода в монолит по сети.
```
Client → notification-service
Authorization: Bearer <jwt from monolith>
```
Плюс: notification-сервис не зависит от доступности монолита на каждый запрос.
Минус: нужен способ отзывать токены (revocation list) и следить за синхронизацией ключей.
**2. Если сейчас сессии (cookie), а не JWT**
Тогда варианты:
- Монолит отдаёт эндпоинт **интроспекции**: `POST /internal/auth/verify` с cookie/session-id → возвращает `user_id`. Notification-сервис дергает его синхронно на каждый запрос регистрации токена (это редкая операция — регистрация устройства, не хайлоад, так что синхронный вызов ок).
- Либо ставите перед всеми сервисами (включая монолит) общий **API Gateway/reverse proxy**, который сам проверяет сессию/JWT и прокидывает дальше уже проверенный `X-User-Id` в заголовке. Это правильный долгосрочный путь при переходе на микросервисы, но требует инфраструктурных изменений сейчас.
## Что практичнее на вашем этапе
Раз у вас уже выделяется notification-сервис как первый шаг из монолита, я бы не городил Gateway ради одного сервиса. Практичнее:
1. Уточнить, **что уже выдаёт монолит клиенту** — JWT или cookie-сессию.
2. Если JWT — notification-сервис верифицирует его сам (шарите secret/public key).
3. Если сессия — добавить в монолите один internal-эндпоинт интроспекции токена, notification-сервис ходит туда синхронно только на операции регистрации/удаления устройства (низкая частота, некритично к задержке).
4. NATS-события от orders в этой части не участвуют — это чисто клиент↔notification-сервис путь.
Что у вас сейчас — JWT или серверные сессии?
jwt
С JWT всё просто — notification-сервису вообще не нужно ходить в монолит по сети для проверки.
## Как это работает
1. Монолит подписывает JWT своим ключом при логине (симметричным HMAC-секретом или, лучше, приватным ключом при RS256/ES256).
2. Notification-сервис получает тот же токен от клиента в заголовке `Authorization: Bearer <jwt>`.
3. Notification-сервис **сам** проверяет подпись и достаёт `user_id` (и что нужно) из claims — без обращения к монолиту.
```
POST /api/v1/devices
Authorization: Bearer <jwt>
{
"token": "...",
"platform": "ios"
}
```
`user_id` при этом берётся **из claims токена**, а не из тела запроса — иначе любой авторизованный пользователь сможет зарегистрировать токен на чужой `user_id`, просто подставив его в JSON. Тело запроса содержит только то, что клиент не может подделать в свою пользу (device token, platform), а personal identity всегда из верифицированного JWT.
## Что нужно расшарить между монолитом и notification-сервисом
- **Если HMAC (HS256)** — общий секрет. Хранить в секрет-менеджере (Vault/K8s secret/SSM), не в коде, раздать обоим сервисам.
- **Если RSA/EC (RS256/ES256)** — гораздо лучше для мультисервисной архитектуры: монолит держит приватный ключ и подписывает, notification-сервис знает только **публичный** ключ и может только проверять, но не подделывать токены. Публичный ключ можно даже раздавать через `/.well-known/jwks.json` эндпоинт на монолите — тогда новые сервисы просто подтягивают его при старте/по кэшу, без ручной синхронизации секретов.
Если сейчас HS256 — при росте числа сервисов стоит подумать о переходе на RS256/ES256 именно из соображений "каждому новому сервису для чтения не нужно доверять секрет, которым можно подписывать".
## Отзыв токенов (revocation)
Раз проверка теперь оффлайн (без похода в монолит), классическая проблема JWT — как обработать логаут/бан до истечения `exp`. Варианты, если это важно для вас:
- короткий TTL access-токена (5-15 мин) + refresh-токен — тогда отозванный доступ отваливается быстро сам по себе;
- либо блэклист отозванных `jti` в Redis, который проверяют все сервисы (но это уже добавляет сетевой поход и связанность).
Если у вас логаут не критичен к мгновенности (например, там просто короткий TTL) — можно не усложнять и оставить чисто оффлайн-проверку.
Какой у вас алгоритм подписи сейчас — HS256 или уже RS256/подобный?
а смс отправка, в сервисе уведомлений хранить телефоны
Да, по той же логике, что и с email — если SMS станет одним из каналов, номера телефонов хранятся в notification-сервисе, а не запрашиваются синхронно у монолита при каждой отправке.
## Варианты получения номера
**1. Дублирование с синхронизацией (рекомендую)**
- При регистрации/изменении профиля в монолите публикуется событие `user.profile_updated` (или `user.phone_changed`) в NATS.
- Notification-сервис подписывается, обновляет свою локальную копию `user_contacts (user_id, phone, email, updated_at)`.
- При отправке SMS notification-сервис берёт номер из своей БД — без синхронного похода в монолит.
Плюс: полная развязка, отправка не зависит от доступности монолита.
Минус: нуженource-of-truth-событие на каждое изменение контактных данных в монолите (если такого механизма пока нет — придётся добавить).
**2. Номер приходит вместе с доменным событием**
Если SMS шлётся не "вообще", а в ответ на конкретное действие (например, подтверждение заявки), можно не хранить номер вовсе, а прокидывать его в payload события:
```json
{
"event_type": "orders.request.status_changed",
"payload": {
"recipient_user_id": "...",
"recipient_phone": "+79991234567"
}
}
```
Плюс: не нужен отдельный механизм синхронизации контактов.
Минус: монолит должен знать, что нужно положить телефон в событие (небольшая связанность "по данным", не по вызовам) — но это всё равно значительно лучше, чем синхронный HTTP-запрос за телефоном на каждую отправку.
## Что выбрать
Если контактные данные меняются редко и на них завязано несколько каналов (email, sms, push-уведомления с персонализацией по имени) — вариант 1 чище: заводите таблицу контактов в notification-сервисе и синхронизируете событием при изменении профиля.
Если SMS — разовый узкий кейс (например, только "код подтверждения" или "статус заявки") — вариант 2 проще и не требует лишней инфраструктуры синхронизации.
## Общая таблица получается такая
| Данные | Где хранится | Как приходят |
|---|---|---|
| Push-токены | notification-сервис | напрямую от клиента, по своему API |
| Email/телефон | notification-сервис (копия) | событие `user.profile_updated` из монолита, либо в payload доменного события |
| Категории/каналы/шаблоны | notification-сервис | конфигурация сервиса |
| Заявки | монолит/orders | — |
Одна важная деталь: раз номера и email — персональные данные (ПДн), при дублировании в notification-сервисе стоит сразу продумать их шифрование at rest и политику хранения/удаления (например, по GDPR/152-ФЗ, если это применимо к вашему проекту) — то есть не просто copy-paste, а с тем же уровнем защиты, что и в монолите.
Возникает SMS-канал уже сейчас, или это на перспективу — просто закладываете архитектуру с запасом?
перспектива
Тогда не стоит сейчас городить полноценную синхронизацию контактов ради канала, которого ещё нет.
## Что делать сейчас
Заложите на будущее минимально — не более:
1. **Таблицу `user_contacts` сразу спроектируйте с запасом** (даже если пока заполняете только то, что нужно для push/email):
```sql
user_contacts (
user_id UUID PRIMARY KEY,
email TEXT,
phone TEXT,
updated_at TIMESTAMPTZ
)
```
Так при добавлении SMS не придётся переделывать схему — просто начнёте заполнять `phone`.
2. **Общий механизм синхронизации делайте один — для всех контактных полей сразу**, а не отдельно под email и отдельно под будущий phone. Т.е. событие `user.profile_updated` с обоими полями в payload:
```json
{
"event_type": "user.profile_updated",
"payload": {
"user_id": "...",
"email": "...",
"phone": "..."
}
}
```
Notification-сервис просто апсертит всё, что пришло, не заботясь о том, используется ли `phone` уже сейчас.
3. **Канал `sms` заведите как enum/значение в справочнике каналов уже сейчас**, даже неактивное — не придётся трогать миграции и код маппинга категорий→каналов, когда он реально понадобится.
## Чего делать не стоит
- Не подключайте SMS-провайдера (Twilio/SMS.ru/др.) заранее — это чистый YAGNI, только усложнит деплой и конфиги без пользы.
- Не делайте отдельную инфраструктуру верификации телефонов (подтверждение номера) заранее — это отдельная фича, привязывайте её к моменту, когда SMS реально понадобится.
- Не усложняйте событие `orders.request.status_changed` полем `recipient_phone`, если SMS туда пока не шлётся — лишнее поле без потребителя.
Итог: с минимальными изменениями в схеме и в контракте события `user.profile_updated` вы ничего не переделываете, когда дойдёте до SMS — просто добавляете провайдера и обработчик канала. Остальное — по факту появления реальной необходимости.
получается настройки пользователя по рассылке сообщений тоже хранить в сервисе уведомлений
Да, верно — это тоже часть зоны ответственности notification-сервиса: настройки касаются доставки уведомлений, а не бизнес-логики монолита.
## Схема хранения
```sql
notification_preferences (
user_id UUID,
category TEXT, -- 'request_status_changed', 'request_approved', ...
channel TEXT, -- 'push', 'email', 'sms'
enabled BOOLEAN DEFAULT true,
updated_at TIMESTAMPTZ,
PRIMARY KEY (user_id, category, channel)
)
```
Дефолт — если записи нет, считаете `enabled = true` (или прописываете дефолты явно при создании пользователя/по первому событию — так проще для аналитики "кто что выключил").
## Как пользователь меняет настройки
Тут два пути, зависит от того, как у вас организован фронтенд/UI:
**1. Фронт стучится напрямую в notification-сервис**
```
PUT /api/v1/preferences
Authorization: Bearer <jwt>
{
"category": "request_approved",
"channel": "email",
"enabled": false
}
```
Тот же принцип авторизации, что и с push-токенами — JWT валидируется notification-сервисом самостоятельно, `user_id` берётся из claims.
Плюс: чисто, без лишних хопов.
Минус: если UI настроек живёт внутри монолитного приложения (например, единая страница профиля), фронту нужно знать про два разных бэкенда — обычно решается тем, что API Gateway/reverse-proxy просто роутит `/api/notifications/*` в notification-сервис, а остальное — в монолит. Для фронта это прозрачно, один домен.
**2. Монолит проксирует запрос**
Если пока нет общего гейтвея и не хочется светить notification-сервис наружу отдельным доменом — монолит принимает запрос от фронта и просто форвардит его в notification-сервис (server-to-server, с сервисным токеном или доверенной сетью). Проще для фронта прямо сейчас, но добавляет лишний хоп и точку отказа.
Если планируете API Gateway в перспективе (вы уже говорили, что growth в сторону микросервисов) — я бы сразу шёл по варианту 1, чтобы не переделывать потом.
## Что ещё сюда относится
- **Глобальный opt-out** — отдельная настройка `user_id → all_notifications_disabled` полезна для "отписаться от всего" (часто требуется по закону для email-рассылок — unsubscribe-ссылка).
- **Read-модель для быстрого чтения** — так как эта таблица читается на **каждое** событие из NATS (проверка "слать ли"), убедитесь что там индекс по `user_id`, и в целом это должна быть быстрая точка доступа (можно и в Redis кэшировать активные подписки, если объёмы большие).
Итого зона ответственности notification-сервиса теперь выглядит так:
| Сущность | Таблица |
|---|---|
| Push-токены | `device_tokens` |
| Контакты (email/phone) | `user_contacts` |
| Настройки подписок | `notification_preferences` |
| Категории/каналы | справочник/enum |
| Шаблоны | `templates` |
| Лог отправок | `notifications` |
Всё, что касается "кому, как и хочет ли получать" — здесь, и нигде больше. Монолит/orders об этом не знает вообще, только публикует факт события.
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2023.full.d.ts","./node_modules/reflect-metadata/index.d.ts","./node_modules/@nestjs/common/decorators/core/bind.decorator.d.ts","./node_modules/@nestjs/common/interfaces/abstract.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller-metadata.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller.interface.d.ts","./node_modules/@nestjs/common/interfaces/features/arguments-host.interface.d.ts","./node_modules/@nestjs/common/interfaces/exceptions/exception-filter.interface.d.ts","./node_modules/rxjs/dist/types/internal/subscription.d.ts","./node_modules/rxjs/dist/types/internal/subscriber.d.ts","./node_modules/rxjs/dist/types/internal/operator.d.ts","./node_modules/rxjs/dist/types/internal/observable.d.ts","./node_modules/rxjs/dist/types/internal/types.d.ts","./node_modules/rxjs/dist/types/internal/operators/audit.d.ts","./node_modules/rxjs/dist/types/internal/operators/audittime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffer.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffercount.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertoggle.d.tsLine truncated
{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.dom.asynciterable.d.ts","./node_modules/typescript/lib/lib.webworker.importscripts.d.ts","./node_modules/typescript/lib/lib.scripthost.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2025.float16.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/typescript/lib/lib.es2023.full.d.ts","./node_modules/reflect-metadata/index.d.ts","./node_modules/@nestjs/common/decorators/core/bind.decorator.d.ts","./node_modules/@nestjs/common/interfaces/abstract.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller-metadata.interface.d.ts","./node_modules/@nestjs/common/interfaces/controllers/controller.interface.d.ts","./node_modules/@nestjs/common/interfaces/features/arguments-host.interface.d.ts","./node_modules/@nestjs/common/interfaces/exceptions/exception-filter.interface.d.ts","./node_modules/rxjs/dist/types/internal/subscription.d.ts","./node_modules/rxjs/dist/types/internal/subscriber.d.ts","./node_modules/rxjs/dist/types/internal/operator.d.ts","./node_modules/rxjs/dist/types/internal/observable.d.ts","./node_modules/rxjs/dist/types/internal/types.d.ts","./node_modules/rxjs/dist/types/internal/operators/audit.d.ts","./node_modules/rxjs/dist/types/internal/operators/audittime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffer.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffercount.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertime.d.ts","./node_modules/rxjs/dist/types/internal/operators/buffertoggle.d.tsLine truncated