feat(realtime): implement SSE realtime notifications; publish events from resolvers; remove polling in chat/sidebar/supplies/warehouse and wire realtime refetch
This commit is contained in:
95
src/app/api/events/route.ts
Normal file
95
src/app/api/events/route.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
import { addClient, removeClient, notifyOrganization, type NotificationEvent } from '@/lib/realtime'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const token = searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
let orgId: string | null = null
|
||||
try {
|
||||
const jwtSecret = process.env.JWT_SECRET
|
||||
if (!jwtSecret) throw new Error('JWT_SECRET not configured')
|
||||
const decoded = jwt.verify(token, jwtSecret) as {
|
||||
userId?: string
|
||||
phone?: string
|
||||
adminId?: string
|
||||
username?: string
|
||||
type?: string
|
||||
organizationId?: string
|
||||
}
|
||||
|
||||
// Only user tokens are supported for SSE for now
|
||||
if (decoded.type === 'admin') {
|
||||
return new Response('Admins not supported for SSE yet', { status: 403 })
|
||||
}
|
||||
|
||||
// For user token, require organization id to route notifications
|
||||
// Clients store it, but token doesn't include; we will not hit DB here to keep things simple.
|
||||
// As a compromise, allow optionally passing orgId via query.
|
||||
orgId = searchParams.get('orgId')
|
||||
if (!orgId) {
|
||||
// Fallback: if token was minted with organizationId claim
|
||||
orgId = (decoded as any).organizationId || null
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return new Response('Organization ID required', { status: 400 })
|
||||
}
|
||||
} catch {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const send = (evt: NotificationEvent) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`))
|
||||
}
|
||||
|
||||
const client = { orgId: orgId!, send }
|
||||
addClient(orgId!, client)
|
||||
|
||||
// Initial hello
|
||||
send({ type: 'connected', payload: { now: Date.now() } })
|
||||
|
||||
// Heartbeat to keep connection alive
|
||||
const intervalId = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`:\n\n`))
|
||||
} catch (e) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
const abortHandler = () => {
|
||||
clearInterval(intervalId)
|
||||
removeClient(orgId!, client)
|
||||
controller.close()
|
||||
}
|
||||
|
||||
// Close on client disconnect
|
||||
req.signal.addEventListener('abort', abortHandler)
|
||||
},
|
||||
cancel() {
|
||||
// no-op
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -23,12 +23,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GET_CONVERSATIONS, GET_INCOMING_REQUESTS, GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
|
||||
// Компонент для отображения логистических заявок (только для логистики)
|
||||
function LogisticsOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
})
|
||||
@ -46,8 +46,7 @@ function LogisticsOrdersNotification() {
|
||||
|
||||
// Компонент для отображения поставок фулфилмента (только поставки, не заявки на партнерство)
|
||||
function FulfillmentSuppliesNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
})
|
||||
@ -65,8 +64,7 @@ function FulfillmentSuppliesNotification() {
|
||||
|
||||
// Компонент для отображения входящих заказов поставщика (только входящие заказы, не заявки на партнерство)
|
||||
function WholesaleOrdersNotification() {
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
})
|
||||
@ -95,21 +93,38 @@ export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean }
|
||||
const { isCollapsed, toggleSidebar } = useSidebar()
|
||||
|
||||
// Загружаем список чатов для подсчета непрочитанных сообщений
|
||||
const { data: conversationsData } = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 60000, // Обновляем каждую минуту в сайдбаре - этого достаточно
|
||||
const { data: conversationsData, refetch: refetchConversations } = useQuery(GET_CONVERSATIONS, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore', // Игнорируем ошибки чтобы не ломать сайдбар
|
||||
notifyOnNetworkStatusChange: false, // Плавные обновления без мерцания
|
||||
})
|
||||
|
||||
// Загружаем входящие заявки для подсчета новых запросов
|
||||
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
||||
pollInterval: 60000, // Обновляем каждую минуту
|
||||
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
notifyOnNetworkStatusChange: false,
|
||||
})
|
||||
|
||||
// Реалтайм обновления бейджей
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
switch (evt.type) {
|
||||
case 'message:new':
|
||||
refetchConversations()
|
||||
break
|
||||
case 'counterparty:request:new':
|
||||
case 'counterparty:request:updated':
|
||||
refetchIncoming()
|
||||
break
|
||||
case 'supply-order:new':
|
||||
case 'supply-order:updated':
|
||||
refetchPending()
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Если уже есть корневой сайдбар и это не корневой экземпляр — не рендерим дубликат
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
|
@ -7,6 +7,7 @@ import React, { useState } from 'react'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
// Импорты компонентов подразделов
|
||||
import { FulfillmentConsumablesOrdersTab } from './fulfillment-supplies/fulfillment-consumables-orders-tab'
|
||||
@ -31,8 +32,7 @@ export function FulfillmentSuppliesDashboard() {
|
||||
const [activeThirdTab, setActiveThirdTab] = useState('new') // новые
|
||||
|
||||
// Загружаем данные о непринятых поставках
|
||||
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
onError: (error) => {
|
||||
@ -40,6 +40,15 @@ export function FulfillmentSuppliesDashboard() {
|
||||
},
|
||||
})
|
||||
|
||||
// Realtime: обновление бейджа
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||
refetchPending()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Логируем ошибку для диагностики
|
||||
React.useEffect(() => {
|
||||
if (pendingError) {
|
||||
|
@ -7,6 +7,7 @@ import React, { useState, useEffect } from 'react'
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
// Импорты компонентов подкатегорий
|
||||
import { FulfillmentConsumablesOrdersTab } from './fulfillment-consumables-orders-tab'
|
||||
@ -33,8 +34,7 @@ export function FulfillmentSuppliesTab() {
|
||||
const [activeTab, setActiveTab] = useState('goods')
|
||||
|
||||
// Загружаем данные о непринятых поставках
|
||||
const { data: pendingData, error: pendingError } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, error: pendingError, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
onError: (error) => {
|
||||
@ -42,6 +42,15 @@ export function FulfillmentSuppliesTab() {
|
||||
},
|
||||
})
|
||||
|
||||
// Realtime: обновление счетчика
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||
refetchPending()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Логируем ошибку для диагностики
|
||||
React.useEffect(() => {
|
||||
if (pendingError) {
|
||||
|
@ -46,6 +46,7 @@ import {
|
||||
} from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
import { WbReturnClaims } from './wb-return-claims'
|
||||
|
||||
@ -238,7 +239,26 @@ export function FulfillmentWarehouseDashboard() {
|
||||
refetch: refetchWarehouseStats,
|
||||
} = useQuery(GET_FULFILLMENT_WAREHOUSE_STATS, {
|
||||
fetchPolicy: 'no-cache', // Принудительно обходим кеш
|
||||
pollInterval: 60000, // Обновляем каждую минуту
|
||||
})
|
||||
|
||||
// Real-time: обновляем ключевые блоки при событиях поставок/склада
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
switch (evt.type) {
|
||||
case 'supply-order:new':
|
||||
case 'supply-order:updated':
|
||||
refetchOrders()
|
||||
refetchWarehouseStats()
|
||||
refetchProducts()
|
||||
refetchSellerSupplies()
|
||||
refetchFulfillmentSupplies()
|
||||
break
|
||||
case 'warehouse:changed':
|
||||
refetchWarehouseStats()
|
||||
refetchFulfillmentSupplies()
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Логируем статистику склада для отладки
|
||||
|
@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { VoicePlayer } from '@/components/ui/voice-player'
|
||||
import { GET_MESSAGES } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
@ -50,17 +51,32 @@ export function MessengerAttachments({ counterparty, onViewChange }: MessengerAt
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; fileName: string; fileSize?: number } | null>(null)
|
||||
|
||||
// Загружаем все сообщения для получения вложений
|
||||
const {
|
||||
data: messagesData,
|
||||
loading,
|
||||
refetch,
|
||||
} = useQuery(GET_MESSAGES, {
|
||||
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||
variables: { counterpartyId: counterparty.id, limit: 1000 },
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: 5000, // Обновляем каждые 5 секунд
|
||||
notifyOnNetworkStatusChange: false, // Не показываем loading при обновлениях
|
||||
})
|
||||
|
||||
// Реалтайм обновление вложений
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type !== 'message:new') return
|
||||
const { senderOrgId, receiverOrgId } = (evt.payload || {}) as {
|
||||
senderOrgId?: string
|
||||
receiverOrgId?: string
|
||||
}
|
||||
if (!user?.organization?.id) return
|
||||
if (
|
||||
senderOrgId === counterparty.id ||
|
||||
receiverOrgId === counterparty.id ||
|
||||
senderOrgId === user.organization.id ||
|
||||
receiverOrgId === user.organization.id
|
||||
) {
|
||||
refetch()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Обновляем данные при открытии вкладки вложений
|
||||
useEffect(() => {
|
||||
onViewChange?.()
|
||||
|
@ -24,6 +24,7 @@ import { GET_MESSAGES, GET_CONVERSATIONS } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
import { MessengerAttachments } from './messenger-attachments'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
@ -68,13 +69,8 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
|
||||
const messageInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Загружаем сообщения с контрагентом
|
||||
const {
|
||||
data: messagesData,
|
||||
loading,
|
||||
refetch,
|
||||
} = useQuery(GET_MESSAGES, {
|
||||
const { data: messagesData, loading, refetch } = useQuery(GET_MESSAGES, {
|
||||
variables: { counterpartyId: counterparty.id },
|
||||
pollInterval: 3000,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
@ -120,6 +116,27 @@ export function MessengerChat({ counterparty, onMessagesRead }: MessengerChatPro
|
||||
|
||||
const messages = useMemo(() => messagesData?.messages || [], [messagesData?.messages])
|
||||
|
||||
// Реалтайм: обновляем чат при поступлении новых сообщений
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type !== 'message:new') return
|
||||
const { senderOrgId, receiverOrgId } = (evt.payload || {}) as {
|
||||
senderOrgId?: string
|
||||
receiverOrgId?: string
|
||||
}
|
||||
if (!user?.organization?.id) return
|
||||
// Refetch if event is for this conversation
|
||||
if (
|
||||
senderOrgId === counterparty.id ||
|
||||
receiverOrgId === counterparty.id ||
|
||||
senderOrgId === user.organization.id ||
|
||||
receiverOrgId === user.organization.id
|
||||
) {
|
||||
refetch()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Отмечаем сообщения как прочитанные только если есть непрочитанные
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && user?.organization?.id && counterparty.id) {
|
||||
|
@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { GET_CONVERSATIONS, GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
import { MessengerChat } from './messenger-chat'
|
||||
import { MessengerConversations } from './messenger-conversations'
|
||||
@ -48,14 +49,19 @@ export function MessengerDashboard() {
|
||||
const [selectedCounterparty, setSelectedCounterparty] = useState<string | null>(null)
|
||||
|
||||
// Загружаем список чатов (conversations) для отображения непрочитанных сообщений
|
||||
const {
|
||||
data: conversationsData,
|
||||
loading: conversationsLoading,
|
||||
refetch: refetchConversations,
|
||||
} = useQuery(GET_CONVERSATIONS, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд - реже, но достаточно
|
||||
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
|
||||
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
|
||||
const { data: conversationsData, loading: conversationsLoading, refetch: refetchConversations } = useQuery(
|
||||
GET_CONVERSATIONS,
|
||||
{
|
||||
fetchPolicy: 'cache-first', // Приоритет кэшу для стабильности
|
||||
notifyOnNetworkStatusChange: false, // Не показываем загрузку при фоновых обновлениях
|
||||
},
|
||||
)
|
||||
|
||||
// Realtime: обновление списка бесед
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'message:new') refetchConversations()
|
||||
},
|
||||
})
|
||||
|
||||
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались
|
||||
|
@ -7,6 +7,7 @@ import { Card } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { GET_INCOMING_REQUESTS } from '@/graphql/queries'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
import { MarketCounterparties } from '../market/market-counterparties'
|
||||
import { MarketFulfillment } from '../market/market-fulfillment'
|
||||
@ -20,12 +21,19 @@ export function PartnersDashboard() {
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
|
||||
// Загружаем входящие заявки для подсветки
|
||||
const { data: incomingRequestsData } = useQuery(GET_INCOMING_REQUESTS, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: incomingRequestsData, refetch: refetchIncoming } = useQuery(GET_INCOMING_REQUESTS, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
})
|
||||
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'counterparty:request:new' || evt.type === 'counterparty:request:updated') {
|
||||
refetchIncoming()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const incomingRequests = incomingRequestsData?.incomingRequests || []
|
||||
const hasIncomingRequests = incomingRequests.length > 0
|
||||
|
||||
|
@ -35,6 +35,7 @@ import {
|
||||
} from '@/graphql/mutations'
|
||||
import { GET_SUPPLY_ORDERS } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
// Типы для данных заказов
|
||||
interface SupplyOrderItem {
|
||||
@ -167,7 +168,15 @@ export function RealSupplyOrdersTab() {
|
||||
const { data, loading, error, refetch } = useQuery(GET_SUPPLY_ORDERS, {
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
pollInterval: 30000, // 🔔 Опрашиваем каждые 30 секунд для получения новых заказов
|
||||
})
|
||||
|
||||
// Realtime: обновление списка заказов
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||
refetch()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Мутация для обновления статуса заказа
|
||||
|
@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
import { useRealtime } from '@/hooks/useRealtime'
|
||||
|
||||
import { AllSuppliesTab } from './fulfillment-supplies/all-supplies-tab'
|
||||
import { RealSupplyOrdersTab } from './fulfillment-supplies/real-supply-orders-tab'
|
||||
@ -39,12 +40,19 @@ export function SuppliesDashboard() {
|
||||
const [statisticsData, setStatisticsData] = useState<any>(null)
|
||||
|
||||
// Загружаем счетчик поставок, требующих одобрения
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||||
const { data: pendingData, refetch: refetchPending } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
fetchPolicy: 'cache-first',
|
||||
errorPolicy: 'ignore',
|
||||
})
|
||||
|
||||
useRealtime({
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === 'supply-order:new' || evt.type === 'supply-order:updated') {
|
||||
refetchPending()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const pendingCount = pendingData?.pendingSuppliesCount
|
||||
// ✅ ПРАВИЛЬНО: Настраиваем уведомления по типам организаций
|
||||
const hasPendingItems = (() => {
|
||||
|
@ -4,6 +4,7 @@ import { GraphQLError, GraphQLScalarType, Kind } from 'graphql'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { notifyMany, notifyOrganization } from '@/lib/realtime'
|
||||
import { DaDataService } from '@/services/dadata-service'
|
||||
import { MarketplaceService } from '@/services/marketplace-service'
|
||||
import { SmsService } from '@/services/sms-service'
|
||||
@ -3336,6 +3337,18 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
// Уведомляем получателя о новой заявке
|
||||
try {
|
||||
notifyOrganization(args.organizationId, {
|
||||
type: 'counterparty:request:new',
|
||||
payload: {
|
||||
requestId: request.id,
|
||||
senderId: request.senderId,
|
||||
receiverId: request.receiverId,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Заявка отправлена',
|
||||
@ -3425,6 +3438,14 @@ export const resolvers = {
|
||||
])
|
||||
}
|
||||
|
||||
// Оповещаем обе стороны об обновлении заявки и возможном изменении списка контрагентов
|
||||
try {
|
||||
notifyMany([request.senderId, request.receiverId], {
|
||||
type: 'counterparty:request:updated',
|
||||
payload: { requestId: updatedRequest.id, status: updatedRequest.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: args.accept ? 'Заявка принята' : 'Заявка отклонена',
|
||||
@ -3597,6 +3618,19 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
// Реалтайм нотификация для обеих организаций (отправитель и получатель)
|
||||
try {
|
||||
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||
type: 'message:new',
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
senderOrgId: message.senderOrganizationId,
|
||||
receiverOrgId: message.receiverOrganizationId,
|
||||
type: message.type,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Сообщение отправлено',
|
||||
@ -3684,6 +3718,18 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||
type: 'message:new',
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
senderOrgId: message.senderOrganizationId,
|
||||
receiverOrgId: message.receiverOrganizationId,
|
||||
type: message.type,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Голосовое сообщение отправлено',
|
||||
@ -3765,6 +3811,18 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||
type: 'message:new',
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
senderOrgId: message.senderOrganizationId,
|
||||
receiverOrgId: message.receiverOrganizationId,
|
||||
type: message.type,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Изображение отправлено',
|
||||
@ -3846,6 +3904,18 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
notifyMany([currentUser.organization.id, args.receiverOrganizationId], {
|
||||
type: 'message:new',
|
||||
payload: {
|
||||
messageId: message.id,
|
||||
senderOrgId: message.senderOrganizationId,
|
||||
receiverOrgId: message.receiverOrganizationId,
|
||||
type: message.type,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Файл отправлен',
|
||||
@ -4225,6 +4295,14 @@ export const resolvers = {
|
||||
description: args.input.description,
|
||||
})
|
||||
|
||||
// Реалтайм: уведомляем о смене складских остатков
|
||||
try {
|
||||
notifyOrganization(currentUser.organization.id, {
|
||||
type: 'warehouse:changed',
|
||||
payload: { supplyId: updatedSupply.id, change: -args.input.quantityUsed },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Использовано ${args.input.quantityUsed} ${updatedSupply.unit} расходника "${updatedSupply.name}"`,
|
||||
@ -4492,6 +4570,20 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
// Реалтайм: уведомляем поставщика и вовлеченные стороны о новом заказе
|
||||
try {
|
||||
const orgIds = [
|
||||
currentUser.organization.id,
|
||||
args.input.partnerId,
|
||||
fulfillmentCenterId || undefined,
|
||||
args.input.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:new',
|
||||
payload: { id: supplyOrder.id, organizationId: currentUser.organization.id },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
// 📦 РЕЗЕРВИРУЕМ ТОВАРЫ У ПОСТАВЩИКА
|
||||
// Увеличиваем поле "ordered" для каждого заказанного товара
|
||||
for (const item of args.input.items) {
|
||||
@ -6361,6 +6453,19 @@ export const resolvers = {
|
||||
console.warn('🎉 Склад организации успешно обновлен!')
|
||||
}
|
||||
|
||||
// Уведомляем вовлеченные организации об изменении статуса заказа
|
||||
try {
|
||||
const orgIds = [
|
||||
existingOrder.organizationId,
|
||||
existingOrder.partnerId,
|
||||
existingOrder.fulfillmentCenterId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Статус заказа поставки обновлен на "${args.status}"`,
|
||||
@ -6467,6 +6572,19 @@ export const resolvers = {
|
||||
newStatus: 'CONFIRMED',
|
||||
})
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
existingOrder.organizationId,
|
||||
existingOrder.partnerId,
|
||||
existingOrder.fulfillmentCenterId || undefined,
|
||||
args.logisticsPartnerId,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Логистика успешно назначена',
|
||||
@ -6590,6 +6708,19 @@ export const resolvers = {
|
||||
})
|
||||
|
||||
console.warn(`[DEBUG] Заказ ${args.id} успешно обновлен до статуса: ${updatedOrder.status}`)
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
updatedOrder.partnerId,
|
||||
updatedOrder.fulfillmentCenterId || undefined,
|
||||
updatedOrder.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Заказ поставки одобрен поставщиком. Товары зарезервированы, остатки обновлены.',
|
||||
@ -6693,6 +6824,19 @@ export const resolvers = {
|
||||
updatedOrder.items.map((item) => `${item.productId}: -${item.quantity} шт.`).join(', '),
|
||||
)
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
updatedOrder.partnerId,
|
||||
updatedOrder.fulfillmentCenterId || undefined,
|
||||
updatedOrder.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: args.reason ? `Заказ отклонен поставщиком. Причина: ${args.reason}` : 'Заказ отклонен поставщиком',
|
||||
@ -6792,6 +6936,19 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
updatedOrder.partnerId,
|
||||
updatedOrder.fulfillmentCenterId || undefined,
|
||||
updatedOrder.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Заказ отправлен поставщиком. Товары переведены в статус 'в пути'.",
|
||||
@ -6859,6 +7016,19 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
updatedOrder.partnerId,
|
||||
updatedOrder.fulfillmentCenterId || undefined,
|
||||
updatedOrder.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Заказ подтвержден логистической компанией',
|
||||
@ -6926,6 +7096,19 @@ export const resolvers = {
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const orgIds = [
|
||||
updatedOrder.organizationId,
|
||||
updatedOrder.partnerId,
|
||||
updatedOrder.fulfillmentCenterId || undefined,
|
||||
updatedOrder.logisticsPartnerId || undefined,
|
||||
].filter(Boolean) as string[]
|
||||
notifyMany(orgIds, {
|
||||
type: 'supply-order:updated',
|
||||
payload: { id: updatedOrder.id, status: updatedOrder.status },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: args.reason
|
||||
|
60
src/hooks/useRealtime.ts
Normal file
60
src/hooks/useRealtime.ts
Normal file
@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { getAuthToken } from '@/lib/apollo-client'
|
||||
|
||||
export type RealtimeEvent = {
|
||||
type: string
|
||||
payload?: any
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type Options = {
|
||||
onEvent?: (evt: RealtimeEvent) => void
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function useRealtime({ onEvent, orgId }: Options = {}) {
|
||||
const handlerRef = useRef(onEvent)
|
||||
handlerRef.current = onEvent
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const token = getAuthToken() || localStorage.getItem('adminAuthToken')
|
||||
// Try to infer orgId from cached userData if not provided
|
||||
let resolvedOrgId = orgId
|
||||
if (!resolvedOrgId) {
|
||||
try {
|
||||
const userDataRaw = localStorage.getItem('userData')
|
||||
if (userDataRaw) {
|
||||
const user = JSON.parse(userDataRaw)
|
||||
resolvedOrgId = user?.organization?.id
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!token || !resolvedOrgId) return
|
||||
|
||||
const url = `/api/events?token=${encodeURIComponent(token)}&orgId=${encodeURIComponent(resolvedOrgId)}`
|
||||
const es = new EventSource(url)
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handlerRef.current?.(data)
|
||||
} catch (e) {
|
||||
// ignore malformed events
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
// Let the browser auto-reconnect
|
||||
}
|
||||
|
||||
return () => {
|
||||
es.close()
|
||||
}
|
||||
}, [orgId])
|
||||
}
|
||||
|
67
src/lib/realtime.ts
Normal file
67
src/lib/realtime.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
export type NotificationEvent = {
|
||||
type: string
|
||||
payload?: unknown
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type Client = {
|
||||
orgId: string
|
||||
send: (evt: NotificationEvent) => void
|
||||
}
|
||||
|
||||
type RealtimeState = {
|
||||
emitter: EventEmitter
|
||||
clientsByOrg: Map<string, Set<Client>>
|
||||
}
|
||||
|
||||
// Ensure singleton across hot reloads
|
||||
const g = globalThis as unknown as { __REALTIME__?: RealtimeState }
|
||||
|
||||
if (!g.__REALTIME__) {
|
||||
g.__REALTIME__ = {
|
||||
emitter: new EventEmitter(),
|
||||
clientsByOrg: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
export const realtime = g.__REALTIME__
|
||||
|
||||
export function addClient(orgId: string, client: Client) {
|
||||
if (!realtime.clientsByOrg.has(orgId)) {
|
||||
realtime.clientsByOrg.set(orgId, new Set())
|
||||
}
|
||||
realtime.clientsByOrg.get(orgId)!.add(client)
|
||||
}
|
||||
|
||||
export function removeClient(orgId: string, client: Client) {
|
||||
const set = realtime.clientsByOrg.get(orgId)
|
||||
if (set) {
|
||||
set.delete(client)
|
||||
if (set.size === 0) {
|
||||
realtime.clientsByOrg.delete(orgId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyOrganization(orgId: string, event: NotificationEvent) {
|
||||
const set = realtime.clientsByOrg.get(orgId)
|
||||
if (!set) return
|
||||
const payload: NotificationEvent = { ...event, createdAt: event.createdAt || new Date().toISOString() }
|
||||
for (const client of set) {
|
||||
try {
|
||||
client.send(payload)
|
||||
} catch (e) {
|
||||
// Ignore send errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyMany(orgIds: string[], event: NotificationEvent) {
|
||||
const unique = Array.from(new Set(orgIds.filter(Boolean)))
|
||||
for (const orgId of unique) {
|
||||
notifyOrganization(orgId, event)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user