add push settings

This commit is contained in:
lendry
2026-07-07 13:58:01 +03:00
parent 1bd95fa99e
commit 57925fb2c4
25 changed files with 3036 additions and 1756 deletions

View File

@@ -32,6 +32,8 @@ import { AppReleaseService } from './domain/app-release.service';
import { FamilyService } from './domain/family.service';
import { MediaService } from './domain/media.service';
import { NotificationsService } from './domain/notifications.service';
import { PushDeviceService } from './domain/push-device.service';
import { FirebasePushService } from './infra/firebase-push.service';
import { ChatService } from './domain/chat.service';
import { PresenceService } from './domain/presence.service';
import { NotificationPublisherService } from './infra/notification-publisher.service';
@@ -93,9 +95,11 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
FamilyService,
MediaService,
NotificationsService,
PushDeviceService,
ChatService,
PresenceService,
NotificationPublisherService,
FirebasePushService,
MinioService,
LdapClientService,
MessagingService,

View File

@@ -22,10 +22,12 @@ import { AppReleaseService } from './app-release.service';
import { FamilyService } from './family.service';
import { MediaService } from './media.service';
import { NotificationsService } from './notifications.service';
import { PushDeviceService } from './push-device.service';
import { ChatService } from './chat.service';
import { PresenceService } from './presence.service';
import { TotpService } from './totp.service';
import { MessagingService } from '../infra/messaging.service';
import { FirebasePushService } from '../infra/firebase-push.service';
import { BotFatherService } from './bot/bot-father.service';
import { BotApiService } from './bot/bot-api.service';
import { BotInboundService } from './bot/bot-inbound.service';
@@ -53,9 +55,11 @@ export class AuthGrpcController {
private readonly family: FamilyService,
private readonly media: MediaService,
private readonly notifications: NotificationsService,
private readonly pushDevices: PushDeviceService,
private readonly chat: ChatService,
private readonly presence: PresenceService,
private readonly messaging: MessagingService,
private readonly firebasePush: FirebasePushService,
private readonly totp: TotpService,
private readonly botFather: BotFatherService,
private readonly botApi: BotApiService,
@@ -651,6 +655,14 @@ export class AuthGrpcController {
return this.messaging.sendTestDelivery(channel, command.target.trim());
}
@GrpcMethod('SettingsService', 'TestFirebasePush')
async testFirebasePush(command: { userId: string }) {
if (!command.userId?.trim()) {
throw new BadRequestException('Не указан пользователь для тестовой отправки');
}
return this.firebasePush.sendTestToUser(command.userId.trim());
}
@GrpcMethod('ProfileService', 'GetProfile')
getProfile(command: { userId: string }) {
return this.profile.getProfile(command.userId);
@@ -1092,6 +1104,16 @@ export class AuthGrpcController {
return this.notifications.deleteAll(command.userId);
}
@GrpcMethod('NotificationsService', 'RegisterPushToken')
registerPushToken(command: { userId: string; token: string; platform: string; deviceLabel?: string }) {
return this.pushDevices.register(command.userId, command.token, command.platform, command.deviceLabel);
}
@GrpcMethod('NotificationsService', 'UnregisterPushToken')
unregisterPushToken(command: { userId: string; token: string }) {
return this.pushDevices.unregister(command.userId, command.token);
}
@GrpcMethod('ChatService', 'ListRooms')
listChatRooms(command: { userId: string; groupId: string }) {
return this.chat.listRooms(command.userId, command.groupId);

View File

@@ -1,12 +1,14 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
import { NotificationPublisherService } from '../infra/notification-publisher.service';
import { FirebasePushService } from '../infra/firebase-push.service';
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly publisher: NotificationPublisherService
private readonly publisher: NotificationPublisherService,
private readonly firebasePush: FirebasePushService
) {}
async create(
@@ -38,6 +40,19 @@ export class NotificationsService {
...(payload ?? {})
}
});
void this.firebasePush
.sendToUser({
userId,
type,
title,
message,
payload: {
notificationId: notification.id,
...(payload ?? {})
}
})
.catch(() => undefined);
}
return this.toResponse(notification);

View File

@@ -0,0 +1,75 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { PushPlatform } from '../generated/prisma/client';
import { PrismaService } from '../infra/prisma.service';
const VALID_PLATFORMS = new Set<string>(['WEB', 'ANDROID', 'IOS']);
@Injectable()
export class PushDeviceService {
constructor(private readonly prisma: PrismaService) {}
async register(userId: string, token: string, platform: string, deviceLabel?: string) {
const normalizedToken = token?.trim();
if (!normalizedToken) {
throw new BadRequestException('Укажите FCM-токен устройства');
}
const normalizedPlatform = (platform?.trim().toUpperCase() || 'WEB') as PushPlatform;
if (!VALID_PLATFORMS.has(normalizedPlatform)) {
throw new BadRequestException('Недопустимая платформа push-уведомлений');
}
const existingOwner = await this.prisma.pushDeviceToken.findUnique({
where: { token: normalizedToken }
});
if (existingOwner && existingOwner.userId !== userId) {
await this.prisma.pushDeviceToken.delete({ where: { token: normalizedToken } });
}
await this.prisma.pushDeviceToken.upsert({
where: { token: normalizedToken },
create: {
userId,
token: normalizedToken,
platform: normalizedPlatform,
deviceLabel: deviceLabel?.trim() || null
},
update: {
userId,
platform: normalizedPlatform,
deviceLabel: deviceLabel?.trim() || null
}
});
return { count: 1 };
}
async unregister(userId: string, token: string) {
const normalizedToken = token?.trim();
if (!normalizedToken) {
throw new BadRequestException('Укажите FCM-токен устройства');
}
const result = await this.prisma.pushDeviceToken.deleteMany({
where: { userId, token: normalizedToken }
});
return { count: result.count };
}
async listTokensForUser(userId: string) {
return this.prisma.pushDeviceToken.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' }
});
}
async removeTokens(tokens: string[]) {
if (!tokens.length) return { count: 0 };
const result = await this.prisma.pushDeviceToken.deleteMany({
where: { token: { in: tokens } }
});
return { count: result.count };
}
}

View File

@@ -111,6 +111,30 @@ export const DEFAULT_SYSTEM_SETTINGS = [
key: 'ADMIN_INSIGHTS_RETENTION_DAYS',
value: '7',
description: 'Срок хранения журналов входов, активности и переписок для админки (дни, 190). Старые записи удаляются автоматически'
},
{ key: 'FIREBASE_PUSH_ENABLED', value: 'false', description: 'Включить push-уведомления через Firebase Cloud Messaging (FCM)' },
{ key: 'FIREBASE_PROJECT_ID', value: '', description: 'ID проекта Firebase (project_id из консоли Firebase)' },
{
key: 'FIREBASE_CLIENT_EMAIL',
value: '',
description: 'Email сервисного аккаунта Firebase (client_email из JSON ключа)'
},
{
key: 'FIREBASE_PRIVATE_KEY',
value: '',
description: 'Приватный ключ сервисного аккаунта Firebase (private_key из JSON, с символами \\n)'
},
{ key: 'FIREBASE_WEB_API_KEY', value: '', description: 'Web API Key из настроек Firebase (публичный, для клиентского SDK)' },
{ key: 'FIREBASE_WEB_APP_ID', value: '', description: 'App ID веб-приложения Firebase (публичный)' },
{
key: 'FIREBASE_WEB_MESSAGING_SENDER_ID',
value: '',
description: 'Messaging Sender ID Firebase (публичный, для клиентского SDK)'
},
{
key: 'FIREBASE_WEB_VAPID_KEY',
value: '',
description: 'VAPID / Web Push certificate key pair из Firebase Cloud Messaging (публичный ключ)'
}
] as const;
@@ -119,7 +143,8 @@ const SECRET_SETTING_KEYS = new Set([
'EMAIL_SMTP_PASSWORD',
'SMS_API_KEY',
'SMS_PASSWORD',
'SMS_GATEWAY_PASSWORD'
'SMS_GATEWAY_PASSWORD',
'FIREBASE_PRIVATE_KEY'
]);
export const PUBLIC_SETTING_KEYS = [
@@ -133,7 +158,13 @@ export const PUBLIC_SETTING_KEYS = [
'MAINTENANCE_MODE',
'LDAP_ENABLED',
'LDAP_USE_LDAPS',
'PIN_LOCK_TIMEOUT_MINUTES'
'PIN_LOCK_TIMEOUT_MINUTES',
'FIREBASE_PUSH_ENABLED',
'FIREBASE_PROJECT_ID',
'FIREBASE_WEB_API_KEY',
'FIREBASE_WEB_APP_ID',
'FIREBASE_WEB_MESSAGING_SENDER_ID',
'FIREBASE_WEB_VAPID_KEY'
] as const;
@Injectable()

View File

@@ -0,0 +1,175 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { cert, deleteApp, getApps, initializeApp } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
import { SettingsService } from '../domain/settings.service';
import { PushDeviceService } from '../domain/push-device.service';
export interface FirebasePushPayload {
userId: string;
type: string;
title: string;
message: string;
payload?: Record<string, unknown>;
}
const SKIP_PUSH_TYPES = new Set(['otp_code']);
@Injectable()
export class FirebasePushService {
private readonly logger = new Logger(FirebasePushService.name);
private initializedProjectId: string | null = null;
constructor(
private readonly settings: SettingsService,
private readonly pushDevices: PushDeviceService
) {}
async sendToUser(notification: FirebasePushPayload) {
if (SKIP_PUSH_TYPES.has(notification.type)) {
return { sentCount: 0, skipped: true };
}
const config = await this.loadServerConfig();
if (!config) {
return { sentCount: 0, skipped: true };
}
const tokens = await this.pushDevices.listTokensForUser(notification.userId);
if (!tokens.length) {
return { sentCount: 0, skipped: true };
}
await this.ensureApp(config);
const data: Record<string, string> = {
type: notification.type,
title: notification.title,
message: notification.message
};
if (notification.payload) {
for (const [key, value] of Object.entries(notification.payload)) {
if (value === undefined || value === null) continue;
data[key] = typeof value === 'string' ? value : JSON.stringify(value);
}
}
const messaging = getMessaging();
let sentCount = 0;
const staleTokens: string[] = [];
const chunkSize = 500;
for (let offset = 0; offset < tokens.length; offset += chunkSize) {
const chunk = tokens.slice(offset, offset + chunkSize);
const response = await messaging.sendEachForMulticast({
tokens: chunk.map((item) => item.token),
notification: {
title: notification.title,
body: notification.message
},
data,
webpush: {
fcmOptions: {
link: '/'
}
}
});
sentCount += response.successCount;
response.responses.forEach((item, index) => {
if (item.success) return;
const code = item.error?.code ?? '';
if (
code === 'messaging/registration-token-not-registered' ||
code === 'messaging/invalid-registration-token' ||
code === 'messaging/invalid-argument'
) {
staleTokens.push(chunk[index].token);
} else if (item.error) {
this.logger.warn(`FCM ошибка для токена: ${item.error.message}`);
}
});
}
if (staleTokens.length) {
await this.pushDevices.removeTokens(staleTokens);
this.logger.log(`Удалено устаревших FCM-токенов: ${staleTokens.length}`);
}
return { sentCount, skipped: false };
}
async sendTestToUser(userId: string) {
const config = await this.loadServerConfig();
if (!config) {
throw new BadRequestException('Firebase push не настроен или отключён');
}
const tokens = await this.pushDevices.listTokensForUser(userId);
if (!tokens.length) {
throw new BadRequestException('У вас нет зарегистрированных устройств для push. Откройте IdP в браузере и разрешите уведомления.');
}
const result = await this.sendToUser({
userId,
type: 'system_test',
title: 'Тест Firebase Push',
message: 'Если вы видите это уведомление — FCM настроен корректно.',
payload: { source: 'admin_test' }
});
if (!result.sentCount) {
throw new BadRequestException('Не удалось отправить тестовое push-уведомление. Проверьте ключи сервисного аккаунта.');
}
return {
message: `Тестовое push отправлено на ${result.sentCount} устройств(а)`,
sentCount: result.sentCount
};
}
private async loadServerConfig() {
const enabled = await this.settings.getBoolean('FIREBASE_PUSH_ENABLED', false);
if (!enabled) return null;
const projectId = (await this.settings.getValue('FIREBASE_PROJECT_ID', '')).trim();
const clientEmail = (await this.settings.getValue('FIREBASE_CLIENT_EMAIL', '')).trim();
const privateKeyRaw = await this.settings.getValue('FIREBASE_PRIVATE_KEY', '');
const privateKey = this.normalizePrivateKey(privateKeyRaw);
if (!projectId || !clientEmail || !privateKey) {
this.logger.warn('Firebase push включён, но не заполнены project_id, client_email или private_key');
return null;
}
return { projectId, clientEmail, privateKey };
}
private normalizePrivateKey(value: string) {
const trimmed = value.trim();
if (!trimmed) return '';
return trimmed.replace(/\\n/g, '\n');
}
private async ensureApp(config: { projectId: string; clientEmail: string; privateKey: string }) {
if (this.initializedProjectId === config.projectId && getApps().length) {
return;
}
for (const app of getApps()) {
await deleteApp(app);
}
initializeApp({
credential: cert({
projectId: config.projectId,
clientEmail: config.clientEmail,
privateKey: config.privateKey
}),
projectId: config.projectId
});
this.initializedProjectId = config.projectId;
}
}