add push settings
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"@prisma/client": "^7.1.0",
|
||||
"amqplib": "^0.10.9",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"firebase-admin": "^14.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"nodemailer": "^7.0.6",
|
||||
"otplib": "^12.0.1",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PushPlatform" AS ENUM ('WEB', 'ANDROID', 'IOS');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PushDeviceToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"platform" "PushPlatform" NOT NULL DEFAULT 'WEB',
|
||||
"deviceLabel" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PushDeviceToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushDeviceToken_token_key" ON "PushDeviceToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushDeviceToken_userId_idx" ON "PushDeviceToken"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushDeviceToken" ADD CONSTRAINT "PushDeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -40,6 +40,12 @@ enum AppReleasePlatform {
|
||||
WINDOWS
|
||||
}
|
||||
|
||||
enum PushPlatform {
|
||||
WEB
|
||||
ANDROID
|
||||
IOS
|
||||
}
|
||||
|
||||
model AppRelease {
|
||||
id String @id @default(uuid())
|
||||
platform AppReleasePlatform
|
||||
@@ -104,6 +110,7 @@ model User {
|
||||
sentFamilyInvites FamilyInvite[] @relation("FamilyInviteInviter")
|
||||
receivedFamilyInvites FamilyInvite[] @relation("FamilyInviteInvitee")
|
||||
notifications Notification[]
|
||||
pushDeviceTokens PushDeviceToken[]
|
||||
chatRoomMembers ChatRoomMember[]
|
||||
chatMessages ChatMessage[]
|
||||
chatPollVotes ChatPollVote[]
|
||||
@@ -437,6 +444,19 @@ model Notification {
|
||||
@@index([userId, isRead, createdAt])
|
||||
}
|
||||
|
||||
model PushDeviceToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
token String @unique
|
||||
platform PushPlatform @default(WEB)
|
||||
deviceLabel String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model ChatRoom {
|
||||
id String @id @default(uuid())
|
||||
groupId String
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
75
apps/sso-core/src/domain/push-device.service.ts
Normal file
75
apps/sso-core/src/domain/push-device.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,30 @@ export const DEFAULT_SYSTEM_SETTINGS = [
|
||||
key: 'ADMIN_INSIGHTS_RETENTION_DAYS',
|
||||
value: '7',
|
||||
description: 'Срок хранения журналов входов, активности и переписок для админки (дни, 1–90). Старые записи удаляются автоматически'
|
||||
},
|
||||
{ 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()
|
||||
|
||||
175
apps/sso-core/src/infra/firebase-push.service.ts
Normal file
175
apps/sso-core/src/infra/firebase-push.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user