fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
839
server/services/notification.service.ts
Normal file
839
server/services/notification.service.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
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";
|
||||
|
||||
// Интерфейс для 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-уведомление в очередь для офлайн-пользователей.
|
||||
* Вызывается после создания 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 {
|
||||
// Формируем правильный 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)) {
|
||||
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();
|
||||
Reference in New Issue
Block a user