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:
Bivekich
2025-08-11 22:13:33 +03:00
parent 52107e793e
commit 3a56092385
14 changed files with 562 additions and 40 deletions

View 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',
},
})
}

View File

@ -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' &&

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}
},
})
// Логируем статистику склада для отладки

View File

@ -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?.()

View File

@ -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) {

View File

@ -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()
},
})
// Также загружаем полный список контрагентов на случай, если с кем-то еще не общались

View File

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

View File

@ -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()
}
},
})
// Мутация для обновления статуса заказа

View File

@ -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 = (() => {

View File

@ -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
View 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
View 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)
}
}