firebase messaging
Gitea Actions / Deploy (push) Successful in 2m1s

This commit is contained in:
vadim committed 2026-10-16 16:51:55 +03:00
1 parent 5ad0ecdf4c
commit 8d02d25da0
12 files changed
+1765 -35

No files matched your search

+2
View File
@@ -11,3 +11,5 @@ coverage
tsconfig.build.tsbuildinfo
src/generated
storage
configs
service-account.json
+2
View File
@@ -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
+1601 -22
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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",
+17 -5
View File
@@ -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;
}
@@ -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;
}