901 lines
34 KiB
TypeScript
901 lines
34 KiB
TypeScript
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' (SW-обработчики кнопок «Ответить»/«Прочитано»),
|
||
// остальные — с реальным типом, чтобы не глушились suppression-логикой активного чата
|
||
type: isChatNotification ? 'chat' : (data.type || 'notification'),
|
||
}
|
||
: 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 getSubscriptionById(id: number, organizationId: number): Promise<NotificationSubscription | null> {
|
||
const [subscription] = await db
|
||
.select()
|
||
.from(notificationSubscriptions)
|
||
.where(
|
||
and(
|
||
eq(notificationSubscriptions.id, id),
|
||
eq(notificationSubscriptions.organizationId, organizationId)
|
||
)
|
||
);
|
||
return subscription || null;
|
||
}
|
||
|
||
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();
|