add push settings
This commit is contained in:
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { getAuthorizedUserId } from '../document-access';
|
import { getAuthorizedUserId } from '../document-access';
|
||||||
import { MarkNotificationReadDto } from '../dto/notifications.dto';
|
import { MarkNotificationReadDto, RegisterPushTokenDto, UnregisterPushTokenDto } from '../dto/notifications.dto';
|
||||||
|
|
||||||
@ApiTags('Уведомления')
|
@ApiTags('Уведомления')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -83,4 +83,36 @@ export class NotificationsController {
|
|||||||
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
return firstValueFrom(this.core.notifications.DeleteAllNotifications({ userId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('push/register')
|
||||||
|
@ApiOperation({ summary: 'Зарегистрировать FCM-токен устройства' })
|
||||||
|
async registerPushToken(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Body() dto: RegisterPushTokenDto
|
||||||
|
) {
|
||||||
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.notifications.RegisterPushToken({
|
||||||
|
userId,
|
||||||
|
token: dto.token,
|
||||||
|
platform: dto.platform ?? 'WEB',
|
||||||
|
deviceLabel: dto.deviceLabel
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('push/register')
|
||||||
|
@ApiOperation({ summary: 'Удалить FCM-токен устройства' })
|
||||||
|
async unregisterPushToken(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Body() dto: UnregisterPushTokenDto
|
||||||
|
) {
|
||||||
|
const userId = await getAuthorizedUserId(this.jwt, this.core, authorization);
|
||||||
|
return firstValueFrom(
|
||||||
|
this.core.notifications.UnregisterPushToken({
|
||||||
|
userId,
|
||||||
|
token: dto.token
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,14 @@ export class SettingsController {
|
|||||||
return this.core.settings.TestMessagingDelivery({ channel: dto.channel, target: dto.target });
|
return this.core.settings.TestMessagingDelivery({ channel: dto.channel, target: dto.target });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('firebase/test')
|
||||||
|
@ApiOperation({ summary: 'Тест Firebase Push', description: 'Отправляет тестовое push-уведомление на устройства текущего администратора.' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Тестовое push-уведомление отправлено' })
|
||||||
|
testFirebasePush(@CurrentAdmin() admin: AdminRequestUser) {
|
||||||
|
assertAdminPermission(admin, 'canManageSettings');
|
||||||
|
return this.core.settings.TestFirebasePush({ userId: admin.id });
|
||||||
|
}
|
||||||
|
|
||||||
@Get('linked-accounts/users/:userId')
|
@Get('linked-accounts/users/:userId')
|
||||||
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
@ApiOperation({ summary: 'Связанные внешние аккаунты', description: 'Возвращает LinkedAccount записи пользователя для Google/Yandex и других провайдеров.' })
|
||||||
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
@ApiParam({ name: 'userId', description: 'ID пользователя' })
|
||||||
|
|||||||
@@ -1 +1,28 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class MarkNotificationReadDto {}
|
export class MarkNotificationReadDto {}
|
||||||
|
|
||||||
|
export class RegisterPushTokenDto {
|
||||||
|
@ApiProperty({ description: 'FCM-токен устройства' })
|
||||||
|
@IsString({ message: 'FCM-токен должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите FCM-токен' })
|
||||||
|
token!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Платформа устройства', enum: ['WEB', 'ANDROID', 'IOS'], default: 'WEB' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WEB', 'ANDROID', 'IOS'], { message: 'Платформа должна быть WEB, ANDROID или IOS' })
|
||||||
|
platform?: 'WEB' | 'ANDROID' | 'IOS';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Метка устройства для отладки' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'Метка устройства должна быть строкой' })
|
||||||
|
deviceLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnregisterPushTokenDto {
|
||||||
|
@ApiProperty({ description: 'FCM-токен устройства' })
|
||||||
|
@IsString({ message: 'FCM-токен должен быть строкой' })
|
||||||
|
@IsNotEmpty({ message: 'Укажите FCM-токен' })
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { useToast } from '@/components/id/toast-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
import { MessagingSettingsSection } from '@/components/id/messaging-settings-section';
|
||||||
|
import { FirebaseSettingsSection } from '@/components/id/firebase-settings-section';
|
||||||
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
||||||
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
import { apiFetch, buildSystemSettingPayload, SocialProvider, SystemSetting } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
|
FIREBASE_SETTING_KEYS,
|
||||||
getSettingMeta,
|
getSettingMeta,
|
||||||
isSettingVisible,
|
isSettingVisible,
|
||||||
MESSAGING_SETTING_KEYS,
|
MESSAGING_SETTING_KEYS,
|
||||||
@@ -63,6 +65,7 @@ export default function AdminSettingsPage() {
|
|||||||
const groups = new Map<string, SystemSetting[]>();
|
const groups = new Map<string, SystemSetting[]>();
|
||||||
for (const setting of settings) {
|
for (const setting of settings) {
|
||||||
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
if (MESSAGING_SETTING_KEYS.includes(setting.key)) continue;
|
||||||
|
if (FIREBASE_SETTING_KEYS.includes(setting.key)) continue;
|
||||||
const meta = getSettingMeta(setting.key);
|
const meta = getSettingMeta(setting.key);
|
||||||
const group = meta?.group ?? 'other';
|
const group = meta?.group ?? 'other';
|
||||||
const list = groups.get(group) ?? [];
|
const list = groups.get(group) ?? [];
|
||||||
@@ -217,6 +220,15 @@ export default function AdminSettingsPage() {
|
|||||||
showToast={showToast}
|
showToast={showToast}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FirebaseSettingsSection
|
||||||
|
settings={settings}
|
||||||
|
token={token}
|
||||||
|
savingGroup={savingGroup}
|
||||||
|
onUpdateValue={updateSettingValue}
|
||||||
|
onSaveGroup={saveSettingsGroup}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="mt-10">
|
<section className="mt-10">
|
||||||
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
|
<h3 className="mb-4 text-xl font-medium">OAuth Social Providers</h3>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { AuthProvider } from '@/components/id/auth-provider';
|
import { AuthProvider } from '@/components/id/auth-provider';
|
||||||
import { RealtimeProvider } from '@/components/notifications/realtime-provider';
|
import { RealtimeProvider } from '@/components/notifications/realtime-provider';
|
||||||
|
import { FirebasePushBridge } from '@/components/notifications/firebase-push-bridge';
|
||||||
import { PublicSettingsProvider } from '@/components/id/public-settings-provider';
|
import { PublicSettingsProvider } from '@/components/id/public-settings-provider';
|
||||||
import { ProjectHead } from '@/components/id/project-head';
|
import { ProjectHead } from '@/components/id/project-head';
|
||||||
import { AppToastProvider } from '@/components/id/toast-provider';
|
import { AppToastProvider } from '@/components/id/toast-provider';
|
||||||
@@ -12,6 +13,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
<PublicSettingsProvider>
|
<PublicSettingsProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ProjectHead />
|
<ProjectHead />
|
||||||
|
<FirebasePushBridge />
|
||||||
<RealtimeProvider>{children}</RealtimeProvider>
|
<RealtimeProvider>{children}</RealtimeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</PublicSettingsProvider>
|
</PublicSettingsProvider>
|
||||||
|
|||||||
123
apps/frontend/components/id/firebase-settings-section.tsx
Normal file
123
apps/frontend/components/id/firebase-settings-section.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Bell, Loader2, Save, Send } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiFetch, SystemSetting } from '@/lib/api';
|
||||||
|
import { FIREBASE_SETTING_KEYS, isSettingVisible } from '@/lib/system-settings-catalog';
|
||||||
|
import { SettingsFieldRow } from '@/components/id/settings-field-row';
|
||||||
|
|
||||||
|
function parseBoolean(value: string) {
|
||||||
|
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingValue(settings: SystemSetting[], key: string) {
|
||||||
|
return settings.find((item) => item.key === key)?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FirebaseSettingsSectionProps {
|
||||||
|
settings: SystemSetting[];
|
||||||
|
token: string | null;
|
||||||
|
savingGroup: string | null;
|
||||||
|
onUpdateValue: (key: string, value: string) => void;
|
||||||
|
onSaveGroup: (groupKey: string, groupSettings: SystemSetting[]) => Promise<void>;
|
||||||
|
showToast: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FirebaseSettingsSection({
|
||||||
|
settings,
|
||||||
|
token,
|
||||||
|
savingGroup,
|
||||||
|
onUpdateValue,
|
||||||
|
onSaveGroup,
|
||||||
|
showToast
|
||||||
|
}: FirebaseSettingsSectionProps) {
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
const firebaseSettings = useMemo(
|
||||||
|
() => settings.filter((item) => FIREBASE_SETTING_KEYS.includes(item.key)),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleSettings = firebaseSettings.filter((setting) => isSettingVisible(setting, settings));
|
||||||
|
const saving = savingGroup === 'firebase';
|
||||||
|
const pushEnabled = parseBoolean(getSettingValue(firebaseSettings, 'FIREBASE_PUSH_ENABLED'));
|
||||||
|
const hasServerCredentials =
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_PROJECT_ID').trim()) &&
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_CLIENT_EMAIL').trim()) &&
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_PRIVATE_KEY').trim());
|
||||||
|
const hasWebConfig =
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_API_KEY').trim()) &&
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_APP_ID').trim()) &&
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_MESSAGING_SENDER_ID').trim()) &&
|
||||||
|
Boolean(getSettingValue(firebaseSettings, 'FIREBASE_WEB_VAPID_KEY').trim());
|
||||||
|
const canTest = pushEnabled && hasServerCredentials && hasWebConfig;
|
||||||
|
|
||||||
|
async function sendTestPush() {
|
||||||
|
if (!token) return;
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const response = await apiFetch<{ message?: string }>('/admin/settings/firebase/test', { method: 'POST' }, token);
|
||||||
|
showToast(response.message ?? 'Тестовое push-уведомление отправлено');
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error instanceof Error ? error.message : 'Не удалось отправить тестовое push-уведомление');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-10">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<Bell className="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-medium">Firebase Push</h3>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
Настройте Firebase Cloud Messaging для push-уведомлений в браузере и мобильных клиентах.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] bg-[#f4f5f8] p-5">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 border-b border-[#e0e3ea] pb-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-medium">Параметры FCM</h4>
|
||||||
|
<p className="mt-1 text-sm text-[#667085]">
|
||||||
|
Сервисный аккаунт — для отправки с сервера. Web API Key, App ID, Sender ID и VAPID — для клиентского SDK.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button disabled={saving || visibleSettings.length === 0} onClick={() => void onSaveGroup('firebase', firebaseSettings)}>
|
||||||
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
Сохранить блок
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{visibleSettings.map((setting) => (
|
||||||
|
<SettingsFieldRow
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
onChange={(value) => onUpdateValue(setting.key, value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-[24px] border border-[#e4e7ec] bg-white p-5">
|
||||||
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
Тест push-уведомления
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-[#667085]">
|
||||||
|
{canTest
|
||||||
|
? 'Отправит тестовое уведомление на ваши зарегистрированные устройства. Сначала откройте IdP в браузере и разрешите уведомления.'
|
||||||
|
: 'Сохраните блок Firebase с включённым push и заполненными ключами, затем зарегистрируйте устройство в браузере.'}
|
||||||
|
</p>
|
||||||
|
<Button className="mt-3" disabled={!canTest || testing} onClick={() => void sendTestPush()}>
|
||||||
|
{testing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
|
Отправить тест
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL, inferApiBaseFromProjectDomain } from '@/lib/project-domains';
|
import { DEFAULT_PUBLIC_API_URL, DEFAULT_PUBLIC_FRONTEND_URL, inferApiBaseFromProjectDomain } from '@/lib/project-domains';
|
||||||
|
import { FirebasePublicConfig, parseFirebasePublicConfig } from '@/lib/firebase-config';
|
||||||
|
|
||||||
interface PublicSettingsContextValue {
|
interface PublicSettingsContextValue {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@@ -12,6 +13,7 @@ interface PublicSettingsContextValue {
|
|||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
ldapUseLdaps: boolean;
|
ldapUseLdaps: boolean;
|
||||||
pinLockTimeoutMinutes: number;
|
pinLockTimeoutMinutes: number;
|
||||||
|
firebaseConfig: FirebasePublicConfig | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
refreshPublicSettings: () => Promise<void>;
|
refreshPublicSettings: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,7 @@ const PublicSettingsContext = createContext<PublicSettingsContextValue>({
|
|||||||
ldapEnabled: false,
|
ldapEnabled: false,
|
||||||
ldapUseLdaps: false,
|
ldapUseLdaps: false,
|
||||||
pinLockTimeoutMinutes: 15,
|
pinLockTimeoutMinutes: 15,
|
||||||
|
firebaseConfig: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
refreshPublicSettings: async () => undefined
|
refreshPublicSettings: async () => undefined
|
||||||
});
|
});
|
||||||
@@ -68,6 +71,7 @@ export function PublicSettingsProvider({ children }: { children: React.ReactNode
|
|||||||
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
ldapEnabled: ['true', '1', 'yes'].includes((settings.LDAP_ENABLED ?? '').trim().toLowerCase()),
|
||||||
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
ldapUseLdaps: ['true', '1', 'yes'].includes((settings.LDAP_USE_LDAPS ?? '').trim().toLowerCase()),
|
||||||
pinLockTimeoutMinutes: Math.max(1, Number.parseInt(settings.PIN_LOCK_TIMEOUT_MINUTES ?? '15', 10) || 15),
|
pinLockTimeoutMinutes: Math.max(1, Number.parseInt(settings.PIN_LOCK_TIMEOUT_MINUTES ?? '15', 10) || 15),
|
||||||
|
firebaseConfig: parseFirebasePublicConfig(settings),
|
||||||
isLoading,
|
isLoading,
|
||||||
refreshPublicSettings
|
refreshPublicSettings
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { usePublicSettings } from '@/components/id/public-settings-provider';
|
||||||
|
import { registerFirebasePush, subscribeFirebaseForegroundMessages } from '@/lib/firebase-push';
|
||||||
|
|
||||||
|
export function FirebasePushBridge() {
|
||||||
|
const { token, user, isPinLocked, isLoading, isContentReady } = useAuth();
|
||||||
|
const { firebaseConfig } = usePublicSettings();
|
||||||
|
const registeredRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !user?.id || isPinLocked || isLoading || !isContentReady || !firebaseConfig) {
|
||||||
|
registeredRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registeredRef.current) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void registerFirebasePush(token, firebaseConfig)
|
||||||
|
.then((fcmToken) => {
|
||||||
|
if (!cancelled && fcmToken) {
|
||||||
|
registeredRef.current = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [firebaseConfig, isContentReady, isLoading, isPinLocked, token, user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firebaseConfig || !token || isPinLocked) return undefined;
|
||||||
|
|
||||||
|
const unsubscribe = subscribeFirebaseForegroundMessages((payload) => {
|
||||||
|
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||||
|
const notification = new Notification(payload.title, {
|
||||||
|
body: payload.message
|
||||||
|
});
|
||||||
|
notification.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
notification.close();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [firebaseConfig, isPinLocked, token]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
32
apps/frontend/lib/firebase-config.ts
Normal file
32
apps/frontend/lib/firebase-config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface FirebasePublicConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
projectId: string;
|
||||||
|
apiKey: string;
|
||||||
|
appId: string;
|
||||||
|
messagingSenderId: string;
|
||||||
|
vapidKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFirebasePublicConfig(settings: Record<string, string>): FirebasePublicConfig | null {
|
||||||
|
const enabled = ['true', '1', 'yes'].includes((settings.FIREBASE_PUSH_ENABLED ?? '').trim().toLowerCase());
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const projectId = settings.FIREBASE_PROJECT_ID?.trim() ?? '';
|
||||||
|
const apiKey = settings.FIREBASE_WEB_API_KEY?.trim() ?? '';
|
||||||
|
const appId = settings.FIREBASE_WEB_APP_ID?.trim() ?? '';
|
||||||
|
const messagingSenderId = settings.FIREBASE_WEB_MESSAGING_SENDER_ID?.trim() ?? '';
|
||||||
|
const vapidKey = settings.FIREBASE_WEB_VAPID_KEY?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!projectId || !apiKey || !appId || !messagingSenderId || !vapidKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
projectId,
|
||||||
|
apiKey,
|
||||||
|
appId,
|
||||||
|
messagingSenderId,
|
||||||
|
vapidKey
|
||||||
|
};
|
||||||
|
}
|
||||||
112
apps/frontend/lib/firebase-push.ts
Normal file
112
apps/frontend/lib/firebase-push.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { initializeApp, getApps, type FirebaseApp } from 'firebase/app';
|
||||||
|
import { getMessaging, getToken, isSupported, onMessage, type Messaging } from 'firebase/messaging';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import type { FirebasePublicConfig } from '@/lib/firebase-config';
|
||||||
|
|
||||||
|
const SW_PATH = '/firebase-messaging-sw.js';
|
||||||
|
const REGISTERED_TOKEN_KEY = 'lendry_fcm_token';
|
||||||
|
|
||||||
|
let messagingInstance: Messaging | null = null;
|
||||||
|
|
||||||
|
function getFirebaseApp(config: FirebasePublicConfig): FirebaseApp {
|
||||||
|
const existing = getApps()[0];
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
return initializeApp({
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
authDomain: `${config.projectId}.firebaseapp.com`,
|
||||||
|
projectId: config.projectId,
|
||||||
|
messagingSenderId: config.messagingSenderId,
|
||||||
|
appId: config.appId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) return null;
|
||||||
|
const registration = await navigator.serviceWorker.register(SW_PATH, { scope: '/' });
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerFirebasePush(authToken: string, config: FirebasePublicConfig) {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
if (!config.enabled) return null;
|
||||||
|
|
||||||
|
const supported = await isSupported();
|
||||||
|
if (!supported) return null;
|
||||||
|
|
||||||
|
if (!('Notification' in window)) return null;
|
||||||
|
|
||||||
|
const permission = Notification.permission === 'granted'
|
||||||
|
? 'granted'
|
||||||
|
: await Notification.requestPermission();
|
||||||
|
|
||||||
|
if (permission !== 'granted') return null;
|
||||||
|
|
||||||
|
const registration = await ensureServiceWorker();
|
||||||
|
if (!registration) return null;
|
||||||
|
|
||||||
|
const app = getFirebaseApp(config);
|
||||||
|
messagingInstance = getMessaging(app);
|
||||||
|
|
||||||
|
const fcmToken = await getToken(messagingInstance, {
|
||||||
|
vapidKey: config.vapidKey,
|
||||||
|
serviceWorkerRegistration: registration
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fcmToken) return null;
|
||||||
|
|
||||||
|
const previousToken = window.localStorage.getItem(REGISTERED_TOKEN_KEY);
|
||||||
|
if (previousToken && previousToken !== fcmToken) {
|
||||||
|
try {
|
||||||
|
await apiFetch('/notifications/push/register', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ token: previousToken })
|
||||||
|
}, authToken);
|
||||||
|
} catch {
|
||||||
|
// ignore stale unregister errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiFetch('/notifications/push/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: fcmToken,
|
||||||
|
platform: 'WEB',
|
||||||
|
deviceLabel: navigator.userAgent.slice(0, 120)
|
||||||
|
})
|
||||||
|
}, authToken);
|
||||||
|
|
||||||
|
window.localStorage.setItem(REGISTERED_TOKEN_KEY, fcmToken);
|
||||||
|
return fcmToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeFirebaseForegroundMessages(listener: (payload: { title: string; message: string; data?: Record<string, string> }) => void) {
|
||||||
|
if (!messagingInstance) return () => undefined;
|
||||||
|
|
||||||
|
return onMessage(messagingInstance, (payload) => {
|
||||||
|
listener({
|
||||||
|
title: payload.notification?.title ?? payload.data?.title ?? 'Уведомление',
|
||||||
|
message: payload.notification?.body ?? payload.data?.message ?? '',
|
||||||
|
data: payload.data
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterFirebasePush(authToken: string | null) {
|
||||||
|
const stored = typeof window !== 'undefined' ? window.localStorage.getItem(REGISTERED_TOKEN_KEY) : null;
|
||||||
|
if (!stored || !authToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiFetch('/notifications/push/register', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ token: stored })
|
||||||
|
}, authToken);
|
||||||
|
} catch {
|
||||||
|
// ignore logout cleanup errors
|
||||||
|
} finally {
|
||||||
|
window.localStorage.removeItem(REGISTERED_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export const SYSTEM_SETTING_GROUPS: Record<string, string> = {
|
|||||||
auth: 'Аутентификация и регистрация',
|
auth: 'Аутентификация и регистрация',
|
||||||
ldap: 'LDAP / Active Directory',
|
ldap: 'LDAP / Active Directory',
|
||||||
messaging: 'Email и SMS',
|
messaging: 'Email и SMS',
|
||||||
|
firebase: 'Firebase Push',
|
||||||
limits: 'Лимиты и ограничения',
|
limits: 'Лимиты и ограничения',
|
||||||
media: 'Медиа и файлы'
|
media: 'Медиа и файлы'
|
||||||
};
|
};
|
||||||
@@ -166,6 +167,69 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
|
|||||||
{ key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
{ key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
{ key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
{ key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
{ key: 'SMS_SENDER', label: 'Имя отправителя SMS', group: 'messaging', type: 'text', hint: 'Опционально, если одобрено провайдером', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
{ key: 'SMS_SENDER', label: 'Имя отправителя SMS', group: 'messaging', type: 'text', hint: 'Опционально, если одобрено провайдером', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_PUSH_ENABLED',
|
||||||
|
label: 'Push-уведомления включены',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'boolean',
|
||||||
|
hint: 'Отправка push через Firebase Cloud Messaging на зарегистрированные устройства'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_PROJECT_ID',
|
||||||
|
label: 'Firebase Project ID',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'project_id из консоли Firebase и JSON сервисного аккаунта',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_CLIENT_EMAIL',
|
||||||
|
label: 'Service Account Email',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'client_email из JSON ключа сервисного аккаунта',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_PRIVATE_KEY',
|
||||||
|
label: 'Service Account Private Key',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'textarea',
|
||||||
|
hint: 'private_key из JSON. Вставьте как есть — переносы строк сохранятся',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_WEB_API_KEY',
|
||||||
|
label: 'Web API Key',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'Публичный ключ для клиентского Firebase SDK (веб и PWA)',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_WEB_APP_ID',
|
||||||
|
label: 'Web App ID',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'appId веб-приложения из консоли Firebase',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_WEB_MESSAGING_SENDER_ID',
|
||||||
|
label: 'Messaging Sender ID',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'senderId для FCM в браузере',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'FIREBASE_WEB_VAPID_KEY',
|
||||||
|
label: 'Web Push VAPID Key',
|
||||||
|
group: 'firebase',
|
||||||
|
type: 'text',
|
||||||
|
hint: 'Пара ключей Web Push certificates → Key pair из Firebase Cloud Messaging',
|
||||||
|
showWhen: { key: 'FIREBASE_PUSH_ENABLED', equals: 'true' }
|
||||||
|
},
|
||||||
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
{ key: 'MAX_FAMILY_MEMBERS', label: 'Макс. участников семьи', group: 'limits', type: 'number' },
|
||||||
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
{ key: 'MAX_ACTIVE_SESSIONS', label: 'Макс. активных сессий', group: 'limits', type: 'number' },
|
||||||
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
{ key: 'AVATAR_URL_TTL_MINUTES', label: 'TTL ссылки на аватар', group: 'media', type: 'number', unit: 'мин' }
|
||||||
@@ -189,6 +253,8 @@ function parseBooleanValue(value: string) {
|
|||||||
|
|
||||||
export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key);
|
export const MESSAGING_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'messaging').map((item) => item.key);
|
||||||
|
|
||||||
|
export const FIREBASE_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) => item.group === 'firebase').map((item) => item.key);
|
||||||
|
|
||||||
export const EMAIL_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) =>
|
export const EMAIL_SETTING_KEYS = SYSTEM_SETTING_CATALOG.filter((item) =>
|
||||||
item.key.startsWith('EMAIL_')
|
item.key.startsWith('EMAIL_')
|
||||||
).map((item) => item.key);
|
).map((item) => item.key);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"firebase": "^12.15.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
|
|||||||
72
apps/frontend/public/firebase-messaging-sw.js
Normal file
72
apps/frontend/public/firebase-messaging-sw.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-app-compat.js');
|
||||||
|
importScripts('https://www.gstatic.com/firebasejs/11.6.0/firebase-messaging-compat.js');
|
||||||
|
|
||||||
|
const API_PREFIX = '/idp-api';
|
||||||
|
|
||||||
|
async function loadPublicSettings() {
|
||||||
|
const response = await fetch(`${API_PREFIX}/settings/public`, { credentials: 'same-origin' });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось загрузить публичные настройки Firebase');
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
const map = Object.fromEntries((payload.settings ?? []).map((item) => [item.key, item.value]));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled(value) {
|
||||||
|
return ['true', '1', 'yes'].includes(String(value ?? '').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initFirebaseMessaging() {
|
||||||
|
const settings = await loadPublicSettings();
|
||||||
|
if (!isEnabled(settings.FIREBASE_PUSH_ENABLED)) return;
|
||||||
|
|
||||||
|
const projectId = settings.FIREBASE_PROJECT_ID?.trim();
|
||||||
|
const apiKey = settings.FIREBASE_WEB_API_KEY?.trim();
|
||||||
|
const appId = settings.FIREBASE_WEB_APP_ID?.trim();
|
||||||
|
const messagingSenderId = settings.FIREBASE_WEB_MESSAGING_SENDER_ID?.trim();
|
||||||
|
|
||||||
|
if (!projectId || !apiKey || !appId || !messagingSenderId) return;
|
||||||
|
|
||||||
|
firebase.initializeApp({
|
||||||
|
apiKey,
|
||||||
|
authDomain: `${projectId}.firebaseapp.com`,
|
||||||
|
projectId,
|
||||||
|
messagingSenderId,
|
||||||
|
appId
|
||||||
|
});
|
||||||
|
|
||||||
|
const messaging = firebase.messaging();
|
||||||
|
messaging.onBackgroundMessage((payload) => {
|
||||||
|
const title = payload.notification?.title || payload.data?.title || 'Уведомление';
|
||||||
|
const options = {
|
||||||
|
body: payload.notification?.body || payload.data?.message || '',
|
||||||
|
data: payload.data ?? {}
|
||||||
|
};
|
||||||
|
self.registration.showNotification(title, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(self.skipWaiting());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||||
|
const existing = clients.find((client) => 'focus' in client);
|
||||||
|
if (existing) {
|
||||||
|
return existing.focus();
|
||||||
|
}
|
||||||
|
return self.clients.openWindow('/');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
initFirebaseMessaging().catch(() => undefined);
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"@prisma/client": "^7.1.0",
|
"@prisma/client": "^7.1.0",
|
||||||
"amqplib": "^0.10.9",
|
"amqplib": "^0.10.9",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"firebase-admin": "^14.1.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"nodemailer": "^7.0.6",
|
"nodemailer": "^7.0.6",
|
||||||
"otplib": "^12.0.1",
|
"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
|
WINDOWS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PushPlatform {
|
||||||
|
WEB
|
||||||
|
ANDROID
|
||||||
|
IOS
|
||||||
|
}
|
||||||
|
|
||||||
model AppRelease {
|
model AppRelease {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
platform AppReleasePlatform
|
platform AppReleasePlatform
|
||||||
@@ -104,6 +110,7 @@ model User {
|
|||||||
sentFamilyInvites FamilyInvite[] @relation("FamilyInviteInviter")
|
sentFamilyInvites FamilyInvite[] @relation("FamilyInviteInviter")
|
||||||
receivedFamilyInvites FamilyInvite[] @relation("FamilyInviteInvitee")
|
receivedFamilyInvites FamilyInvite[] @relation("FamilyInviteInvitee")
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
pushDeviceTokens PushDeviceToken[]
|
||||||
chatRoomMembers ChatRoomMember[]
|
chatRoomMembers ChatRoomMember[]
|
||||||
chatMessages ChatMessage[]
|
chatMessages ChatMessage[]
|
||||||
chatPollVotes ChatPollVote[]
|
chatPollVotes ChatPollVote[]
|
||||||
@@ -437,6 +444,19 @@ model Notification {
|
|||||||
@@index([userId, isRead, createdAt])
|
@@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 {
|
model ChatRoom {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
groupId String
|
groupId String
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { AppReleaseService } from './domain/app-release.service';
|
|||||||
import { FamilyService } from './domain/family.service';
|
import { FamilyService } from './domain/family.service';
|
||||||
import { MediaService } from './domain/media.service';
|
import { MediaService } from './domain/media.service';
|
||||||
import { NotificationsService } from './domain/notifications.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 { ChatService } from './domain/chat.service';
|
||||||
import { PresenceService } from './domain/presence.service';
|
import { PresenceService } from './domain/presence.service';
|
||||||
import { NotificationPublisherService } from './infra/notification-publisher.service';
|
import { NotificationPublisherService } from './infra/notification-publisher.service';
|
||||||
@@ -93,9 +95,11 @@ import { BotFatherAssistantService } from './domain/bot/bot-father-assistant.ser
|
|||||||
FamilyService,
|
FamilyService,
|
||||||
MediaService,
|
MediaService,
|
||||||
NotificationsService,
|
NotificationsService,
|
||||||
|
PushDeviceService,
|
||||||
ChatService,
|
ChatService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
NotificationPublisherService,
|
NotificationPublisherService,
|
||||||
|
FirebasePushService,
|
||||||
MinioService,
|
MinioService,
|
||||||
LdapClientService,
|
LdapClientService,
|
||||||
MessagingService,
|
MessagingService,
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ import { AppReleaseService } from './app-release.service';
|
|||||||
import { FamilyService } from './family.service';
|
import { FamilyService } from './family.service';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { PushDeviceService } from './push-device.service';
|
||||||
import { ChatService } from './chat.service';
|
import { ChatService } from './chat.service';
|
||||||
import { PresenceService } from './presence.service';
|
import { PresenceService } from './presence.service';
|
||||||
import { TotpService } from './totp.service';
|
import { TotpService } from './totp.service';
|
||||||
import { MessagingService } from '../infra/messaging.service';
|
import { MessagingService } from '../infra/messaging.service';
|
||||||
|
import { FirebasePushService } from '../infra/firebase-push.service';
|
||||||
import { BotFatherService } from './bot/bot-father.service';
|
import { BotFatherService } from './bot/bot-father.service';
|
||||||
import { BotApiService } from './bot/bot-api.service';
|
import { BotApiService } from './bot/bot-api.service';
|
||||||
import { BotInboundService } from './bot/bot-inbound.service';
|
import { BotInboundService } from './bot/bot-inbound.service';
|
||||||
@@ -53,9 +55,11 @@ export class AuthGrpcController {
|
|||||||
private readonly family: FamilyService,
|
private readonly family: FamilyService,
|
||||||
private readonly media: MediaService,
|
private readonly media: MediaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly pushDevices: PushDeviceService,
|
||||||
private readonly chat: ChatService,
|
private readonly chat: ChatService,
|
||||||
private readonly presence: PresenceService,
|
private readonly presence: PresenceService,
|
||||||
private readonly messaging: MessagingService,
|
private readonly messaging: MessagingService,
|
||||||
|
private readonly firebasePush: FirebasePushService,
|
||||||
private readonly totp: TotpService,
|
private readonly totp: TotpService,
|
||||||
private readonly botFather: BotFatherService,
|
private readonly botFather: BotFatherService,
|
||||||
private readonly botApi: BotApiService,
|
private readonly botApi: BotApiService,
|
||||||
@@ -651,6 +655,14 @@ export class AuthGrpcController {
|
|||||||
return this.messaging.sendTestDelivery(channel, command.target.trim());
|
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')
|
@GrpcMethod('ProfileService', 'GetProfile')
|
||||||
getProfile(command: { userId: string }) {
|
getProfile(command: { userId: string }) {
|
||||||
return this.profile.getProfile(command.userId);
|
return this.profile.getProfile(command.userId);
|
||||||
@@ -1092,6 +1104,16 @@ export class AuthGrpcController {
|
|||||||
return this.notifications.deleteAll(command.userId);
|
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')
|
@GrpcMethod('ChatService', 'ListRooms')
|
||||||
listChatRooms(command: { userId: string; groupId: string }) {
|
listChatRooms(command: { userId: string; groupId: string }) {
|
||||||
return this.chat.listRooms(command.userId, command.groupId);
|
return this.chat.listRooms(command.userId, command.groupId);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { NotificationPublisherService } from '../infra/notification-publisher.service';
|
import { NotificationPublisherService } from '../infra/notification-publisher.service';
|
||||||
|
import { FirebasePushService } from '../infra/firebase-push.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly publisher: NotificationPublisherService
|
private readonly publisher: NotificationPublisherService,
|
||||||
|
private readonly firebasePush: FirebasePushService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -38,6 +40,19 @@ export class NotificationsService {
|
|||||||
...(payload ?? {})
|
...(payload ?? {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void this.firebasePush
|
||||||
|
.sendToUser({
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
payload: {
|
||||||
|
notificationId: notification.id,
|
||||||
|
...(payload ?? {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toResponse(notification);
|
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',
|
key: 'ADMIN_INSIGHTS_RETENTION_DAYS',
|
||||||
value: '7',
|
value: '7',
|
||||||
description: 'Срок хранения журналов входов, активности и переписок для админки (дни, 1–90). Старые записи удаляются автоматически'
|
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;
|
] as const;
|
||||||
|
|
||||||
@@ -119,7 +143,8 @@ const SECRET_SETTING_KEYS = new Set([
|
|||||||
'EMAIL_SMTP_PASSWORD',
|
'EMAIL_SMTP_PASSWORD',
|
||||||
'SMS_API_KEY',
|
'SMS_API_KEY',
|
||||||
'SMS_PASSWORD',
|
'SMS_PASSWORD',
|
||||||
'SMS_GATEWAY_PASSWORD'
|
'SMS_GATEWAY_PASSWORD',
|
||||||
|
'FIREBASE_PRIVATE_KEY'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const PUBLIC_SETTING_KEYS = [
|
export const PUBLIC_SETTING_KEYS = [
|
||||||
@@ -133,7 +158,13 @@ export const PUBLIC_SETTING_KEYS = [
|
|||||||
'MAINTENANCE_MODE',
|
'MAINTENANCE_MODE',
|
||||||
'LDAP_ENABLED',
|
'LDAP_ENABLED',
|
||||||
'LDAP_USE_LDAPS',
|
'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;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3846
package-lock.json
generated
3846
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ service NotificationsService {
|
|||||||
rpc DeleteAllNotifications (UserIdRequest) returns (MutationResponse);
|
rpc DeleteAllNotifications (UserIdRequest) returns (MutationResponse);
|
||||||
rpc MarkNotificationRead (MarkNotificationReadRequest) returns (MutationResponse);
|
rpc MarkNotificationRead (MarkNotificationReadRequest) returns (MutationResponse);
|
||||||
rpc MarkAllNotificationsRead (UserIdRequest) returns (MutationResponse);
|
rpc MarkAllNotificationsRead (UserIdRequest) returns (MutationResponse);
|
||||||
|
rpc RegisterPushToken (RegisterPushTokenRequest) returns (MutationResponse);
|
||||||
|
rpc UnregisterPushToken (UnregisterPushTokenRequest) returns (MutationResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message UserIdRequest {
|
message UserIdRequest {
|
||||||
@@ -48,3 +50,15 @@ message MarkNotificationReadRequest {
|
|||||||
message MutationResponse {
|
message MutationResponse {
|
||||||
int32 count = 1;
|
int32 count = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message RegisterPushTokenRequest {
|
||||||
|
string userId = 1;
|
||||||
|
string token = 2;
|
||||||
|
string platform = 3;
|
||||||
|
optional string deviceLabel = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnregisterPushTokenRequest {
|
||||||
|
string userId = 1;
|
||||||
|
string token = 2;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ service SettingsService {
|
|||||||
rpc UpsertSocialProvider (UpsertSocialProviderRequest) returns (SocialProvider);
|
rpc UpsertSocialProvider (UpsertSocialProviderRequest) returns (SocialProvider);
|
||||||
rpc DeleteSocialProvider (ProviderNameRequest) returns (MutationCountResponse);
|
rpc DeleteSocialProvider (ProviderNameRequest) returns (MutationCountResponse);
|
||||||
rpc TestMessagingDelivery (TestMessagingDeliveryRequest) returns (TestMessagingDeliveryResponse);
|
rpc TestMessagingDelivery (TestMessagingDeliveryRequest) returns (TestMessagingDeliveryResponse);
|
||||||
|
rpc TestFirebasePush (TestFirebasePushRequest) returns (TestFirebasePushResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message Empty {}
|
message Empty {}
|
||||||
@@ -236,6 +237,15 @@ message TestMessagingDeliveryResponse {
|
|||||||
optional string message = 4;
|
optional string message = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message TestFirebasePushRequest {
|
||||||
|
string userId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TestFirebasePushResponse {
|
||||||
|
string message = 1;
|
||||||
|
int32 sentCount = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message TotpStatusResponse {
|
message TotpStatusResponse {
|
||||||
bool isEnabled = 1;
|
bool isEnabled = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user