notification
This commit is contained in:
1 parent
382cafa152
commit
60391ee123
26 files changed
+1077
-14
No files matched your search
@@ -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[]>;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { NotificationMessage } from '../../../../generated/prisma/client.js';
|
||||
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
|
||||
import type { NotificationStatusValue } from '../value-objects/notification-status.vo.js';
|
||||
|
||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||
export const NOTIFICATION_MESSAGE_REPOSITORY = Symbol('NOTIFICATION_MESSAGE_REPOSITORY');
|
||||
|
||||
export interface INotificationMessageRepository {
|
||||
create(params: {
|
||||
userId: string;
|
||||
channel: NotificationChannelValue;
|
||||
subject?: string;
|
||||
content: string;
|
||||
}): Promise<NotificationMessage>;
|
||||
markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void>;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { NotificationPreference } from '../../../../generated/prisma/client.js';
|
||||
import type { NotificationChannelValue } from '../value-objects/notification-channel.vo.js';
|
||||
|
||||
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||
export const NOTIFICATION_PREFERENCE_REPOSITORY = Symbol('NOTIFICATION_PREFERENCE_REPOSITORY');
|
||||
|
||||
export interface INotificationPreferenceRepository {
|
||||
findByUserId(userId: string): Promise<NotificationPreference[]>;
|
||||
set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const NOTIFICATION_CHANNELS = ['EMAIL', 'PUSH'] as const;
|
||||
|
||||
export type NotificationChannelValue = (typeof NOTIFICATION_CHANNELS)[number];
|
||||
|
||||
export class NotificationChannel {
|
||||
private constructor(private readonly _value: NotificationChannelValue) {}
|
||||
|
||||
static create(value: string): NotificationChannel {
|
||||
if (!NOTIFICATION_CHANNELS.includes(value as NotificationChannelValue)) {
|
||||
throw new Error(`Недопустимый канал уведомлений: "${value}". Разрешены: ${NOTIFICATION_CHANNELS.join(', ')}`);
|
||||
}
|
||||
|
||||
return new NotificationChannel(value as NotificationChannelValue);
|
||||
}
|
||||
|
||||
get value(): NotificationChannelValue {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: NotificationChannel): boolean {
|
||||
return this._value === other.value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const NOTIFICATION_STATUSES = ['PENDING', 'SENT', 'FAILED'] as const;
|
||||
|
||||
export type NotificationStatusValue = (typeof NOTIFICATION_STATUSES)[number];
|
||||
|
||||
export class NotificationStatus {
|
||||
private constructor(private readonly _value: NotificationStatusValue) {}
|
||||
|
||||
static create(value: string): NotificationStatus {
|
||||
if (!NOTIFICATION_STATUSES.includes(value as NotificationStatusValue)) {
|
||||
throw new Error(`Недопустимый статус уведомления: "${value}". Разрешены: ${NOTIFICATION_STATUSES.join(', ')}`);
|
||||
}
|
||||
|
||||
return new NotificationStatus(value as NotificationStatusValue);
|
||||
}
|
||||
|
||||
static pending(): NotificationStatus {
|
||||
return new NotificationStatus('PENDING');
|
||||
}
|
||||
|
||||
get value(): NotificationStatusValue {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
equals(other: NotificationStatus): boolean {
|
||||
return this._value === other.value;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||
import type { NotificationDeviceToken } from '../../../../../../generated/prisma/client.js';
|
||||
import type { IDeviceTokenRepository } from '../../../../domain/repositories/device-token.repository.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceTokenPrismaRepository implements IDeviceTokenRepository {
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
async upsert(userId: string, token: string, platform: string): Promise<void> {
|
||||
await this.prisma.notificationDeviceToken.upsert({
|
||||
where: { userId_token: { userId, token } },
|
||||
create: { userId, token, platform },
|
||||
update: { platform },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: string, token: string): Promise<void> {
|
||||
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token } });
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
|
||||
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||
import type { NotificationMessage } from '../../../../../../generated/prisma/client.js';
|
||||
import type { INotificationMessageRepository } from '../../../../domain/repositories/notification-message.repository.interface.js';
|
||||
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
|
||||
import type { NotificationStatusValue } from '../../../../domain/value-objects/notification-status.vo.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationMessagePrismaRepository implements INotificationMessageRepository {
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
create(params: {
|
||||
userId: string;
|
||||
channel: NotificationChannelValue;
|
||||
subject?: string;
|
||||
content: string;
|
||||
}): Promise<NotificationMessage> {
|
||||
return this.prisma.notificationMessage.create({ data: params });
|
||||
}
|
||||
|
||||
async markStatus(id: string, status: NotificationStatusValue, error?: string): Promise<void> {
|
||||
await this.prisma.notificationMessage.update({
|
||||
where: { id },
|
||||
data: { status, error, sentAt: status === 'SENT' ? new Date() : undefined },
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||
import type { NotificationPreference } from '../../../../../../generated/prisma/client.js';
|
||||
import type { INotificationPreferenceRepository } from '../../../../domain/repositories/notification-preference.repository.interface.js';
|
||||
import type { NotificationChannelValue } from '../../../../domain/value-objects/notification-channel.vo.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationPreferencePrismaRepository implements INotificationPreferenceRepository {
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
findByUserId(userId: string): Promise<NotificationPreference[]> {
|
||||
return this.prisma.notificationPreference.findMany({ where: { userId } });
|
||||
}
|
||||
|
||||
async set(userId: string, channel: NotificationChannelValue, enabled: boolean): Promise<void> {
|
||||
await this.prisma.notificationPreference.upsert({
|
||||
where: { userId_channel: { userId, channel } },
|
||||
create: { userId, channel, enabled },
|
||||
update: { enabled },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import EmailSender from '../../../../lib/email-sender.js';
|
||||
import { PrismaAdapter } from '../../../../common/prisma-adapter/prisma.adapter.js';
|
||||
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
|
||||
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
||||
|
||||
@Injectable()
|
||||
export class EmailChannelSender implements NotificationChannelSender {
|
||||
readonly channel: NotificationChannelValue = 'EMAIL';
|
||||
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: params.userId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
await EmailSender.getTransporter().sendMail({
|
||||
to: user.email,
|
||||
subject: params.subject ?? 'Уведомление',
|
||||
text: params.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import type { NotificationChannelSender } from '../../domain/channel-sender.interface.js';
|
||||
import type { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
||||
import {
|
||||
DEVICE_TOKEN_REPOSITORY,
|
||||
type IDeviceTokenRepository,
|
||||
} from '../../domain/repositories/device-token.repository.interface.js';
|
||||
|
||||
/**
|
||||
* Провайдер push-доставки (FCM/APNs) пока не подключён — отправка логируется.
|
||||
* Токены уже читаются из БД, так что при подключении провайдера
|
||||
* меняется только тело send(), остальной пайплайн трогать не нужно.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushChannelSender implements NotificationChannelSender {
|
||||
private readonly logger = new Logger(PushChannelSender.name);
|
||||
readonly channel: NotificationChannelValue = 'PUSH';
|
||||
|
||||
constructor(@Inject(DEVICE_TOKEN_REPOSITORY) private readonly deviceTokens: IDeviceTokenRepository) {}
|
||||
|
||||
async send(params: { userId: string; content: string }): Promise<void> {
|
||||
const tokens = await this.deviceTokens.findByUserId(params.userId);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`No device tokens registered for user ${params.userId}`);
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Push provider is not configured yet, skipping actual delivery to ${tokens.length} device(s): ${params.content}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||
import { DEVICE_TOKEN_REPOSITORY } from './domain/repositories/device-token.repository.interface.js';
|
||||
import { NOTIFICATION_PREFERENCE_REPOSITORY } from './domain/repositories/notification-preference.repository.interface.js';
|
||||
import { NOTIFICATION_MESSAGE_REPOSITORY } from './domain/repositories/notification-message.repository.interface.js';
|
||||
import { NOTIFICATION_CHANNEL_SENDERS } from './domain/channel-sender.interface.js';
|
||||
import { DeviceTokenPrismaRepository } from './infrastructure/persistence/prisma/repositories/device-token.prisma-repository.js';
|
||||
import { NotificationPreferencePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-preference.prisma-repository.js';
|
||||
import { NotificationMessagePrismaRepository } from './infrastructure/persistence/prisma/repositories/notification-message.prisma-repository.js';
|
||||
import { EmailChannelSender } from './infrastructure/senders/email-channel.sender.js';
|
||||
import { PushChannelSender } from './infrastructure/senders/push-channel.sender.js';
|
||||
import { NotificationsService } from './application/services/notifications.service.js';
|
||||
import { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
|
||||
import { NotificationController } from './presentation/controllers/notification.controller.js';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [NotificationController],
|
||||
providers: [
|
||||
{
|
||||
// Domain видит только интерфейс, конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||
provide: DEVICE_TOKEN_REPOSITORY,
|
||||
useClass: DeviceTokenPrismaRepository,
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_PREFERENCE_REPOSITORY,
|
||||
useClass: NotificationPreferencePrismaRepository,
|
||||
},
|
||||
{
|
||||
provide: NOTIFICATION_MESSAGE_REPOSITORY,
|
||||
useClass: NotificationMessagePrismaRepository,
|
||||
},
|
||||
EmailChannelSender,
|
||||
PushChannelSender,
|
||||
{
|
||||
// Диспетчер обращается к списку каналов, не завязываясь на конкретные классы
|
||||
provide: NOTIFICATION_CHANNEL_SENDERS,
|
||||
useFactory: (email: EmailChannelSender, push: PushChannelSender) => [email, push],
|
||||
inject: [EmailChannelSender, PushChannelSender],
|
||||
},
|
||||
NotificationsService,
|
||||
NotificationDispatcherService,
|
||||
],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
Reference in new issue
Block a user