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; } // Очередь 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 { const notifiedUserIds = new Set(); // 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([ 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 { 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 { // Базовые условия: тип события + организация + активная подписка 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 { 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 | 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 { 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 { 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 { 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 { 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 { await db .delete(notificationSubscriptions) .where( and( eq(notificationSubscriptions.id, id), eq(notificationSubscriptions.userId, userId), eq(notificationSubscriptions.organizationId, organizationId) ) ); return true; } // Инициализация типов событий в базе async initializeEventTypes(): Promise { 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 { // Подавляем только если у пользователя есть активное 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, actions?: Array<{ action: string; title: string }> ): Promise { 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, 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 { 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; }): Promise { 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 { 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 { 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 { 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();