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

@@ -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,