fix(chat): подавлять push-уведомления при открытом активном чате и автоотметка прочтения в task chat

- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat

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

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

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

- SW: подавляет foreground/system push, если активный чат совпадает
This commit is contained in:
2026-07-16 18:59:00 +03:00
parent 63c83a059d
commit 7672dd98df
9 changed files with 317 additions and 1 deletions

View File

@@ -108,6 +108,40 @@ export function registerPushRoutes(app: import("express").Express): void {
res.status(500).json({ error: 'Ошибка обновления статуса' });
}
});
// Update the chat currently open on the user's visible tab.
// Used by the server to suppress push notifications for active chats.
app.post('/api/devices/active-chat', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
const { type, id, visible } = req.body;
if (!['messenger', 'task'].includes(type) || typeof id !== 'number' || typeof visible !== 'boolean') {
return res.status(400).json({ error: 'Некорректные параметры активного чата' });
}
await storage.updateUserActiveChat(userId, organizationId, type, id, visible);
res.json({ success: true });
} catch (error) {
console.error('Active chat update error:', error);
res.status(500).json({ error: 'Ошибка обновления активного чата' });
}
});
// Clear active chat when the user leaves the chat page.
app.delete('/api/devices/active-chat', authenticateToken, async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.userId;
const organizationId = req.user!.organizationId;
await storage.clearUserActiveChat(userId, organizationId);
res.json({ success: true });
} catch (error) {
console.error('Active chat clear error:', error);
res.status(500).json({ error: 'Ошибка сброса активного чата' });
}
});
// Get user's registered devices
app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => {

View File

@@ -13,6 +13,7 @@ import {
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 {
@@ -582,6 +583,32 @@ export class NotificationService {
// === 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 уведомления.
@@ -596,6 +623,18 @@ export class NotificationService {
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) {
@@ -716,6 +755,13 @@ export class NotificationService {
// 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,

View File

@@ -394,6 +394,88 @@ export class ContentStorage extends DataTablesStorage {
return userIds.filter(id => !onlineUserIds.has(id));
}
async updateUserActiveChat(
userId: number,
organizationId: number,
type: 'messenger' | 'task',
id: number,
visible: boolean
): Promise<void> {
const existing = await db.select().from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
))
.limit(1);
if (existing.length > 0) {
await db.update(userPresence)
.set({
activeChatType: type,
activeChatId: id,
activeChatVisible: visible,
activeChatUpdatedAt: new Date(),
lastSeenAt: new Date(),
})
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
} else {
await db.insert(userPresence).values({
userId,
organizationId,
isOnline: true,
lastSeenAt: new Date(),
activeDevices: 0,
activeChatType: type,
activeChatId: id,
activeChatVisible: visible,
activeChatUpdatedAt: new Date(),
});
}
}
async clearUserActiveChat(userId: number, organizationId: number): Promise<void> {
await db.update(userPresence)
.set({
activeChatType: null,
activeChatId: null,
activeChatVisible: false,
activeChatUpdatedAt: new Date(),
})
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
}
async getUserActiveChat(userId: number, organizationId: number): Promise<{
type: 'messenger' | 'task' | null;
id: number | null;
visible: boolean;
updatedAt: Date | null;
} | null> {
const [presence] = await db.select({
activeChatType: userPresence.activeChatType,
activeChatId: userPresence.activeChatId,
activeChatVisible: userPresence.activeChatVisible,
activeChatUpdatedAt: userPresence.activeChatUpdatedAt,
}).from(userPresence)
.where(and(
eq(userPresence.userId, userId),
eq(userPresence.organizationId, organizationId)
));
if (!presence) return null;
return {
type: (presence.activeChatType as 'messenger' | 'task') || null,
id: presence.activeChatId ?? null,
visible: presence.activeChatVisible ?? false,
updatedAt: presence.activeChatUpdatedAt ?? null,
};
}
// Web Push Subscriptions
async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> {
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));