fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View 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();