Files
iistwin/server/services/notification.service.ts
Ильяс Султанов 7672dd98df fix(chat): подавлять push-уведомления при открытом активном чате и автоотметка прочтения в task chat
- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat

- Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке

- Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx

- TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке

- SW: подавляет foreground/system push, если активный чат совпадает
2026-07-16 18:59:00 +03:00

886 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { db } from "../db";
import {
notificationEventTypes,
notificationSubscriptions,
userNotifications,
deviceTokens,
userPresence,
type NotificationEvent,
type NotificationSubscription,
type NotificationEventType,
type UserNotification
} from "@shared/schema";
import { webPushService } from "./web-push.service";
import { eq, and, or, isNull, inArray, sql } from "drizzle-orm";
import { storage } from "../storage";
import { eventBus } from "../routes/shared";
// Интерфейс для push-уведомления (для отправки через Pushy)
export interface PushNotificationPayload {
userId: number;
organizationId: number;
tokens: string[];
title: string;
body: string;
data?: Record<string, string>;
}
// Очередь push-уведомлений (для обработки pushService)
const pushQueue: PushNotificationPayload[] = [];
// Предопределённые типы событий
export const EVENT_TYPES = {
// Task events
TASK_CREATED: 'task.created',
TASK_STATUS_CHANGED: 'task.status.changed',
TASK_ASSIGNED: 'task.assigned',
TASK_FIELD_CHANGED: 'task.field.changed',
TASK_COMMENT_CREATED: 'task.comment.created',
TASK_COMMENT_REPLIED: 'task.comment.replied',
TASK_MENTIONED: 'task.mentioned',
TASK_SUBTASK_CREATED: 'task.subtask.created',
// Form events
FORM_STRUCTURE_CHANGED: 'form.structure.changed',
// System events
SYSTEM_ANNOUNCEMENT: 'system.announcement',
// Reminder events
TASK_REMINDER: 'task.reminder',
// Reaction events
MESSAGE_REACTION_RECEIVED: 'message.reaction.received',
// Messenger events
MESSENGER_MESSAGE_CREATED: 'messenger.message_created',
} as const;
// Категории событий с человекочитаемыми названиями
export const EVENT_TYPE_DEFINITIONS: Array<{
code: string;
name: string;
description: string;
category: string;
}> = [
{ code: EVENT_TYPES.TASK_CREATED, name: 'Новая задача', description: 'Создана новая задача', category: 'task' },
{ code: EVENT_TYPES.TASK_STATUS_CHANGED, name: 'Статус изменён', description: 'Изменён статус задачи', category: 'task' },
{ code: EVENT_TYPES.TASK_ASSIGNED, name: 'Назначение', description: 'Вам назначена задача', category: 'task' },
{ code: EVENT_TYPES.TASK_FIELD_CHANGED, name: 'Поле изменено', description: 'Изменено поле задачи', category: 'task' },
{ code: EVENT_TYPES.TASK_COMMENT_CREATED, name: 'Новый комментарий', description: 'Добавлен комментарий к задаче', category: 'chat' },
{ code: EVENT_TYPES.TASK_COMMENT_REPLIED, name: 'Ответ на комментарий', description: 'Ответ на ваш комментарий', category: 'chat' },
{ code: EVENT_TYPES.TASK_MENTIONED, name: 'Упоминание', description: 'Вас упомянули в комментарии', category: 'chat' },
{ code: EVENT_TYPES.TASK_SUBTASK_CREATED, name: 'Новая подзадача', description: 'Создана подзадача', category: 'task' },
{ code: EVENT_TYPES.FORM_STRUCTURE_CHANGED, name: 'Структура формы', description: 'Изменена структура формы', category: 'form' },
{ code: EVENT_TYPES.SYSTEM_ANNOUNCEMENT, name: 'Объявление', description: 'Системное объявление', category: 'system' },
{ code: EVENT_TYPES.TASK_REMINDER, name: 'Напоминание', description: 'Напоминание о задаче', category: 'task' },
{ code: EVENT_TYPES.MESSAGE_REACTION_RECEIVED, name: 'Реакция на сообщение', description: 'Кто-то отреагировал на ваше сообщение', category: 'chat' },
{ code: EVENT_TYPES.MESSENGER_MESSAGE_CREATED, name: 'Новое сообщение в мессенджере', description: 'Новое сообщение в личном или групповом чате мессенджера', category: 'chat' },
];
export class NotificationService {
/**
* Обрабатывает событие и создаёт уведомления для подписчиков.
* Уведомления создаются ТОЛЬКО для пользователей с активной подпиской на соответствующий тип события.
* Возвращает массив userId пользователей, которые получили уведомления.
*/
async processEvent(event: NotificationEvent): Promise<number[]> {
const notifiedUserIds = new Set<number>();
// When targetUserIds is explicitly provided, deliver ONLY to those users
// and skip subscriber-based fanout entirely (used for reminders and similar explicit delivery)
if (event.targetUserIds && event.targetUserIds.length > 0) {
const notification = this.buildNotificationContent(event);
for (const userId of event.targetUserIds) {
await this.createNotification({
userId,
organizationId: event.organizationId,
type: event.type,
title: notification.title,
message: notification.message,
taskId: event.taskId,
formId: event.formId,
messageId: event.messageId,
});
notifiedUserIds.add(userId);
}
return Array.from(notifiedUserIds);
}
// 1. Найти подписчиков на это событие (с учётом tenant isolation)
const subscribers = await this.findSubscribers(event);
// 2. Создать уведомления для подписчиков (исключая автора события)
// НЕ рассылаем через fanout события с персональной target-логикой ниже:
// TASK_ASSIGNED → только назначенному,
// TASK_COMMENT_REPLIED → только автору оригинального комментария,
// TASK_MENTIONED → только упомянутым пользователям.
const skipFanoutTypes = new Set<string>([
EVENT_TYPES.TASK_ASSIGNED,
EVENT_TYPES.TASK_COMMENT_REPLIED,
EVENT_TYPES.TASK_MENTIONED,
]);
if (!skipFanoutTypes.has(event.type)) {
for (const subscription of subscribers) {
const userId = subscription.userId;
// Пропускаем автора события и уже уведомлённых
if (userId === event.triggeredBy || notifiedUserIds.has(userId)) {
continue;
}
// Создаём уведомление с соответствующим содержимым
const notification = this.buildNotificationContent(event);
await this.createNotification({
userId,
organizationId: event.organizationId,
type: event.type,
title: notification.title,
message: notification.message,
taskId: event.taskId,
formId: event.formId,
messageId: event.messageId,
});
notifiedUserIds.add(userId);
}
}
// 3. Обработка упоминаний - только для пользователей с подпиской на task.mentioned
if (event.mentionedUserIds && event.mentionedUserIds.length > 0) {
// Найти подписчиков на упоминания
const mentionSubscribers = await this.findSubscribersForEventType(
EVENT_TYPES.TASK_MENTIONED,
event.organizationId,
event.formId,
event.taskId
);
const subscribedUserIds = new Set(mentionSubscribers.map(s => s.userId));
for (const userId of event.mentionedUserIds) {
// Уведомляем только если пользователь подписан на упоминания и не автор
if (userId !== event.triggeredBy && !notifiedUserIds.has(userId) && subscribedUserIds.has(userId)) {
await this.createNotification({
userId,
organizationId: event.organizationId,
type: EVENT_TYPES.TASK_MENTIONED,
title: 'Вас упомянули',
message: event.payload.message || 'Вас упомянули в комментарии',
taskId: event.taskId,
formId: event.formId,
messageId: event.messageId,
});
notifiedUserIds.add(userId);
}
}
}
// 4. Специальная обработка: ответ на комментарий - только если автор подписан
if (event.type === EVENT_TYPES.TASK_COMMENT_REPLIED && event.payload.originalAuthorId) {
const originalAuthorId = event.payload.originalAuthorId;
if (originalAuthorId !== event.triggeredBy && !notifiedUserIds.has(originalAuthorId)) {
// Проверяем, подписан ли автор оригинального сообщения на ответы
const replySubscribers = await this.findSubscribersForEventType(
EVENT_TYPES.TASK_COMMENT_REPLIED,
event.organizationId,
event.formId,
event.taskId
);
const isSubscribed = replySubscribers.some(s => s.userId === originalAuthorId);
if (isSubscribed) {
await this.createNotification({
userId: originalAuthorId,
organizationId: event.organizationId,
type: EVENT_TYPES.TASK_COMMENT_REPLIED,
title: 'Ответ на ваш комментарий',
message: event.payload.message || 'Кто-то ответил на ваш комментарий',
taskId: event.taskId,
formId: event.formId,
messageId: event.messageId,
});
notifiedUserIds.add(originalAuthorId);
}
}
}
// 5. Специальная обработка: назначение задачи - только если пользователь подписан
if (event.type === EVENT_TYPES.TASK_ASSIGNED && event.payload.assignedTo) {
const assignedTo = event.payload.assignedTo;
if (assignedTo !== event.triggeredBy && !notifiedUserIds.has(assignedTo)) {
// Проверяем подписку
const assignSubscribers = await this.findSubscribersForEventType(
EVENT_TYPES.TASK_ASSIGNED,
event.organizationId,
event.formId,
event.taskId
);
const isSubscribed = assignSubscribers.some(s => s.userId === assignedTo);
if (isSubscribed) {
await this.createNotification({
userId: assignedTo,
organizationId: event.organizationId,
type: EVENT_TYPES.TASK_ASSIGNED,
title: 'Вам назначена задача',
message: event.payload.message || `Вам назначена задача "${event.payload.taskTitle}"`,
taskId: event.taskId,
formId: event.formId,
});
notifiedUserIds.add(assignedTo);
} else {
console.warn(
`[Notifications] User ${assignedTo} has no subscription for TASK_ASSIGNED, skipping ` +
`(formId=${event.formId}, taskId=${event.taskId})`
);
}
}
}
return Array.from(notifiedUserIds);
}
/**
* Формирует содержимое уведомления на основе типа события
*/
private buildNotificationContent(event: NotificationEvent): { title: string; message: string } {
switch (event.type) {
case EVENT_TYPES.TASK_CREATED:
return {
title: 'Новая задача',
message: event.payload.message || `Создана новая задача "${event.payload.taskTitle}"`,
};
case EVENT_TYPES.TASK_STATUS_CHANGED:
return {
title: 'Статус изменён',
message: event.payload.message || `Статус задачи изменён на "${event.payload.newStatus}"`,
};
case EVENT_TYPES.TASK_COMMENT_CREATED:
return {
title: 'Новый комментарий',
message: event.payload.message || 'Добавлен новый комментарий к задаче',
};
case EVENT_TYPES.TASK_COMMENT_REPLIED:
return {
title: 'Ответ на комментарий',
message: event.payload.message || 'Ответ на ваш комментарий',
};
case EVENT_TYPES.TASK_ASSIGNED:
return {
title: event.payload.taskTitle || 'Назначение',
message: event.payload.message || `Вам назначена задача "${event.payload.taskTitle}"`,
};
case EVENT_TYPES.TASK_SUBTASK_CREATED:
return {
title: 'Новая подзадача',
message: event.payload.message || 'Создана новая подзадача',
};
case EVENT_TYPES.TASK_REMINDER:
return {
title: event.payload.taskTitle || 'Напоминание о задаче',
message: event.payload.message || `Напоминание о задаче "${event.payload.taskTitle}"`,
};
case EVENT_TYPES.MESSAGE_REACTION_RECEIVED:
return {
title: 'Реакция на сообщение',
message: event.payload.message || 'Кто-то отреагировал на ваше сообщение',
};
case EVENT_TYPES.MESSENGER_MESSAGE_CREATED:
return {
title: event.payload.chatName || 'Новое сообщение',
message: event.payload.message || 'Новое сообщение в мессенджере',
};
default:
return {
title: 'Уведомление',
message: event.payload.message || 'У вас новое уведомление',
};
}
}
/**
* Находит подписчиков для конкретного типа события (используется для проверки подписок)
*/
private async findSubscribersForEventType(
eventTypeCode: string,
organizationId: number,
formId?: number,
taskId?: number
): Promise<NotificationSubscription[]> {
const baseCondition = and(
eq(notificationSubscriptions.eventTypeCode, eventTypeCode),
eq(notificationSubscriptions.organizationId, organizationId),
eq(notificationSubscriptions.isActive, true)
);
const targetConditions = [];
targetConditions.push(isNull(notificationSubscriptions.targetType));
targetConditions.push(eq(notificationSubscriptions.targetType, 'all'));
if (formId) {
targetConditions.push(
and(
eq(notificationSubscriptions.targetType, 'form'),
eq(notificationSubscriptions.targetId, formId)
)
);
}
if (taskId) {
targetConditions.push(
and(
eq(notificationSubscriptions.targetType, 'task'),
eq(notificationSubscriptions.targetId, taskId)
)
);
}
return db
.select()
.from(notificationSubscriptions)
.where(
and(
baseCondition,
or(...targetConditions)
)
);
}
/**
* Находит подписчиков на событие с учётом tenant isolation и целевых фильтров.
* ВАЖНО: Обязательно фильтруем по organizationId для tenant isolation!
*/
private async findSubscribers(event: NotificationEvent): Promise<NotificationSubscription[]> {
// Базовые условия: тип события + организация + активная подписка
const baseCondition = and(
eq(notificationSubscriptions.eventTypeCode, event.type),
eq(notificationSubscriptions.organizationId, event.organizationId),
eq(notificationSubscriptions.isActive, true)
);
// Условия для целевого типа (target)
const targetConditions = [];
// Подписка на все события этого типа (targetType = null или 'all')
targetConditions.push(isNull(notificationSubscriptions.targetType));
targetConditions.push(eq(notificationSubscriptions.targetType, 'all'));
// Подписка на конкретную форму
if (event.formId) {
targetConditions.push(
and(
eq(notificationSubscriptions.targetType, 'form'),
eq(notificationSubscriptions.targetId, event.formId)
)
);
}
// Подписка на конкретную задачу
if (event.taskId) {
targetConditions.push(
and(
eq(notificationSubscriptions.targetType, 'task'),
eq(notificationSubscriptions.targetId, event.taskId)
)
);
}
// Комбинируем все условия
const subscriptions = await db
.select()
.from(notificationSubscriptions)
.where(
and(
baseCondition,
or(...targetConditions)
)
);
return subscriptions;
}
/**
* Создаёт уведомление в базе данных и ставит push в очередь для офлайн-пользователей
*/
private async createNotification(data: {
userId: number;
organizationId: number;
type: string;
title: string;
message: string;
taskId?: number;
formId?: number;
messageId?: number;
}): Promise<UserNotification> {
const [notification] = await db
.insert(userNotifications)
.values({
userId: data.userId,
organizationId: data.organizationId,
type: data.type,
title: data.title,
message: data.message,
taskId: data.taskId || null,
messageId: data.messageId || null,
isRead: false,
})
.returning();
// Ставим push в очередь для офлайн-пользователей
// Определяем, является ли это уведомление из чата
const isChatNotification = [
EVENT_TYPES.TASK_COMMENT_CREATED,
EVENT_TYPES.TASK_COMMENT_REPLIED,
EVENT_TYPES.TASK_MENTIONED,
].includes(data.type as any);
const pushData: Record<string, string> | undefined = data.taskId && data.formId
? {
taskId: data.taskId.toString(),
formId: data.formId.toString(),
isChat: isChatNotification ? 'true' : 'false',
...(data.messageId ? { messageId: data.messageId.toString() } : {}),
type: 'chat',
}
: undefined;
const chatActions = isChatNotification
? [
{ action: 'reply', title: 'Ответить' },
{ action: 'read', title: 'Прочитано' },
]
: undefined;
await this.queuePushNotification(
data.userId,
data.organizationId,
data.title,
data.message,
pushData,
chatActions
);
return notification;
}
// === Subscription Management ===
async getEventTypes(): Promise<NotificationEventType[]> {
let types = await db.select().from(notificationEventTypes).where(eq(notificationEventTypes.isActive, true));
if (types.length === 0) {
await this.initializeEventTypes();
types = await db.select().from(notificationEventTypes).where(eq(notificationEventTypes.isActive, true));
}
return types;
}
async getUserSubscriptions(userId: number, organizationId: number): Promise<NotificationSubscription[]> {
return db
.select()
.from(notificationSubscriptions)
.where(
and(
eq(notificationSubscriptions.userId, userId),
eq(notificationSubscriptions.organizationId, organizationId)
)
);
}
async createSubscription(data: {
userId: number;
organizationId: number;
eventTypeCode: string;
targetType?: string;
targetId?: number;
channels?: string[];
}): Promise<NotificationSubscription> {
const [subscription] = await db
.insert(notificationSubscriptions)
.values({
userId: data.userId,
organizationId: data.organizationId,
eventTypeCode: data.eventTypeCode,
targetType: data.targetType || null,
targetId: data.targetId || null,
channels: data.channels || ['app'],
isActive: true,
})
.onConflictDoUpdate({
target: [
notificationSubscriptions.userId,
notificationSubscriptions.eventTypeCode,
notificationSubscriptions.targetType,
notificationSubscriptions.targetId
],
set: {
channels: data.channels || ['app'],
isActive: true,
updatedAt: new Date(),
},
})
.returning();
return subscription;
}
async updateSubscription(
id: number,
userId: number,
organizationId: number,
updates: { channels?: string[]; isActive?: boolean }
): Promise<NotificationSubscription | null> {
const [subscription] = await db
.update(notificationSubscriptions)
.set({
...updates,
updatedAt: new Date(),
})
.where(
and(
eq(notificationSubscriptions.id, id),
eq(notificationSubscriptions.userId, userId),
eq(notificationSubscriptions.organizationId, organizationId)
)
)
.returning();
return subscription || null;
}
async deleteSubscription(id: number, userId: number, organizationId: number): Promise<boolean> {
await db
.delete(notificationSubscriptions)
.where(
and(
eq(notificationSubscriptions.id, id),
eq(notificationSubscriptions.userId, userId),
eq(notificationSubscriptions.organizationId, organizationId)
)
);
return true;
}
// Инициализация типов событий в базе
async initializeEventTypes(): Promise<void> {
for (const eventType of EVENT_TYPE_DEFINITIONS) {
await db
.insert(notificationEventTypes)
.values({
code: eventType.code,
name: eventType.name,
description: eventType.description,
category: eventType.category,
isActive: true,
})
.onConflictDoNothing();
}
}
// === Push Notification Methods ===
/**
* Проверяет, нужно ли подавить видимый push, потому что пользователь уже
* смотрит в соответствующий чат на активной видимой вкладке.
*/
private async shouldSkipPushForActiveChat(
userId: number,
organizationId: number,
chatType: 'messenger' | 'task',
chatId: number
): Promise<boolean> {
// Подавляем только если у пользователя есть активное SSE-соединение,
// т.е. открыто приложение.
if (!eventBus.isUserConnected(userId, organizationId)) return false;
const activeChat = await storage.getUserActiveChat(userId, organizationId);
if (!activeChat) return false;
if (!activeChat.visible || activeChat.type !== chatType || activeChat.id !== chatId) return false;
// Игнорируем устаревшие записи (например, вкладка закрылась, но не успела
// сообщить об этом).
const maxAgeMs = 60 * 1000;
if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt.getTime() > maxAgeMs) return false;
return true;
}
/**
* Добавляет push-уведомление в очередь для офлайн-пользователей.
* Вызывается после создания in-app уведомления.
* Также отправляет Web Push уведомления немедленно.
*/
async queuePushNotification(
userId: number,
organizationId: number,
title: string,
body: string,
data?: Record<string, string>,
actions?: Array<{ action: string; title: string }>
): Promise<void> {
try {
// Если пользователь уже смотрит в task-чат — не присылаем видимый push.
if (data?.type === 'chat' && data?.taskId) {
const taskId = parseInt(data.taskId, 10);
if (!isNaN(taskId)) {
const shouldSkip = await this.shouldSkipPushForActiveChat(userId, organizationId, 'task', taskId);
if (shouldSkip) {
console.log(`[PushSuppress] Skipping push for user ${userId}: active task chat ${taskId}`);
return;
}
}
}
// Формируем правильный URL для открытия задачи
let url = '/';
if (data?.taskId && data?.formId) {
// Добавляем tab=chat для уведомлений из чата
const isChat = data?.isChat === 'true';
url = `/forms/${data.formId}/tasks/${data.taskId}${isChat ? '?tab=chat' : ''}`;
}
// Отправляем Web Push уведомление немедленно (независимо от онлайн-статуса)
const webPushSent = await webPushService.sendToUser(userId, organizationId, {
title,
body,
icon: '/icon-192.png',
url,
data: data as Record<string, any>,
actions,
});
if (webPushSent > 0) {
console.log(`[WebPush] Sent ${webPushSent} notification(s) to user ${userId}`);
}
// Также ставим Pushy push в очередь для офлайн-пользователей с мобильными приложениями
const isOnline = await storage.isUserOnline(userId, organizationId);
if (!isOnline) {
// Получаем токены устройств пользователя (с tenant isolation)
const tokens = await storage.getDeviceTokensByUser(userId, organizationId);
if (tokens.length > 0) {
pushQueue.push({
userId,
organizationId,
tokens: tokens.map(t => t.token),
title,
body,
data,
});
console.log(`Pushy push queued for offline user ${userId} in org ${organizationId}, devices: ${tokens.length}`);
}
}
} catch (error) {
console.error('Error sending push notification:', error);
}
}
/**
* Получает и очищает очередь push-уведомлений.
* Используется внешним push-сервисом (Pushy) для отправки.
*/
getPendingPushNotifications(): PushNotificationPayload[] {
const pending = [...pushQueue];
pushQueue.length = 0;
return pending;
}
/**
* Проверяет, есть ли pending push-уведомления.
*/
hasPendingPushNotifications(): boolean {
return pushQueue.length > 0;
}
/**
* Получает список офлайн-пользователей из списка (с tenant isolation).
*/
async getOfflineUserIds(userIds: number[], organizationId: number): Promise<number[]> {
return storage.getOfflineUsers(userIds, organizationId);
}
/**
* Доставляет уведомление о новом сообщении в мессенджере всем участникам разговора.
* Учитывает mute-статус (пропускает push для заглушённых чатов, кроме @mentions).
* Отправляет только пользователям с активной подпиской на messenger.message_created.
*/
async notifyMessengerMessage(params: {
conversationId: number;
authorId: number;
authorName: string;
chatName: string;
chatType: 'direct' | 'group' | 'bot_direct';
messageText: string;
mentionedUserIds?: number[];
memberOrgMap: Map<number, { orgId: number; mutedAt: Date | null }>;
}): Promise<void> {
const { conversationId, authorId, authorName, chatName, chatType, messageText, mentionedUserIds, memberOrgMap } = params;
const mentionedSet = new Set(mentionedUserIds ?? []);
const msgSnippet = messageText.length > 100 ? messageText.slice(0, 100) + '…' : messageText || '📎 Вложение';
const pushBody = chatType === 'group' ? `${authorName}: ${msgSnippet}` : msgSnippet;
for (const [memberId, { orgId, mutedAt }] of memberOrgMap) {
if (memberId === authorId) continue;
const isMuted = mutedAt !== null && mutedAt !== undefined;
const isMentioned = mentionedSet.has(memberId);
// Проверяем подписку пользователя на messenger.message_created
const subscriptions = await db
.select()
.from(notificationSubscriptions)
.where(
and(
eq(notificationSubscriptions.userId, memberId),
eq(notificationSubscriptions.organizationId, orgId),
eq(notificationSubscriptions.eventTypeCode, EVENT_TYPES.MESSENGER_MESSAGE_CREATED),
eq(notificationSubscriptions.isActive, true),
)
)
.limit(1);
const sub = subscriptions[0];
if (!sub) continue;
const channels: string[] = (sub.channels as string[]) ?? ['app'];
// Push-уведомление: пропускаем для заглушённых чатов,
// КРОМЕ случая когда пользователь упомянут через @mention
if (channels.includes('push') && (!isMuted || isMentioned)) {
// Не шлём push, если пользователь уже смотрит в этот мессенджерный чат.
const shouldSkip = await this.shouldSkipPushForActiveChat(memberId, orgId, 'messenger', conversationId);
if (shouldSkip) {
console.log(`[PushSuppress] Skipping messenger push for user ${memberId}: active conv ${conversationId}`);
continue;
}
try {
await webPushService.sendToUser(memberId, orgId, {
title: chatName,
body: pushBody,
icon: '/icon-192.png',
url: `/chat/${conversationId}`,
tag: `messenger-conv-${conversationId}`,
data: {
type: 'messenger',
conversationId: conversationId.toString(),
},
actions: [
{ action: 'reply', title: 'Ответить' },
{ action: 'read', title: 'Прочитано' },
],
});
} catch (err) {
console.error('[MessengerNotify] Error sending web push:', err);
}
}
}
}
/**
* Создаёт уведомление о реакции на сообщение непосредственно для автора сообщения.
* Не использует систему подписок — реакции доставляются напрямую, только автору.
*/
async notifyMessageReaction(params: {
reactorId: number;
reactorName: string;
messageAuthorId: number;
emoji: string;
organizationId: number;
taskId?: number;
formId?: number;
messageId?: number;
}): Promise<void> {
if (params.reactorId === params.messageAuthorId) return;
const message = `${params.reactorName} отреагировал ${params.emoji} на ваше сообщение`;
await this.createNotification({
userId: params.messageAuthorId,
organizationId: params.organizationId,
type: EVENT_TYPES.MESSAGE_REACTION_RECEIVED,
title: 'Реакция на сообщение',
message,
taskId: params.taskId,
formId: params.formId,
messageId: params.messageId,
});
}
async createDefaultSubscriptions(userId: number, organizationId: number): Promise<void> {
const defaults = [
EVENT_TYPES.TASK_ASSIGNED,
EVENT_TYPES.TASK_COMMENT_REPLIED,
EVENT_TYPES.TASK_MENTIONED,
EVENT_TYPES.MESSAGE_REACTION_RECEIVED,
EVENT_TYPES.MESSENGER_MESSAGE_CREATED,
];
for (const code of defaults) {
try {
const existing = await db
.select({ id: notificationSubscriptions.id })
.from(notificationSubscriptions)
.where(
and(
eq(notificationSubscriptions.userId, userId),
eq(notificationSubscriptions.organizationId, organizationId),
eq(notificationSubscriptions.eventTypeCode, code),
isNull(notificationSubscriptions.targetType),
isNull(notificationSubscriptions.targetId)
)
)
.limit(1);
if (existing.length === 0) {
await db.insert(notificationSubscriptions).values({
userId,
organizationId,
eventTypeCode: code,
channels: ['app', 'push'],
isActive: true,
});
}
} catch (err) {
console.error(`[Notifications] Failed to create default subscription ${code} for user ${userId}:`, err);
}
}
}
async cleanupDuplicateSubscriptions(): Promise<number> {
try {
const result = await db.execute(sql`
DELETE FROM notification_subscriptions a
USING notification_subscriptions b
WHERE a.id > b.id
AND a.user_id = b.user_id
AND a.organization_id = b.organization_id
AND a.event_type_code = b.event_type_code
AND (
(a.target_type IS NULL AND b.target_type IS NULL)
OR a.target_type = b.target_type
)
AND (
(a.target_id IS NULL AND b.target_id IS NULL)
OR a.target_id = b.target_id
)
`);
const count = (result as any)?.rowCount ?? 0;
if (count > 0) {
console.log(`[Notifications] Cleaned up ${count} duplicate subscription(s)`);
}
return count;
} catch (err) {
console.error('[Notifications] Failed to clean up duplicate subscriptions:', err);
return 0;
}
}
}
export const notificationService = new NotificationService();