1 parent
5ad0ecdf4c
commit
8d02d25da0
12 files changed
+1766
-36
No files matched your search
@@ -11,3 +11,5 @@ coverage
|
||||
tsconfig.build.tsbuildinfo
|
||||
src/generated
|
||||
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)
|
||||
/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-validator": "^0.15.1",
|
||||
"file-type": "^22.1.1",
|
||||
"firebase-admin": "^14.5.0",
|
||||
"graphql": "^16.14.2",
|
||||
"nodemailer": "^10.0.0",
|
||||
"pg": "^8.23.0",
|
||||
|
||||
@@ -73,10 +73,22 @@ in-process pub/sub уже используется в issue-модуле
|
||||
- `EmailChannelSender` — берёт email пользователя напрямую из таблицы `user`
|
||||
(общая БД, синхронный поход не нужен) и шлёт через существующий
|
||||
`EmailSender`/nodemailer (`src/lib/email-sender.ts`).
|
||||
- `PushChannelSender` — читает токены устройств из `notification_device_token`,
|
||||
но сама отправка — заглушка (лог вместо реального вызова). Провайдер
|
||||
(FCM/APNs) ещё не подключён; когда появится, меняется только тело
|
||||
`send()`, остальной пайплайн — нет.
|
||||
- `PushChannelSender` — читает токены устройств (Firebase Installation ID)
|
||||
из `notification_device_token` и шлёт через Firebase Cloud Messaging
|
||||
(`firebase-admin`, `sendEachForMulticast`, `subject` → title, `content` → body).
|
||||
Токены, которые 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
|
||||
|
||||
@@ -90,6 +102,7 @@ in-process pub/sub уже используется в issue-модуле
|
||||
| `DELETE` | `/device-tokens` | `{ token }` | Удаляет push-токен (токен в теле, не в URL — может содержать небезопасные для path символы) |
|
||||
| `GET` | `/preferences` | — | Список каналов (`EMAIL`, `PUSH`) с эффективным `enabled` |
|
||||
| `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` передаются вызывающим кодом как есть.
|
||||
- SMS-канала — при необходимости добавляется как ещё одно значение в
|
||||
`NOTIFICATION_CHANNELS` + `SmsChannelSender`, без изменений в остальном коде.
|
||||
@@ -7,5 +7,7 @@ export interface IDeviceTokenRepository {
|
||||
/** Регистрирует токен устройства, обновляет запись при повторной регистрации того же токена */
|
||||
upsert(userId: string, token: string, platform: string): Promise<void>;
|
||||
remove(userId: string, token: string): Promise<void>;
|
||||
/** Удаляет сразу несколько токенов пользователя, например протухшие после ответа FCM */
|
||||
removeMany(userId: string, tokens: string[]): Promise<void>;
|
||||
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 } });
|
||||
}
|
||||
|
||||
async removeMany(userId: string, tokens: string[]): Promise<void> {
|
||||
await this.prisma.notificationDeviceToken.deleteMany({ where: { userId, token: { in: tokens } } });
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<NotificationDeviceToken[]> {
|
||||
return this.prisma.notificationDeviceToken.findMany({ where: { userId } });
|
||||
}
|
||||
|
||||
@@ -1,31 +1,79 @@
|
||||
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 { NotificationChannelValue } from '../../domain/value-objects/notification-channel.vo.js';
|
||||
import {
|
||||
DEVICE_TOKEN_REPOSITORY,
|
||||
type IDeviceTokenRepository,
|
||||
} 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) пока не подключён — отправка логируется.
|
||||
* Токены уже читаются из БД, так что при подключении провайдера
|
||||
* меняется только тело send(), остальной пайплайн трогать не нужно.
|
||||
* Отправка push через Firebase Cloud Messaging. В notification_device_token.token
|
||||
* хранится Firebase Installation ID (FID) устройства.
|
||||
* Отправка считается успешной, если сообщение принято FCM хотя бы для одного устройства.
|
||||
*/
|
||||
@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) {}
|
||||
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> {
|
||||
const tokens = await this.deviceTokens.findByUserId(params.userId);
|
||||
async send(params: { userId: string; subject?: string; content: string }): Promise<void> {
|
||||
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) {
|
||||
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}`,
|
||||
);
|
||||
let successCount = 0;
|
||||
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 { EmailChannelSender } from './infrastructure/senders/email-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 { NotificationDispatcherService } from './application/services/notification-dispatcher.service.js';
|
||||
import { NotificationController } from './presentation/controllers/notification.controller.js';
|
||||
@@ -30,6 +31,7 @@ import { NotificationController } from './presentation/controllers/notification.
|
||||
provide: NOTIFICATION_MESSAGE_REPOSITORY,
|
||||
useClass: NotificationMessagePrismaRepository,
|
||||
},
|
||||
firebaseMessagingProvider,
|
||||
EmailChannelSender,
|
||||
PushChannelSender,
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NotificationsService } from '../../application/services/notifications.s
|
||||
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 { SendTestPushDto } from '../dto/send-test-push.dto.js';
|
||||
import { NotificationChannel } from '../../domain/value-objects/notification-channel.vo.js';
|
||||
|
||||
@Controller('notifications')
|
||||
@@ -48,6 +49,21 @@ export class NotificationController {
|
||||
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-слоя */
|
||||
private parseChannel(value: string) {
|
||||
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