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:
@@ -6,6 +6,11 @@ const STATIC_ASSETS = [
|
|||||||
'/favicon.svg'
|
'/favicon.svg'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Активный чат, о котором сообщает клиент через postMessage.
|
||||||
|
// Используется для подавления push-уведомлений, когда пользователь уже
|
||||||
|
// смотрит в соответствующий чат.
|
||||||
|
let activeChat = null; // { type: 'messenger'|'task', id: number, visible: boolean, updatedAt: number }
|
||||||
|
|
||||||
// ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ────
|
// ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ────
|
||||||
// Schema history:
|
// Schema history:
|
||||||
// v1 – `queue` store (keyPath: id, index: enqueuedAt)
|
// v1 – `queue` store (keyPath: id, index: enqueuedAt)
|
||||||
@@ -480,6 +485,26 @@ async function sendToForegroundClients(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldSuppressPushForActiveChat(payload) {
|
||||||
|
if (!activeChat || !activeChat.visible) return false;
|
||||||
|
|
||||||
|
// Игнорируем устаревшие записи.
|
||||||
|
const maxAgeMs = 60 * 1000;
|
||||||
|
if (!activeChat.updatedAt || Date.now() - activeChat.updatedAt > maxAgeMs) return false;
|
||||||
|
|
||||||
|
const nestedType = payload.data?.type;
|
||||||
|
|
||||||
|
if (nestedType === 'messenger' && activeChat.type === 'messenger') {
|
||||||
|
return parseInt(payload.data?.conversationId, 10) === activeChat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((nestedType === 'chat' || payload.type === 'chat') && activeChat.type === 'task') {
|
||||||
|
return parseInt(payload.data?.taskId || payload.taskId, 10) === activeChat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
console.log('[SW] Push notification received');
|
console.log('[SW] Push notification received');
|
||||||
|
|
||||||
@@ -509,6 +534,12 @@ self.addEventListener('push', (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подавляем push, если пользователь уже смотрит в этот чат на активной вкладке.
|
||||||
|
if (shouldSuppressPushForActiveChat(data)) {
|
||||||
|
console.log('[SW] Push suppressed for active chat:', activeChat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
hasVisibleClient().then((visible) => {
|
hasVisibleClient().then((visible) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
@@ -620,5 +651,21 @@ self.addEventListener('pushsubscriptionchange', (event) => {
|
|||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', (event) => {
|
||||||
if (event.data?.type === 'SKIP_WAITING') {
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'ACTIVE_CHAT') {
|
||||||
|
const payload = event.data?.payload || {};
|
||||||
|
activeChat = {
|
||||||
|
type: payload.type,
|
||||||
|
id: payload.id,
|
||||||
|
visible: !!payload.visible,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === 'CLEAR_ACTIVE_CHAT') {
|
||||||
|
activeChat = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Send, User, AtSign, Reply, X, Edit, Check, CheckCheck, MoreVertical, Bot, Link as LinkIcon, Clock, BarChart2, Paperclip, Loader2 } from 'lucide-react';
|
import { Send, User, AtSign, Reply, X, Edit, Check, CheckCheck, MoreVertical, Bot, Link as LinkIcon, Clock, BarChart2, Paperclip, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isPWAInstalled } from '@/lib/pwa';
|
import { isPWAInstalled, reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
|
||||||
import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
|
import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
|
||||||
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
|
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
@@ -238,6 +238,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
// Мгновенно добавляем новое сообщение если оно относится к текущей задаче
|
// Мгновенно добавляем новое сообщение если оно относится к текущей задаче
|
||||||
// Проверяем author для сообщений пользователей, bot для сообщений ботов
|
// Проверяем author для сообщений пользователей, bot для сообщений ботов
|
||||||
const isFromCurrentUser = data.message.author && user && data.message.author.id === user.id;
|
const isFromCurrentUser = data.message.author && user && data.message.author.id === user.id;
|
||||||
|
const isVisible = typeof document !== 'undefined' && document.visibilityState === 'visible';
|
||||||
|
|
||||||
if (data.taskId === taskId && !isFromCurrentUser) {
|
if (data.taskId === taskId && !isFromCurrentUser) {
|
||||||
// Обновляем кэш с новым сообщением
|
// Обновляем кэш с новым сообщением
|
||||||
@@ -256,6 +257,11 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
messages: [...currentMessages, data.message]
|
messages: [...currentMessages, data.message]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Автоотметка прочтения для видимого открытого чата.
|
||||||
|
if (isVisible && data.message.id > 0) {
|
||||||
|
markAsReadMutation.mutate(data.message.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReactionUpdated: (data) => {
|
onReactionUpdated: (data) => {
|
||||||
@@ -508,6 +514,29 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
|
|||||||
}
|
}
|
||||||
}, [messages.length, isLoading, user, hasMarkedInitialMessages]);
|
}, [messages.length, isLoading, user, hasMarkedInitialMessages]);
|
||||||
|
|
||||||
|
// Сообщаем серверу и SW, что этот task-чат открыт, чтобы подавить push.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskId || taskId <= 0) return;
|
||||||
|
|
||||||
|
const report = (visible: boolean) => {
|
||||||
|
reportActiveChat('task', taskId, visible);
|
||||||
|
setActiveChatInSw('task', taskId, visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
report(document.visibilityState === 'visible');
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
report(document.visibilityState === 'visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
clearActiveChat();
|
||||||
|
clearActiveChatInSw();
|
||||||
|
};
|
||||||
|
}, [taskId]);
|
||||||
|
|
||||||
// Keep activeQueueIdsRef in sync with IndexedDB whenever the queue changes.
|
// Keep activeQueueIdsRef in sync with IndexedDB whenever the queue changes.
|
||||||
// The offline-queue-updated event fires on enqueue (queryClient.ts) and on
|
// The offline-queue-updated event fires on enqueue (queryClient.ts) and on
|
||||||
// successful item removal (useOfflineSync.ts), so this covers all transitions.
|
// successful item removal (useOfflineSync.ts), so this covers all transitions.
|
||||||
|
|||||||
@@ -256,6 +256,46 @@ export async function initWebPush(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Active chat reporting (used to suppress push for open chats) ──────────────
|
||||||
|
|
||||||
|
export async function reportActiveChat(
|
||||||
|
type: 'messenger' | 'task',
|
||||||
|
id: number,
|
||||||
|
visible: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiRequest('POST', '/api/devices/active-chat', { type, id, visible });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ActiveChat] Failed to report active chat:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearActiveChat(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiRequest('DELETE', '/api/devices/active-chat');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ActiveChat] Failed to clear active chat:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveChatInSw(
|
||||||
|
type: 'messenger' | 'task',
|
||||||
|
id: number,
|
||||||
|
visible: boolean
|
||||||
|
): void {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
const controller = navigator.serviceWorker.controller;
|
||||||
|
if (!controller) return;
|
||||||
|
controller.postMessage({ type: 'ACTIVE_CHAT', payload: { type, id, visible } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearActiveChatInSw(): void {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
const controller = navigator.serviceWorker.controller;
|
||||||
|
if (!controller) return;
|
||||||
|
controller.postMessage({ type: 'CLEAR_ACTIVE_CHAT' });
|
||||||
|
}
|
||||||
|
|
||||||
export function initPWA(): void {
|
export function initPWA(): void {
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
dispatchAttachmentQueueUpdated,
|
dispatchAttachmentQueueUpdated,
|
||||||
} from '@/lib/attachmentQueue';
|
} from '@/lib/attachmentQueue';
|
||||||
import { getAll as getAllQueued } from '@/lib/offlineQueue';
|
import { getAll as getAllQueued } from '@/lib/offlineQueue';
|
||||||
|
import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useEvents } from '@/hooks/useEvents';
|
import { useEvents } from '@/hooks/useEvents';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -1048,6 +1049,29 @@ export default function ChatPage() {
|
|||||||
};
|
};
|
||||||
}, [activeConvId]);
|
}, [activeConvId]);
|
||||||
|
|
||||||
|
// Report active chat to server and SW so push is suppressed while this chat is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeConvId) return;
|
||||||
|
|
||||||
|
const report = (visible: boolean) => {
|
||||||
|
reportActiveChat('messenger', activeConvId, visible);
|
||||||
|
setActiveChatInSw('messenger', activeConvId, visible);
|
||||||
|
};
|
||||||
|
|
||||||
|
report(document.visibilityState === 'visible');
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
report(document.visibilityState === 'visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
clearActiveChat();
|
||||||
|
clearActiveChatInSw();
|
||||||
|
};
|
||||||
|
}, [activeConvId]);
|
||||||
|
|
||||||
const activeConv = conversations.find(c => c.id === activeConvId) ?? null;
|
const activeConv = conversations.find(c => c.id === activeConvId) ?? null;
|
||||||
|
|
||||||
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({
|
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Добавляем информацию об активном чате пользователя для подавления push-уведомлений,
|
||||||
|
-- когда получатель уже смотрит в нужный чат на видимой вкладке.
|
||||||
|
|
||||||
|
ALTER TABLE user_presence
|
||||||
|
ADD COLUMN IF NOT EXISTS active_chat_type text,
|
||||||
|
ADD COLUMN IF NOT EXISTS active_chat_id integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS active_chat_visible boolean DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS active_chat_updated_at timestamp DEFAULT now();
|
||||||
@@ -108,6 +108,40 @@ export function registerPushRoutes(app: import("express").Express): void {
|
|||||||
res.status(500).json({ error: 'Ошибка обновления статуса' });
|
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
|
// Get user's registered devices
|
||||||
app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => {
|
app.get('/api/devices', authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { webPushService } from "./web-push.service";
|
import { webPushService } from "./web-push.service";
|
||||||
import { eq, and, or, isNull, inArray, sql } from "drizzle-orm";
|
import { eq, and, or, isNull, inArray, sql } from "drizzle-orm";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
|
import { eventBus } from "../routes/shared";
|
||||||
|
|
||||||
// Интерфейс для push-уведомления (для отправки через Pushy)
|
// Интерфейс для push-уведомления (для отправки через Pushy)
|
||||||
export interface PushNotificationPayload {
|
export interface PushNotificationPayload {
|
||||||
@@ -582,6 +583,32 @@ export class NotificationService {
|
|||||||
|
|
||||||
// === Push Notification Methods ===
|
// === 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-уведомление в очередь для офлайн-пользователей.
|
* Добавляет push-уведомление в очередь для офлайн-пользователей.
|
||||||
* Вызывается после создания in-app уведомления.
|
* Вызывается после создания in-app уведомления.
|
||||||
@@ -596,6 +623,18 @@ export class NotificationService {
|
|||||||
actions?: Array<{ action: string; title: string }>
|
actions?: Array<{ action: string; title: string }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
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 для открытия задачи
|
// Формируем правильный URL для открытия задачи
|
||||||
let url = '/';
|
let url = '/';
|
||||||
if (data?.taskId && data?.formId) {
|
if (data?.taskId && data?.formId) {
|
||||||
@@ -716,6 +755,13 @@ export class NotificationService {
|
|||||||
// Push-уведомление: пропускаем для заглушённых чатов,
|
// Push-уведомление: пропускаем для заглушённых чатов,
|
||||||
// КРОМЕ случая когда пользователь упомянут через @mention
|
// КРОМЕ случая когда пользователь упомянут через @mention
|
||||||
if (channels.includes('push') && (!isMuted || isMentioned)) {
|
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 {
|
try {
|
||||||
await webPushService.sendToUser(memberId, orgId, {
|
await webPushService.sendToUser(memberId, orgId, {
|
||||||
title: chatName,
|
title: chatName,
|
||||||
|
|||||||
@@ -394,6 +394,88 @@ export class ContentStorage extends DataTablesStorage {
|
|||||||
return userIds.filter(id => !onlineUserIds.has(id));
|
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
|
// Web Push Subscriptions
|
||||||
async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> {
|
async registerWebPushSubscription(userId: number, organizationId: number, endpoint: string, subscription: string): Promise<void> {
|
||||||
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));
|
await db.delete(webPushSubscriptions).where(eq(webPushSubscriptions.endpoint, endpoint));
|
||||||
|
|||||||
@@ -2493,6 +2493,12 @@ export const userPresence = pgTable("user_presence", {
|
|||||||
isOnline: boolean("is_online").default(false),
|
isOnline: boolean("is_online").default(false),
|
||||||
lastSeenAt: timestamp("last_seen_at").defaultNow(),
|
lastSeenAt: timestamp("last_seen_at").defaultNow(),
|
||||||
activeDevices: integer("active_devices").default(0),
|
activeDevices: integer("active_devices").default(0),
|
||||||
|
// Активный чат: используется сервером, чтобы не слать push, если пользователь
|
||||||
|
// уже смотрит в этот чат на видимой вкладке.
|
||||||
|
activeChatType: text("active_chat_type"), // 'messenger' | 'task'
|
||||||
|
activeChatId: integer("active_chat_id"),
|
||||||
|
activeChatVisible: boolean("active_chat_visible").default(false),
|
||||||
|
activeChatUpdatedAt: timestamp("active_chat_updated_at").defaultNow(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
userIndex: index("user_presence_user_idx").on(table.userId),
|
userIndex: index("user_presence_user_idx").on(table.userId),
|
||||||
onlineIndex: index("user_presence_online_idx").on(table.isOnline),
|
onlineIndex: index("user_presence_online_idx").on(table.isOnline),
|
||||||
|
|||||||
Reference in New Issue
Block a user