1 parent
5ad0ecdf4c
commit
8d02d25da0
12 files changed
+1765
-35
No files matched your search
@@ -11,3 +11,5 @@ coverage
|
|||||||
tsconfig.build.tsbuildinfo
|
tsconfig.build.tsbuildinfo
|
||||||
src/generated
|
src/generated
|
||||||
storage
|
storage
|
||||||
|
configs
|
||||||
|
service-account.json
|
||||||
@@ -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
|
||||||
Generated
+1601
-22
File diff suppressed because it is too large.
Load diff
@@ -42,6 +42,7 @@
|
|||||||
"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.1.1",
|
||||||
|
"firebase-admin": "^14.5.0",
|
||||||
"graphql": "^16.14.2",
|
"graphql": "^16.14.2",
|
||||||
"nodemailer": "^10.0.0",
|
"nodemailer": "^10.0.0",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+4
@@ -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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user