diff --git a/.superpowers/brainstorm/1523-1779454537/state/server.pid b/.superpowers/brainstorm/1523-1779454537/state/server.pid
new file mode 100644
index 0000000..45ff3ac
--- /dev/null
+++ b/.superpowers/brainstorm/1523-1779454537/state/server.pid
@@ -0,0 +1 @@
+1531
diff --git a/.superpowers/brainstorm/1702-1779454560/state/server.pid b/.superpowers/brainstorm/1702-1779454560/state/server.pid
new file mode 100644
index 0000000..1d25bf7
--- /dev/null
+++ b/.superpowers/brainstorm/1702-1779454560/state/server.pid
@@ -0,0 +1 @@
+1702
diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx
index 450b0f5..d497721 100644
--- a/client/src/app/layout/MainLayout.tsx
+++ b/client/src/app/layout/MainLayout.tsx
@@ -5,10 +5,10 @@ import Divider from '@mui/material/Divider'
import Grid from '@mui/material/Grid'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
-import SvgIcon from '@mui/material/SvgIcon'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { AppHeader } from '@/app/layout/AppHeader'
+import vkLogoSrc from '@/shared/assets/vk-logo.svg'
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
@@ -91,9 +91,7 @@ export function MainLayout({ children }: PropsWithChildren) {
color="text.secondary"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
>
-
-
-
+
VK
diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx
index 7d02222..2e26cf5 100644
--- a/client/src/app/providers/AppProviders.tsx
+++ b/client/src/app/providers/AppProviders.tsx
@@ -3,6 +3,7 @@ import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
+import { SseProvider } from './SseProvider'
function AppThemeInner({ children }: PropsWithChildren) {
const controller = useThemeController()
@@ -185,6 +186,7 @@ export function AppProviders({ children }: PropsWithChildren) {
return (
+
{children}
diff --git a/client/src/app/providers/SseProvider.tsx b/client/src/app/providers/SseProvider.tsx
new file mode 100644
index 0000000..b13c74f
--- /dev/null
+++ b/client/src/app/providers/SseProvider.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useRef } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useUnit } from 'effector-react'
+import { createEventStream } from '@/shared/lib/sse'
+import { $token } from '@/shared/model/auth'
+
+export function SseProvider() {
+ const token = useUnit($token)
+ const queryClient = useQueryClient()
+ const sourceRef = useRef(null)
+
+ useEffect(() => {
+ if (!token) {
+ if (sourceRef.current) {
+ sourceRef.current.close()
+ sourceRef.current = null
+ }
+ return
+ }
+
+ const es = createEventStream(token)
+ sourceRef.current = es
+
+ function handleEvent(eventName: string) {
+ return function (event: MessageEvent) {
+ try {
+ const data = JSON.parse(event.data)
+ const orderId = data.orderId
+
+ switch (eventName) {
+ case 'message:new':
+ queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
+ queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:statusChanged':
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:updated':
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:new':
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
+ break
+ }
+ } catch {
+ // ignore parse errors (e.g. heartbit comments)
+ }
+ }
+ }
+
+ const messageNewHandler = handleEvent('message:new')
+ const orderStatusHandler = handleEvent('order:statusChanged')
+ const orderUpdatedHandler = handleEvent('order:updated')
+ const orderNewHandler = handleEvent('order:new')
+
+ es.addEventListener('message:new', messageNewHandler)
+ es.addEventListener('order:statusChanged', orderStatusHandler)
+ es.addEventListener('order:updated', orderUpdatedHandler)
+ es.addEventListener('order:new', orderNewHandler)
+
+ return () => {
+ es.removeEventListener('message:new', messageNewHandler)
+ es.removeEventListener('order:statusChanged', orderStatusHandler)
+ es.removeEventListener('order:updated', orderUpdatedHandler)
+ es.removeEventListener('order:new', orderNewHandler)
+ es.close()
+ sourceRef.current = null
+ }
+ }, [token, queryClient])
+
+ return null
+}
diff --git a/client/src/app/providers/__tests__/SseProvider.test.tsx b/client/src/app/providers/__tests__/SseProvider.test.tsx
new file mode 100644
index 0000000..c9f55c4
--- /dev/null
+++ b/client/src/app/providers/__tests__/SseProvider.test.tsx
@@ -0,0 +1,150 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SseProvider } from '../SseProvider'
+
+const mockInvalidateQueries = vi.fn()
+
+vi.mock('@tanstack/react-query', async () => {
+ const actual = await vi.importActual('@tanstack/react-query')
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
+})
+
+vi.mock('@/shared/model/auth', () => ({
+ $token: {
+ defaultState: null,
+ subscribe: () => () => {},
+ getState: () => null,
+ watch: () => () => {},
+ on: () => {},
+ reset: () => {},
+ },
+}))
+
+let mockToken: string | null = null
+let mockEventHandlers: Record void> = {}
+let mockCloseCalls = 0
+
+class MockEventSource {
+ url: string
+ constructor(url: string) {
+ this.url = url
+ mockCloseCalls = 0
+ mockEventHandlers = {}
+ }
+ addEventListener(type: string, handler: (event: MessageEvent) => void) {
+ mockEventHandlers[type] = handler
+ }
+ removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
+ delete mockEventHandlers[type]
+ }
+ close() {
+ mockCloseCalls++
+ }
+}
+
+vi.mock('@/shared/lib/sse', () => ({
+ createEventStream: (token: string) => {
+ mockToken = token
+ return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
+ },
+}))
+
+vi.mock('effector-react', async () => {
+ const actual = await vi.importActual('effector-react')
+ return { ...actual, useUnit: () => mockToken }
+})
+
+function renderSse() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render(
+
+
+ ,
+ )
+}
+
+describe('SseProvider', () => {
+ afterEach(() => {
+ mockToken = null
+ mockInvalidateQueries.mockReset()
+ mockCloseCalls = 0
+ mockEventHandlers = {}
+ })
+
+ it('renders nothing (returns null)', () => {
+ mockToken = null
+ const { container } = renderSse()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('does not create EventSource when token is null', () => {
+ mockToken = null
+ renderSse()
+ expect(mockToken).toBeNull()
+ })
+
+ it('creates EventSource when token is set', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ expect(mockToken).toBe('test-jwt')
+ })
+
+ it('closes EventSource on unmount', () => {
+ mockToken = 'test-jwt'
+ const { unmount } = renderSse()
+ expect(mockCloseCalls).toBe(0)
+ unmount()
+ expect(mockCloseCalls).toBe(1)
+ })
+
+ it('invalidates unread-count and conversations on message:new', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['message:new']
+ expect(handler).toBeDefined()
+ handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
+
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] })
+ })
+
+ it('invalidates order queries on order:statusChanged', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:statusChanged']
+ handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] })
+ })
+
+ it('invalidates order queries on order:updated', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:updated']
+ handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] })
+ })
+
+ it('invalidates admin queries on order:new', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:new']
+ handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
+ })
+
+ it('handles invalid JSON gracefully', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['message:new']
+ expect(() => {
+ handler(new MessageEvent('message:new', { data: ':heartbit' }))
+ }).not.toThrow()
+ expect(mockInvalidateQueries).not.toHaveBeenCalled()
+ })
+})
diff --git a/client/src/entities/review/api/reviews-api.ts b/client/src/entities/review/api/reviews-api.ts
index badcf9e..b7aefc5 100644
--- a/client/src/entities/review/api/reviews-api.ts
+++ b/client/src/entities/review/api/reviews-api.ts
@@ -25,6 +25,7 @@ export type PublicReviewFeedItem = {
text: string | null
imageUrl: string | null
createdAt: string
+ authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
@@ -53,6 +54,7 @@ export type PublicProductReviewItem = {
text: string | null
imageUrl: string | null
createdAt: string
+ authorId: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarStyle?: string | null
diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx
index a7f05e4..b98e351 100644
--- a/client/src/features/product-review/ui/ProductReviewsList.tsx
+++ b/client/src/features/product-review/ui/ProductReviewsList.tsx
@@ -20,7 +20,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
{PICKUP_ADDRESS_FULL}
+
+ Email:{' '}
+
+ {STORE_EMAIL}
+
+
+
+ Телефон:{' '}
+
+ {STORE_PHONE}
+
+
+
+
+ ВКонтакте
+
+
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
index b7e0e73..18782b1 100644
--- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
+++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
@@ -49,8 +49,6 @@ export function AdminLayoutPage() {
queryKey: ['admin', 'orders', 'summary'],
queryFn: fetchAdminOrdersSummary,
enabled: isAdmin,
- refetchInterval: 45_000,
- refetchOnWindowFocus: true,
})
const newOrdersAttention = ordersSummaryQuery.data?.attentionCount ?? 0
diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx
index ce3b7a6..40ba74f 100644
--- a/client/src/pages/checkout/ui/CheckoutPage.tsx
+++ b/client/src/pages/checkout/ui/CheckoutPage.tsx
@@ -191,8 +191,8 @@ export function CheckoutPage() {
)}
- Стоимость доставки ориентировочно 300 ₽. Точная цена будет скорректирована после расчёта. В сумме заказа
- сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
+ Сумма доставки зависит от региона и способа доставки. Точная цена будет скорректирована после расчёта. В
+ сумме заказа сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
>
)}
diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx
index 9cff739..0d76d32 100644
--- a/client/src/pages/me/ui/MeLayoutPage.tsx
+++ b/client/src/pages/me/ui/MeLayoutPage.tsx
@@ -47,8 +47,6 @@ export function MeLayoutPage() {
queryKey: ['me', 'messages', 'unread-count'],
queryFn: fetchUnreadMessageCount,
enabled: Boolean(user),
- refetchInterval: 45_000,
- refetchOnWindowFocus: true,
})
const unreadMessages = unreadQuery.data?.count ?? 0
diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx
index 79ae7f1..8df4011 100644
--- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx
+++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx
@@ -9,10 +9,12 @@ import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
+import { useSearchParams } from 'react-router-dom'
import {
$user,
changePasswordFx,
fetchAuthMethodsFx,
+ requestEmailChangeFx,
setPasswordFx,
unlinkOAuthFx,
type AuthMethod,
@@ -77,11 +79,78 @@ export function AuthMethodsSection() {
return authMethods.filter((m) => m.active).length
}, [authMethods])
+ const [searchParams] = useSearchParams()
+ const emailVerified = searchParams.get('emailVerified')
+
+ const emailForm = useForm<{ email: string }>({
+ defaultValues: { email: '' },
+ })
+ const [emailChangeError, setEmailChangeError] = useState(null)
+ const [verificationUrl, setVerificationUrl] = useState(null)
+
+ const emailChangeMutation = useMutation({
+ mutationFn: async (email: string) => {
+ setEmailChangeError(null)
+ const url = await requestEmailChangeFx(email)
+ return url
+ },
+ onSuccess: (url) => setVerificationUrl(url),
+ onError: (err) => setEmailChangeError(err?.message || 'Не удалось сменить email'),
+ })
+
if (!user) return null
return (
+ Почта
+
+
+ {emailVerified === '1' && (
+
+ Почта успешно подтверждена
+
+ )}
+
+
+ {user.email}
+
+
+ {!verificationUrl && (
+
+
+
+
+ )}
+
+ {verificationUrl && (
+
+
+ Ссылка подтверждения готова.
+
+
+
+ )}
+
+
Методы входа
{fetchError && (
diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx
index 06c425d..693756d 100644
--- a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx
+++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx
@@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { AuthMethodsSection } from '../AuthMethodsSection'
@@ -15,6 +16,7 @@ vi.mock('@/shared/model/auth', () => ({
fetchAuthMethodsFx: vi.fn().mockResolvedValue([]),
setPasswordFx: vi.fn(),
unlinkOAuthFx: vi.fn(),
+ requestEmailChangeFx: vi.fn(),
}))
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
@@ -29,7 +31,9 @@ function renderSection() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(
-
+
+
+
,
)
}
diff --git a/client/src/shared/assets/vk-logo.svg b/client/src/shared/assets/vk-logo.svg
new file mode 100644
index 0000000..1c61468
--- /dev/null
+++ b/client/src/shared/assets/vk-logo.svg
@@ -0,0 +1,11 @@
+
diff --git a/client/src/shared/config/index.ts b/client/src/shared/config/index.ts
index 70c2585..367ddab 100644
--- a/client/src/shared/config/index.ts
+++ b/client/src/shared/config/index.ts
@@ -13,6 +13,6 @@ export const STORE_PUBLIC_SITE_URL = (() => {
})()
/** Демо-контакты для футера; при необходимости задайте через VITE_* в `.env`. */
-export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'hello@example.com'
-export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (900) 000-00-00'
-export const VK_URL = import.meta.env.VITE_VK_URL ?? '#'
+export const STORE_EMAIL = import.meta.env.VITE_STORE_EMAIL ?? 'larisa8502@yandex.ru'
+export const STORE_PHONE = import.meta.env.VITE_STORE_PHONE ?? '+7 (952) 318-16-24'
+export const VK_URL = import.meta.env.VITE_VK_URL ?? 'https://vk.com/club158395871'
diff --git a/client/src/shared/lib/sse.ts b/client/src/shared/lib/sse.ts
new file mode 100644
index 0000000..ed9282e
--- /dev/null
+++ b/client/src/shared/lib/sse.ts
@@ -0,0 +1,3 @@
+export function createEventStream(token: string): EventSource {
+ return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
+}
diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts
index 542b83e..2e91529 100644
--- a/client/src/shared/model/auth.ts
+++ b/client/src/shared/model/auth.ts
@@ -7,9 +7,6 @@ export type AuthUser = {
id: string
email: string
displayName?: string | null
- firstName?: string | null
- lastName?: string | null
- gender?: string | null
avatar?: string | null
avatarStyle?: string | null
isAdmin?: boolean
@@ -104,6 +101,11 @@ export const changePasswordFx = createEffect(async (params: { oldPassword: strin
await apiClient.post('me/change-password', params)
})
+export const requestEmailChangeFx = createEffect(async (email: string) => {
+ const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email })
+ return data.verificationUrl
+})
+
// ----- Error stores -----
export const $updateProfileError = createErrorStore(updateProfileFx).$error
diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
index 2a2f622..a288fca 100644
--- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
+++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx
@@ -102,7 +102,7 @@ export function ReviewsBlock() {
)}
**For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace HTTP polling with Server-Sent Events (SSE) for real-time updates of chat messages, unread counters, order status changes, and admin notifications.
+
+**Architecture:** New SSE route on server bridges existing EventBus events to SSE streams. Client-side SseProvider manages EventSource lifecycle (connect on login, close on logout) and invalidates React Query caches on incoming events.
+
+**Tech Stack:** Fastify (raw SSE via `reply.raw`), EventSource API, Effector (`$token` store), React Query (`invalidateQueries`), vitest.
+
+---
+
+## File Map
+
+| File | Responsibility |
+|---|---|
+| `server/src/routes/sse.js` | SSE endpoint, EventBus listener bridge |
+| `server/src/routes/__tests__/sse.test.js` | Server tests |
+| `server/src/index.js` | Import and register SSE routes |
+| `client/src/shared/lib/sse.ts` | EventSource factory |
+| `client/src/app/providers/SseProvider.tsx` | SSE→ReactQuery invalidation bridge |
+| `client/src/app/providers/__tests__/SseProvider.test.tsx` | Client tests |
+| `client/src/app/providers/AppProviders.tsx` | Mount SseProvider |
+| `client/src/pages/me/ui/MeLayoutPage.tsx` | Remove refetchInterval |
+| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Remove refetchInterval |
+
+---
+
+### Task 1: Server — write SSE route tests (TDD red)
+
+**Files:**
+- Create: `server/src/routes/__tests__/sse.test.js`
+
+- [ ] **Step 1: Write the test file**
+
+```js
+import Fastify from 'fastify'
+import { EventEmitter } from 'node:events'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
+
+describe('formatSSE', () => {
+ it('formats event with data', () => {
+ const result = formatSSE('message:new', { orderId: 'o1' })
+ expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n')
+ })
+
+ it('formats event without data', () => {
+ const result = formatSSE('heartbit')
+ expect(result).toBe('event: heartbit\n\n')
+ })
+})
+
+describe('formatHeartbit', () => {
+ it('returns SSE comment', () => {
+ expect(formatHeartbit()).toBe(':heartbit\n\n')
+ })
+})
+
+describe('isAdminUser', () => {
+ it('returns false for non-matching email', () => {
+ expect(isAdminUser({ email: 'user@test.com' })).toBe(false)
+ })
+
+ it('returns true when email matches ADMIN_EMAIL', () => {
+ const adminEmail = process.env.ADMIN_EMAIL
+ if (adminEmail) {
+ expect(isAdminUser({ email: adminEmail })).toBe(true)
+ }
+ })
+
+ it('returns false for null/undefined user', () => {
+ expect(isAdminUser(null)).toBe(false)
+ expect(isAdminUser(undefined)).toBe(false)
+ })
+})
+
+describe('buildSseListeners', () => {
+ let eventBus
+ let write
+
+ beforeEach(() => {
+ eventBus = new EventEmitter()
+ eventBus.setMaxListeners(50)
+ write = vi.fn()
+ })
+
+ it('forwards orderMessage:adminReply to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: message:new')
+ expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
+ cleanup()
+ })
+
+ it('ignores orderMessage:adminReply for non-matching userId', () => {
+ const cleanup = buildSseListeners('user-2', false, eventBus, write)
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('forwards orderMessage:sent to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: message:new')
+ cleanup()
+ })
+
+ it('ignores orderMessage:sent for non-admin', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('forwards order:statusChanged to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
+ expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
+ cleanup()
+ })
+
+ it('forwards payment:statusChanged to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
+ cleanup()
+ })
+
+ it('forwards order:deliveryFeeAdjusted to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:updated')
+ cleanup()
+ })
+
+ it('forwards order:created to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:new')
+ cleanup()
+ })
+
+ it('forwards order:created:admin to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:new')
+ cleanup()
+ })
+
+ it('ignores order:created for non-admin', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('cleanup removes all listeners', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ cleanup()
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).not.toHaveBeenCalled()
+ })
+})
+
+describe('GET /api/sse/stream (integration)', () => {
+ let app
+
+ beforeAll(async () => {
+ app = Fastify({ logger: false })
+ app.decorate('authenticate', async function (request, reply) {
+ try {
+ const token = request.query?.token
+ if (!token) throw new Error('no token')
+ if (token === 'user-token') {
+ request.user = { sub: 'user-1', email: 'user@test.com' }
+ } else if (token === 'admin-token') {
+ request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' }
+ } else {
+ throw new Error('bad token')
+ }
+ } catch {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ })
+ app.decorate('eventBus', new EventEmitter())
+ await registerSseRoutes(app)
+ await app.ready()
+ })
+
+ afterAll(async () => {
+ await app.close()
+ })
+
+ it('returns 401 without token', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream' })
+ expect(res.statusCode).toBe(401)
+ })
+
+ it('returns 200 and event-stream headers for authenticated user', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe('text/event-stream')
+ expect(res.headers['cache-control']).toBe('no-cache')
+ expect(res.headers['connection']).toBe('keep-alive')
+ })
+
+ it('sends initial heartbit', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token' })
+ expect(res.body).toContain(':heartbit')
+ })
+})
+```
+
+- [ ] **Step 2: Run tests — expect FAIL (sse.js does not exist)**
+
+Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js`
+Expected: FAIL — module `../sse.js` not found
+
+---
+
+### Task 2: Server — implement SSE route (TDD green)
+
+**Files:**
+- Create: `server/src/routes/sse.js`
+
+- [ ] **Step 1: Write sse.js with exported helpers**
+
+```js
+import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
+
+const {
+ ORDER_CREATED,
+ ORDER_STATUS_CHANGED,
+ ORDER_MESSAGE_SENT,
+ ORDER_MESSAGE_ADMIN_REPLY,
+ PAYMENT_STATUS_CHANGED,
+ DELIVERY_FEE_ADJUSTED,
+} = NOTIFICATION_EVENTS
+
+export function isAdminUser(user) {
+ return user?.email === process.env.ADMIN_EMAIL
+}
+
+export function formatSSE(event, data) {
+ const lines = [`event: ${event}`]
+ if (data !== undefined) {
+ lines.push(`data: ${JSON.stringify(data)}`)
+ }
+ return lines.join('\n') + '\n\n'
+}
+
+export function formatHeartbit() {
+ return ':heartbit\n\n'
+}
+
+export function buildSseListeners(userId, admin, eventBus, write) {
+ const listeners = []
+
+ function on(eventName, filterFn, sseEvent, dataFn) {
+ function handler(payload) {
+ if (!filterFn(payload)) return
+ write(formatSSE(sseEvent, dataFn(payload)))
+ }
+ listeners.push({ eventName, handler })
+ eventBus.on(eventName, handler)
+ }
+
+ on(
+ ORDER_MESSAGE_ADMIN_REPLY,
+ (p) => p.userId === userId,
+ 'message:new',
+ (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
+ )
+
+ on(
+ ORDER_MESSAGE_SENT,
+ () => admin,
+ 'message:new',
+ (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
+ )
+
+ on(
+ ORDER_STATUS_CHANGED,
+ (p) => p.userId === userId,
+ 'order:statusChanged',
+ (p) => ({ orderId: p.orderId, newStatus: p.newStatus }),
+ )
+
+ on(
+ PAYMENT_STATUS_CHANGED,
+ (p) => p.userId === userId,
+ 'order:statusChanged',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ DELIVERY_FEE_ADJUSTED,
+ (p) => p.userId === userId,
+ 'order:updated',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ ORDER_CREATED,
+ () => admin,
+ 'order:new',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ 'order:created:admin',
+ () => admin,
+ 'order:new',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ return function cleanup() {
+ for (const { eventName, handler } of listeners) {
+ eventBus.off(eventName, handler)
+ }
+ }
+}
+
+export async function registerSseRoutes(fastify) {
+ fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ reply.raw.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ })
+
+ const userId = request.user.sub
+ const admin = isAdminUser(request.user)
+
+ reply.raw.write(formatHeartbit())
+
+ const heartbitTimer = setInterval(() => {
+ reply.raw.write(formatHeartbit())
+ }, 30_000)
+
+ const cleanup = buildSseListeners(userId, admin, fastify.eventBus, (chunk) => {
+ reply.raw.write(chunk)
+ })
+
+ request.raw.on('close', () => {
+ clearInterval(heartbitTimer)
+ cleanup()
+ })
+ })
+}
+```
+
+- [ ] **Step 2: Run tests — expect PASS**
+
+Run: `cd server && npx vitest run src/routes/__tests__/sse.test.js`
+Expected: all PASS
+
+- [ ] **Step 3: Commit both files**
+
+```bash
+git add server/src/routes/sse.js server/src/routes/__tests__/sse.test.js
+git commit -m "feat: add SSE route with EventBus bridge and tests"
+```
+
+---
+
+### Task 3: Server — register SSE routes in index.js
+
+**Files:**
+- Modify: `server/src/index.js`
+
+- [ ] **Step 1: Add import**
+
+After line 27 (`import { registerUserMessageRoutes } from './routes/user-messages.js'`), add:
+
+```js
+import { registerSseRoutes } from './routes/sse.js'
+```
+
+- [ ] **Step 2: Add registration**
+
+After line 94 (`await registerUserMessageRoutes(fastify)`), add:
+
+```js
+await registerSseRoutes(fastify)
+```
+
+- [ ] **Step 3: Verify server starts**
+
+Run: `cd server && timeout 5 npm run dev 2>&1 || true`
+Expected: no crash, server starts listening
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/src/index.js
+git commit -m "feat: register SSE routes in server"
+```
+
+---
+
+### Task 4: Client — EventSource factory
+
+**Files:**
+- Create: `client/src/shared/lib/sse.ts`
+
+- [ ] **Step 1: Write the factory**
+
+```ts
+export function createEventStream(token: string): EventSource {
+ return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add client/src/shared/lib/sse.ts
+git commit -m "feat: add EventSource factory for SSE"
+```
+
+---
+
+### Task 5: Client — write SseProvider tests (TDD red)
+
+**Files:**
+- Create: `client/src/app/providers/__tests__/SseProvider.test.tsx`
+
+- [ ] **Step 1: Write the test file**
+
+```tsx
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SseProvider } from '../SseProvider'
+
+const mockInvalidateQueries = vi.fn()
+
+vi.mock('@tanstack/react-query', async () => {
+ const actual = await vi.importActual('@tanstack/react-query')
+ return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
+})
+
+vi.mock('@/shared/model/auth', () => ({
+ $token: { defaultState: null, subscribe: () => () => {}, getState: () => null, watch: () => () => {}, on: () => {}, reset: () => {} },
+}))
+
+let mockToken: string | null = null
+let mockEventHandlers: Record void> = {}
+let mockCloseCalls = 0
+
+class MockEventSource {
+ url: string
+ constructor(url: string) {
+ this.url = url
+ mockCloseCalls = 0
+ mockEventHandlers = {}
+ }
+ addEventListener(type: string, handler: (event: MessageEvent) => void) {
+ mockEventHandlers[type] = handler
+ }
+ removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
+ delete mockEventHandlers[type]
+ }
+ close() { mockCloseCalls++ }
+}
+
+vi.mock('@/shared/lib/sse', () => ({
+ createEventStream: (token: string) => {
+ mockToken = token
+ return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
+ },
+}))
+
+vi.mock('effector-react', async () => {
+ const actual = await vi.importActual('effector-react')
+ return { ...actual, useUnit: () => mockToken }
+})
+
+function renderSse() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
+ return render()
+}
+
+describe('SseProvider', () => {
+ afterEach(() => {
+ mockToken = null
+ mockInvalidateQueries.mockReset()
+ mockCloseCalls = 0
+ mockEventHandlers = {}
+ })
+
+ it('renders nothing (returns null)', () => {
+ mockToken = null
+ const { container } = renderSse()
+ expect(container.innerHTML).toBe('')
+ })
+
+ it('does not create EventSource when token is null', () => {
+ mockToken = null
+ renderSse()
+ expect(mockToken).toBeNull()
+ })
+
+ it('creates EventSource when token is set', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ expect(mockToken).toBe('test-jwt')
+ })
+
+ it('closes EventSource on unmount', () => {
+ mockToken = 'test-jwt'
+ const { unmount } = renderSse()
+ expect(mockCloseCalls).toBe(0)
+ unmount()
+ expect(mockCloseCalls).toBe(1)
+ })
+
+ it('invalidates unread-count and conversations on message:new', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['message:new']
+ expect(handler).toBeDefined()
+ handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
+
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o1'] })
+ })
+
+ it('invalidates order queries on order:statusChanged', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:statusChanged']
+ handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o2'] })
+ })
+
+ it('invalidates order queries on order:updated', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:updated']
+ handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'o3'] })
+ })
+
+ it('invalidates admin queries on order:new', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['order:new']
+ handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
+ })
+
+ it('handles invalid JSON gracefully', () => {
+ mockToken = 'test-jwt'
+ renderSse()
+ const handler = mockEventHandlers['message:new']
+ expect(() => { handler(new MessageEvent('message:new', { data: ':heartbit' })) }).not.toThrow()
+ expect(mockInvalidateQueries).not.toHaveBeenCalled()
+ })
+})
+```
+
+- [ ] **Step 2: Run tests — expect FAIL (SseProvider does not exist)**
+
+Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx`
+Expected: FAIL — module `../SseProvider` not found
+
+---
+
+### Task 6: Client — implement SseProvider (TDD green)
+
+**Files:**
+- Create: `client/src/app/providers/SseProvider.tsx`
+
+- [ ] **Step 1: Write the SseProvider component**
+
+```tsx
+import { useEffect, useRef } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useUnit } from 'effector-react'
+import { createEventStream } from '@/shared/lib/sse'
+import { $token } from '@/shared/model/auth'
+
+export function SseProvider() {
+ const token = useUnit($token)
+ const queryClient = useQueryClient()
+ const sourceRef = useRef(null)
+
+ useEffect(() => {
+ if (!token) {
+ if (sourceRef.current) {
+ sourceRef.current.close()
+ sourceRef.current = null
+ }
+ return
+ }
+
+ const es = createEventStream(token)
+ sourceRef.current = es
+
+ function handleEvent(eventName: string) {
+ return function (event: MessageEvent) {
+ try {
+ const data = JSON.parse(event.data)
+ const orderId = data.orderId
+
+ switch (eventName) {
+ case 'message:new':
+ queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
+ queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:statusChanged':
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:updated':
+ if (orderId) {
+ queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', orderId] })
+ }
+ break
+ case 'order:new':
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
+ queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
+ break
+ }
+ } catch {
+ // ignore parse errors (e.g. heartbit comments)
+ }
+ }
+ }
+
+ const messageNewHandler = handleEvent('message:new')
+ const orderStatusHandler = handleEvent('order:statusChanged')
+ const orderUpdatedHandler = handleEvent('order:updated')
+ const orderNewHandler = handleEvent('order:new')
+
+ es.addEventListener('message:new', messageNewHandler)
+ es.addEventListener('order:statusChanged', orderStatusHandler)
+ es.addEventListener('order:updated', orderUpdatedHandler)
+ es.addEventListener('order:new', orderNewHandler)
+
+ return () => {
+ es.removeEventListener('message:new', messageNewHandler)
+ es.removeEventListener('order:statusChanged', orderStatusHandler)
+ es.removeEventListener('order:updated', orderUpdatedHandler)
+ es.removeEventListener('order:new', orderNewHandler)
+ es.close()
+ sourceRef.current = null
+ }
+ }, [token, queryClient])
+
+ return null
+}
+```
+
+- [ ] **Step 2: Run tests — expect PASS**
+
+Run: `cd client && npx vitest run src/app/providers/__tests__/SseProvider.test.tsx`
+Expected: all PASS
+
+- [ ] **Step 3: Commit both files**
+
+```bash
+git add client/src/shared/lib/sse.ts client/src/app/providers/SseProvider.tsx client/src/app/providers/__tests__/SseProvider.test.tsx
+git commit -m "feat: add SseProvider — SSE to ReactQuery bridge with tests"
+```
+
+---
+
+### Task 7: Client — mount SseProvider in AppProviders
+
+**Files:**
+- Modify: `client/src/app/providers/AppProviders.tsx`
+
+- [ ] **Step 1: Add import and mount**
+
+```tsx
+import { SseProvider } from './SseProvider'
+```
+
+Inside the return, add `` as first child of `QueryClientProvider`:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd client && npx tsc --noEmit`
+Expected: no errors
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/app/providers/AppProviders.tsx
+git commit -m "feat: mount SseProvider in AppProviders"
+```
+
+---
+
+### Task 8: Client — remove polling from MeLayoutPage
+
+**Files:**
+- Modify: `client/src/pages/me/ui/MeLayoutPage.tsx`
+
+- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus**
+
+Find the unread query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`:
+
+```ts
+const unreadQuery = useQuery({
+ queryKey: ['me', 'messages', 'unread-count'],
+ queryFn: fetchUnreadMessageCount,
+ enabled: Boolean(user),
+})
+```
+
+- [ ] **Step 2: Verify TypeScript**
+
+Run: `cd client && npx tsc --noEmit`
+Expected: no errors
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/pages/me/ui/MeLayoutPage.tsx
+git commit -m "feat: remove polling from MeLayoutPage — replaced by SSE"
+```
+
+---
+
+### Task 9: Client — remove polling from AdminLayoutPage
+
+**Files:**
+- Modify: `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`
+
+- [ ] **Step 1: Remove refetchInterval and refetchOnWindowFocus**
+
+Find the orders summary query. Remove `refetchInterval: 45_000` and `refetchOnWindowFocus: true`:
+
+```ts
+const ordersSummaryQuery = useQuery({
+ queryKey: ['admin', 'orders', 'summary'],
+ queryFn: fetchAdminOrdersSummary,
+ enabled: isAdmin,
+})
+```
+
+- [ ] **Step 2: Verify TypeScript**
+
+Run: `cd client && npx tsc --noEmit`
+Expected: no errors
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
+git commit -m "feat: remove admin polling from AdminLayoutPage — replaced by SSE"
+```
+
+---
+
+### Task 10: Final verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Run server tests**
+
+Run: `cd server && npm test -- --run`
+Expected: all tests pass
+
+- [ ] **Step 2: Run client tests**
+
+Run: `cd client && npm test -- --run`
+Expected: all tests pass
+
+- [ ] **Step 3: Run client lint**
+
+Run: `cd client && npm run lint`
+Expected: no errors
+
+- [ ] **Step 4: Run server lint**
+
+Run: `cd server && npm run lint`
+Expected: no errors
+
+- [ ] **Step 5: Run client format check**
+
+Run: `cd client && npm run format:check`
+Expected: no formatting issues
+
+- [ ] **Step 6: Run client build**
+
+Run: `cd client && npm run build`
+Expected: successful build
diff --git a/docs/superpowers/specs/2026-05-22-sse-realtime-design.md b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md
new file mode 100644
index 0000000..ca906cf
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-22-sse-realtime-design.md
@@ -0,0 +1,146 @@
+# SSE Realtime Design
+
+## Goal
+
+Replace HTTP polling (`refetchInterval: 45_000`) with Server-Sent Events (SSE) for real-time updates: chat messages, unread counters, order status changes, admin notifications.
+
+## Decisions
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Technology | SSE over WebSocket | Native browser API, auto-reconnect, simpler server code. Messages still sent via HTTP POST. |
+| SSE approach | Direct EventBus bridge (no recovery buffer) | Sufficient for shop scale. EventSource has built-in auto-reconnect. |
+| Connection lifecycle | Connect on login, close on logout | SSE created when JWT appears in Effector `$token`, closed on `null`. |
+| Auth method | JWT in query param (`?token=`) | EventSource doesn't support custom headers. Server's `authenticate` decorator already handles `request.query?.token`. |
+
+## Server-side
+
+### New file: `server/src/routes/sse.js`
+
+Single SSE endpoint:
+
+```
+GET /api/sse/stream?token=JWT
+PreHandler: fastify.authenticate
+```
+
+**Behavior:**
+1. Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`.
+2. Sends heartbit comment every 30s: `:heartbit\n\n` (invisible to EventSource, keeps connection alive).
+3. Subscribes to `request.server.eventBus`, filters events by user identity, pushes matching events through SSE.
+4. On `response.raw.on('close')` — removes EventBus listeners.
+
+**Event mapping (EventBus → SSE):**
+
+| EventBus event | Who receives | SSE `event` type | Payload |
+|---|---|---|---|
+| `orderMessage:adminReply` | User (order.userId) | `message:new` | `{ orderId, messageId, preview }` |
+| `order:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, newStatus }` |
+| `payment:statusChanged` | User (order.userId) | `order:statusChanged` | `{ orderId, paymentStatus }` |
+| `order:deliveryFeeAdjusted` | User (order.userId) | `order:updated` | `{ orderId }` |
+| `orderMessage:sent` | Admin (all admins) | `message:new` | `{ orderId, messageId, preview }` |
+| `order:created` | Admin | `order:new` | `{ orderId }` |
+
+**Admin filtering:** If `request.user` is admin (checked via email match), subscribe to all admin events without userId filtering. Currently only one admin exists, so this is straightforward.
+
+### Modified file: `server/src/index.js`
+
+Add import and registration:
+
+```js
+import { registerSseRoutes } from './routes/sse.js'
+// ...
+await registerSseRoutes(fastify)
+```
+
+No other server changes needed. Existing `authenticate` decorator (line 75-84) already supports `request.query?.token`.
+
+## Client-side
+
+### New file: `client/src/shared/lib/sse.ts`
+
+Factory function:
+
+```ts
+export function createEventStream(token: string): EventSource {
+ return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
+}
+```
+
+### New file: `client/src/app/providers/SseProvider.tsx`
+
+React component that bridges SSE events to React Query cache invalidation:
+
+1. Subscribes to `$token` from Effector (`@/shared/model/auth`).
+2. When token appears → creates `EventSource` via `createEventStream(token)`.
+3. When token becomes `null` → closes `EventSource`.
+4. Registers `onmessage` handlers for each SSE event type:
+
+| SSE event | Handler |
+|---|---|
+| `message:new` | User side: `invalidateQueries(['me', 'messages', 'unread-count'])`, `invalidateQueries(['me', 'conversations'])`, `invalidateQueries(['me', 'orders', orderId])`. Admin side: `invalidateQueries(['admin', 'orders', orderId])`. |
+| `order:statusChanged` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. |
+| `order:new` | Admin: `invalidateQueries(['admin', 'orders', 'summary'])`, `invalidateQueries(['admin', 'orders'])`. |
+| `order:updated` | `invalidateQueries(['me', 'orders', orderId])`. Admin (if viewing): `invalidateQueries(['admin', 'orders', orderId])`. |
+
+React Query's `invalidateQueries` only refetches active (currently mounted) queries. Inactive queries are just marked stale. This means SseProvider can call `invalidateQueries(['me', 'orders', orderId])` unconditionally — it will only refetch if the user has that order's chat page open.
+
+5. Uses `useQueryClient()` to access the query client.
+
+### Modified files
+
+**`client/src/app/providers/AppProviders.tsx`:**
+- Add `` as a child of `QueryClientProvider` (or wrap it around, needs `queryClient`).
+
+**`client/src/pages/me/ui/MeLayoutPage.tsx`:**
+- Remove `refetchInterval: 45_000` from the unread count query.
+- Remove `refetchOnWindowFocus: true` (revert to global default `false`).
+
+**`client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:**
+- Remove `refetchInterval: 45_000` from the orders summary query.
+- Remove `refetchOnWindowFocus: true` (revert to global default `false`).
+
+## Data Flow (example: admin replies to user)
+
+```
+Admin → POST /api/admin/orders/:id/messages
+ → Prisma: creates OrderMessage
+ → EventBus: emit('orderMessage:adminReply', { orderId, userId, messageId, preview })
+ → dispatchNotification: email/telegram to user (existing behavior, unchanged)
+ → SSE handler: filters by userId, formats as SSE event
+
+Client (SseProvider):
+ → SSE event 'message:new' received
+ → invalidateQueries(['me', 'messages', 'unread-count']) → badge updates
+ → invalidateQueries(['me', 'conversations']) → dialog list updates
+ → invalidateQueries(['me', 'orders', orderId]) → chat updates (only if that order's query is active)
+```
+
+## Files Summary
+
+| File | Status | Description |
+|---|---|---|
+| `server/src/routes/sse.js` | New | SSE endpoint, EventBus→SSE bridge |
+| `server/src/index.js` | Modify | Import and register SSE routes |
+| `client/src/shared/lib/sse.ts` | New | EventSource factory |
+| `client/src/app/providers/SseProvider.tsx` | New | SSE→ReactQuery bridge component |
+| `client/src/app/providers/AppProviders.tsx` | Modify | Mount SseProvider |
+| `client/src/pages/me/ui/MeLayoutPage.tsx` | Modify | Remove refetchInterval |
+| `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` | Modify | Remove refetchInterval |
+
+## Testing
+
+**Server (vitest):**
+- SSE endpoint returns correct headers (`text/event-stream`, `no-cache`, `keep-alive`).
+- SSE endpoint sends heartbit comment on connect.
+- When EventBus emits an event relevant to the connected user, SSE stream contains the formatted event.
+- When EventBus emits an event for a different user, SSE stream does NOT receive it.
+- EventSource cleanup: listeners removed on response close.
+- Admin receives all admin events regardless of userId.
+
+**Client (vitest + jsdom):**
+- `createEventStream(token)` returns EventSource with correct URL including token.
+- `SseProvider` creates EventSource when `$token` is set.
+- `SseProvider` closes EventSource when `$token` becomes null.
+- `SseProvider` calls `queryClient.invalidateQueries` with correct keys on each SSE event type.
+- No EventSource created when `$token` is null.
diff --git a/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md b/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md
new file mode 100644
index 0000000..f1bd362
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md
@@ -0,0 +1,126 @@
+# VK OAuth без email — Design
+
+## Проблема
+
+VK ID не всегда возвращает email (необязательное поле). Текущий код требует email на трёх уровнях:
+1. Callback (`oauth-social.js:209`) — `if (!emailSuggestion) return oauthErrorRedirect(...)`
+2. `findOrCreateUserFromOAuth` (`oauth-social.js:72,87`) — `if (!norm) return null`
+3. Схема БД — `email String @unique` (NOT NULL)
+
+Результат: пользователь, у которого VK не отдал email, видит ошибку `no_email` и не может войти.
+
+## Решение
+
+Три изменения:
+
+1. **Новый пользователь без email** — генерировать синтетический email `vk_@vk.local`
+2. **Привязка VK к существующему аккаунту (link)** — не требовать email от VK
+3. **Смена email в профиле** — дать пользователю возможность сменить синтетический email на настоящий, с верификацией
+
+---
+
+## Часть 1: OAuth flow (сервер)
+
+### `server/src/routes/oauth-social.js`
+
+**`findOrCreateUserFromOAuth`** (стр. 53-104):
+
+- **Режим link** (стр. 71-77): убрать `if (!norm) return null`. Если `linkToUserId` передан — email не нужен, создаём `OAuthAccount` и возвращаем пользователя.
+- **Новый пользователь без email** (стр. 87): вместо `if (!norm) return null` — если `norm` отсутствует, генерируем `vk_@vk.local` и создаём пользователя с `displayName = 'Пользователь'`.
+
+**VK callback** (стр. 206-209): убрать строку `if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')`.
+
+**Yandex callback** — без изменений (Яндекс всегда возвращает email).
+
+---
+
+## Часть 2: Смена email с верификацией
+
+### Схема БД — новая модель `PendingEmail`
+
+```prisma
+model PendingEmail {
+ id String @id @default(cuid())
+ userId String
+ email String
+ token String @unique
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+```
+
+### Миграция
+
+```bash
+cd server && npx prisma migrate dev --name pending_email
+```
+
+### Серверные роуты
+
+Новые роуты в `server/src/routes/auth-session.js` (рядом с `/api/me` и `/api/me/auth-methods`):
+
+**`PATCH /api/me/email`** (requireAuth):
+
+- Тело: `{ email: string }`
+- Валидация: нормализовать через `normalizeEmail()`, проверить формат
+- Проверить, что email не занят (`findUnique({ email })`) → 409 Conflict
+- Удалить предыдущие `PendingEmail` для этого пользователя
+- Создать `PendingEmail` с `token = crypto.randomUUID()`, `expiresAt = now + 24h`
+- Ответ: `{ verificationUrl: '/api/me/verify-email?token=' }` (отправка email не реализуем, токен возвращаем в ответе API)
+
+**`GET /api/me/verify-email`** (без авторизации, только по токену):
+
+- Искать `PendingEmail` по токену, проверить `expiresAt > now`
+- Обновить `User.email`, удалить `PendingEmail`
+- Редирект: `{CLIENT_PUBLIC_URL}/me?emailVerified=1`
+
+### Клиент
+
+**`client/src/shared/model/auth.ts`** — добавить эффекты:
+
+```ts
+export const requestEmailChangeFx = createEffect(async (email: string) => {
+ const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email })
+ return data.verificationUrl
+})
+
+export const verifyEmailFx = createEffect(async (token: string) => {
+ window.location.href = `/api/me/verify-email?token=${token}`
+})
+```
+
+**`AuthMethodsSection.tsx`** — добавить секцию смены email:
+
+- Текстовое поле (email) + кнопка «Сменить email»
+- После успешного запроса — показать кнопку «Подтвердить email» (переход по `verificationUrl`)
+- Ошибки: неверный формат, email занят
+- После успешной верификации — обновить `$user` (подгрузить через `meFx` заново)
+
+---
+
+## Структура изменений
+
+```
+server/
+ prisma/schema.prisma — модель PendingEmail
+ prisma/migrations/ — миграция (авто)
+ src/routes/oauth-social.js — findOrCreateUserFromOAuth + VK callback fix
+ src/routes/auth-session.js — PATCH /api/me/email, GET /api/me/verify-email
+ __tests__/ — тесты на новый flow
+
+client/
+ src/shared/model/auth.ts — requestEmailChangeFx, verifyEmailFx
+ src/pages/me/ui/sections/
+ AuthMethodsSection.tsx — UI для смены email
+ __tests__/ — тесты UI
+```
+
+---
+
+## Не входит в scope
+
+- Отправка email с кодом подтверждения (верификация через ссылку в ответе API)
+- OAuth для админа (админ только email/код)
+- Синтетический email для Яндекса (Яндекс всегда возвращает email)
diff --git a/server/package-lock.json b/server/package-lock.json
index 48993e2..293ae7b 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -8,6 +8,8 @@
"name": "server",
"version": "1.0.0",
"dependencies": {
+ "@dicebear/collection": "^9.4.2",
+ "@dicebear/core": "^9.4.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^10.0.0",
@@ -31,6 +33,436 @@
"vitest": "^3.2.4"
}
},
+ "node_modules/@dicebear/adventurer": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz",
+ "integrity": "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/adventurer-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.4.2.tgz",
+ "integrity": "sha512-5xgkG/mNL4j3Q4SJGQLBU/KnU90tng8Ze5ofThD+55wi0oeY/nSAUowg6UFCmHrktjifj/MEx3CQqbpcPWtfIA==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/avataaars": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.2.tgz",
+ "integrity": "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==",
+ "license": "See LICENSE file",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/avataaars-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.4.2.tgz",
+ "integrity": "sha512-/eNrp0YCNJRwQXqOloLm1+3Ss2C+pMpUQIGkbEnGsP1UK+13Ge80ggDDof1HpdqvG9HAZcKa7hnbG/0HSwyDSw==",
+ "license": "See LICENSE file",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/big-ears": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.2.tgz",
+ "integrity": "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/big-ears-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.4.2.tgz",
+ "integrity": "sha512-M8Ozmzza4eY4hpLOYULgJxMYmBA0CsBnrE15/xw6LZkEREXnrX5z0NJsf8hUfdyF6BWZ+RBgzoiav32DAC5zcg==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/big-smile": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.2.tgz",
+ "integrity": "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/bottts": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.2.tgz",
+ "integrity": "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==",
+ "license": "See LICENSE file",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/bottts-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.2.tgz",
+ "integrity": "sha512-kFNwWt6j+gzZ5n5Pz7WVwePubREAQOF8ZwWA9ztwVYDVMLnOChWbAofy5FED4j5md2MXFH2EgLCFCMr5K2BmIA==",
+ "license": "See LICENSE file",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/collection": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.4.2.tgz",
+ "integrity": "sha512-KArubv7if8H7j9sIfpDK2hJJqrdNVR5zMPAMOSpIU2JPyXx8TC9o5wsmXb8il5wOHgaS9Q/cla7jUNIiDD7Gsg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dicebear/adventurer": "9.4.2",
+ "@dicebear/adventurer-neutral": "9.4.2",
+ "@dicebear/avataaars": "9.4.2",
+ "@dicebear/avataaars-neutral": "9.4.2",
+ "@dicebear/big-ears": "9.4.2",
+ "@dicebear/big-ears-neutral": "9.4.2",
+ "@dicebear/big-smile": "9.4.2",
+ "@dicebear/bottts": "9.4.2",
+ "@dicebear/bottts-neutral": "9.4.2",
+ "@dicebear/croodles": "9.4.2",
+ "@dicebear/croodles-neutral": "9.4.2",
+ "@dicebear/dylan": "9.4.2",
+ "@dicebear/fun-emoji": "9.4.2",
+ "@dicebear/glass": "9.4.2",
+ "@dicebear/icons": "9.4.2",
+ "@dicebear/identicon": "9.4.2",
+ "@dicebear/initials": "9.4.2",
+ "@dicebear/lorelei": "9.4.2",
+ "@dicebear/lorelei-neutral": "9.4.2",
+ "@dicebear/micah": "9.4.2",
+ "@dicebear/miniavs": "9.4.2",
+ "@dicebear/notionists": "9.4.2",
+ "@dicebear/notionists-neutral": "9.4.2",
+ "@dicebear/open-peeps": "9.4.2",
+ "@dicebear/personas": "9.4.2",
+ "@dicebear/pixel-art": "9.4.2",
+ "@dicebear/pixel-art-neutral": "9.4.2",
+ "@dicebear/rings": "9.4.2",
+ "@dicebear/shapes": "9.4.2",
+ "@dicebear/thumbs": "9.4.2",
+ "@dicebear/toon-head": "9.4.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/core": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz",
+ "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@dicebear/croodles": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.2.tgz",
+ "integrity": "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/croodles-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.4.2.tgz",
+ "integrity": "sha512-oG5IeUdtiYshQ89gkAVcl5w3xAEi5UZX2fTzIyelpBPCG176l7VuuFzlxi2umnB3E6LVHYy06DXvUo/p+rXB2Q==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/dylan": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.4.2.tgz",
+ "integrity": "sha512-1vQvRu9x9DrwFxhFaIU2rf0EUL04yDTbAt7fHyAjM0mEsKzTD4mRNf95tCRuavCoW6W48u7A/OY6jyIub6kxLQ==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/fun-emoji": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.2.tgz",
+ "integrity": "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/glass": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.4.2.tgz",
+ "integrity": "sha512-z5qUogHQ1b6UJ2zCqT848mU2U9DKbVDhiX6GPDjD7tYLisCCJVisH9p6WyNdHvflUd4SHkA6gRqVJIh2v2HnTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/icons": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.4.2.tgz",
+ "integrity": "sha512-QSMMz0NA03ypSGhXC8HQX8FSj8lYT+/5yqH+/N03OH2IjL0q7wwGZ7nqsrtlRp76O5WqMTwGfSbTUUYPjFr+Xw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/identicon": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.2.tgz",
+ "integrity": "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/initials": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.2.tgz",
+ "integrity": "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/lorelei": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.2.tgz",
+ "integrity": "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/lorelei-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.4.2.tgz",
+ "integrity": "sha512-yspanTthA5vh6iCdeLzn6xZ4yYMYRcfcxblcgSvHTF1ut0bjAXtw5SXzZ6aJTrJWiHkzYOQuTOR6GVYiW80Q7w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/micah": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.2.tgz",
+ "integrity": "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/miniavs": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.4.2.tgz",
+ "integrity": "sha512-wLwyFNNUnDRd3BbhSBhXR0XEpX8sG0/xDA5M/OkDoapLqZnnI48YLUSDd2N5QTAVMmcSEuZOYxkcnj7WW79vlg==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/notionists": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.2.tgz",
+ "integrity": "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/notionists-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.4.2.tgz",
+ "integrity": "sha512-AyD9kEfVxQUwDGf4Op059gVmYIOAkTKg3dtE9h9mEKP7zl/kMy5B67BFFOo7sB0mXCjzAegZ6ekGU02E8+hIHw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/open-peeps": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.4.2.tgz",
+ "integrity": "sha512-i01tLgtp2g937T81sVeAOVlqsCtiTck/Kw20g7hN80+7xrXjOUepz2HPLy3HeiMjwjMGRy5o54kSd0/8Ht4Dqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/personas": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.4.2.tgz",
+ "integrity": "sha512-NJlkvI5F5gugt6t2+7QrYNTwQC7+4IQZS3vG0dYk2BncxOHax0BuLovdSdiAesTL4ZkytFYIydWmKmV2/xcUwg==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/pixel-art": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.2.tgz",
+ "integrity": "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/pixel-art-neutral": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.4.2.tgz",
+ "integrity": "sha512-9e9Lz554uQvWaXV2P17ss+hPa6rTyuAKBtB8zk8ECjHiZzIl61N/KcTVLZ4dILVZwj7gYriaLo16QEqvL2GJCg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/rings": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.2.tgz",
+ "integrity": "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/shapes": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.2.tgz",
+ "integrity": "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/thumbs": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.2.tgz",
+ "integrity": "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
+ "node_modules/@dicebear/toon-head": {
+ "version": "9.4.2",
+ "resolved": "https://registry.npmjs.org/@dicebear/toon-head/-/toon-head-9.4.2.tgz",
+ "integrity": "sha512-lwFeSXyAnaKnCfMt9TiJwnD1cXQUGkey/0h6i/+4TVHVMCz5/Ri5u1ynovPNHy1SnBf858QwoXHkxilGLwQX/g==",
+ "license": "(MIT AND CC-BY-4.0)",
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "@dicebear/core": "^9.0.0"
+ }
+ },
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1466,7 +1898,6 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
- "dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/types": {
diff --git a/server/package.json b/server/package.json
index ed46702..0e552ad 100644
--- a/server/package.json
+++ b/server/package.json
@@ -18,6 +18,8 @@
"db:reset:test": "prisma migrate reset --force"
},
"dependencies": {
+ "@dicebear/collection": "^9.4.2",
+ "@dicebear/core": "^9.4.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^10.0.0",
diff --git a/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql
new file mode 100644
index 0000000..4b0a7e4
--- /dev/null
+++ b/server/prisma/migrations/20260522143134_remove_unused_user_fields/migration.sql
@@ -0,0 +1,27 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `firstName` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `gender` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `lastName` on the `User` table. All the data in the column will be lost.
+
+*/
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_User" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "email" TEXT NOT NULL,
+ "displayName" TEXT,
+ "avatar" TEXT,
+ "avatarStyle" TEXT,
+ "passwordHash" TEXT,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
+INSERT INTO "new_User" ("avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt") SELECT "avatar", "avatarStyle", "createdAt", "displayName", "email", "id", "passwordHash", "updatedAt" FROM "User";
+DROP TABLE "User";
+ALTER TABLE "new_User" RENAME TO "User";
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/server/prisma/migrations/20260522175250_pending_email/migration.sql b/server/prisma/migrations/20260522175250_pending_email/migration.sql
new file mode 100644
index 0000000..b6dfb3c
--- /dev/null
+++ b/server/prisma/migrations/20260522175250_pending_email/migration.sql
@@ -0,0 +1,19 @@
+-- CreateTable
+CREATE TABLE "PendingEmail" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "userId" TEXT NOT NULL,
+ "email" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "expiresAt" DATETIME NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "PendingEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PendingEmail_token_key" ON "PendingEmail"("token");
+
+-- CreateIndex
+CREATE INDEX "PendingEmail_token_idx" ON "PendingEmail"("token");
+
+-- CreateIndex
+CREATE INDEX "PendingEmail_userId_idx" ON "PendingEmail"("userId");
diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db
index 27d8a10..873e50e 100644
Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index be0aa90..dc7ffde 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -78,9 +78,6 @@ model User {
id String @id @default(cuid())
email String @unique
displayName String?
- firstName String?
- lastName String?
- gender String?
avatar String?
avatarStyle String?
passwordHash String?
@@ -94,6 +91,7 @@ model User {
reviews Review[]
orderMessageReadStates UserOrderMessageReadState[]
oauthAccounts OAuthAccount[]
+ pendingEmails PendingEmail[]
notificationPreference NotificationPreference?
notificationLogs NotificationLog[]
}
@@ -264,6 +262,20 @@ model OAuthAccount {
@@index([userId])
}
+model PendingEmail {
+ id String @id @default(cuid())
+ userId String
+ email String
+ token String @unique
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([token])
+ @@index([userId])
+}
+
model AuthCode {
id String @id @default(cuid())
email String
diff --git a/server/src/index.js b/server/src/index.js
index 912e003..ce32fff 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -19,12 +19,12 @@ import { prisma } from './lib/prisma.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
-import { registerAuthRoutes } from './routes/auth.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerUploadsResized } from './routes/uploads-resized.js'
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
import { registerUserAddressRoutes } from './routes/user-addresses.js'
import { registerUserCartRoutes } from './routes/user-cart.js'
+import { registerSseRoutes } from './routes/sse.js'
import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js'
@@ -93,6 +93,7 @@ registerAuth(fastify)
await registerUserAddressRoutes(fastify)
await registerUserCartRoutes(fastify)
await registerUserMessageRoutes(fastify)
+await registerSseRoutes(fastify)
await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify)
diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js
index e79df0b..a4462f9 100644
--- a/server/src/lib/bootstrap-admin.js
+++ b/server/src/lib/bootstrap-admin.js
@@ -1,4 +1,5 @@
import { normalizeEmail } from './auth.js'
+import { generateAvatar } from './generate-avatar.js'
import { prisma } from './prisma.js'
export async function ensureAdminUser() {
@@ -8,10 +9,11 @@ export async function ensureAdminUser() {
throw new Error('ADMIN_EMAIL должен быть валидным email')
}
+ const avatarUri = await generateAvatar(adminEmail)
await prisma.user.upsert({
where: { email: adminEmail },
update: {},
- create: { email: adminEmail },
+ create: { email: adminEmail, avatar: avatarUri, avatarStyle: 'avataaars' },
})
// Ensure admin notification settings exist
diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js
new file mode 100644
index 0000000..e95d905
--- /dev/null
+++ b/server/src/lib/generate-avatar.js
@@ -0,0 +1,9 @@
+import { createAvatar } from '@dicebear/core'
+import { avataaars } from '@dicebear/collection'
+
+const DEFAULT_STYLE = avataaars
+
+export async function generateAvatar(seed) {
+ const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) })
+ return avatar.toDataUri()
+}
diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js
index 9e60924..e4dc118 100644
--- a/server/src/routes/__tests__/oauth-social.test.js
+++ b/server/src/routes/__tests__/oauth-social.test.js
@@ -2,22 +2,16 @@ import { describe, it, expect } from 'vitest'
import { prisma } from '../../lib/prisma.js'
describe('OAuth — User model fields', () => {
- it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => {
+ it('stores displayName and avatar fields on User model', async () => {
const user = await prisma.user.create({
data: {
email: 'test-oauth@example.com',
displayName: 'Test User',
- firstName: 'Test',
- lastName: 'User',
- gender: 'male',
avatar: 'https://example.com/avatar.jpg',
},
})
expect(user.displayName).toBe('Test User')
- expect(user.firstName).toBe('Test')
- expect(user.lastName).toBe('User')
- expect(user.gender).toBe('male')
expect(user.avatar).toBe('https://example.com/avatar.jpg')
await prisma.user.delete({ where: { id: user.id } })
@@ -31,9 +25,6 @@ describe('OAuth — User model fields', () => {
})
expect(user.displayName).toBeNull()
- expect(user.firstName).toBeNull()
- expect(user.lastName).toBeNull()
- expect(user.gender).toBeNull()
expect(user.avatar).toBeNull()
await prisma.user.delete({ where: { id: user.id } })
diff --git a/server/src/routes/__tests__/sse.test.js b/server/src/routes/__tests__/sse.test.js
new file mode 100644
index 0000000..082f34d
--- /dev/null
+++ b/server/src/routes/__tests__/sse.test.js
@@ -0,0 +1,188 @@
+import Fastify from 'fastify'
+import { EventEmitter } from 'node:events'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { buildSseListeners, formatHeartbit, formatSSE, isAdminUser, registerSseRoutes } from '../sse.js'
+
+describe('formatSSE', () => {
+ it('formats event with data', () => {
+ const result = formatSSE('message:new', { orderId: 'o1' })
+ expect(result).toBe('event: message:new\ndata: {"orderId":"o1"}\n\n')
+ })
+
+ it('formats event without data', () => {
+ const result = formatSSE('heartbit')
+ expect(result).toBe('event: heartbit\n\n')
+ })
+})
+
+describe('formatHeartbit', () => {
+ it('returns SSE comment', () => {
+ expect(formatHeartbit()).toBe(':heartbit\n\n')
+ })
+})
+
+describe('isAdminUser', () => {
+ it('returns false for non-matching email', () => {
+ expect(isAdminUser({ email: 'user@test.com' })).toBe(false)
+ })
+
+ it('returns true when email matches ADMIN_EMAIL', () => {
+ const adminEmail = process.env.ADMIN_EMAIL
+ if (!adminEmail) {
+ console.warn('ADMIN_EMAIL not set, skipping')
+ return
+ }
+ expect(isAdminUser({ email: adminEmail })).toBe(true)
+ })
+
+ it('returns false for null/undefined user', () => {
+ expect(isAdminUser(null)).toBe(false)
+ expect(isAdminUser(undefined)).toBe(false)
+ })
+})
+
+describe('buildSseListeners', () => {
+ let eventBus
+ let write
+
+ beforeEach(() => {
+ eventBus = new EventEmitter()
+ eventBus.setMaxListeners(50)
+ write = vi.fn()
+ })
+
+ it('forwards orderMessage:adminReply to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: message:new')
+ expect(write.mock.calls[0][0]).toContain('"orderId":"o1"')
+ cleanup()
+ })
+
+ it('ignores orderMessage:adminReply for non-matching userId', () => {
+ const cleanup = buildSseListeners('user-2', false, eventBus, write)
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('forwards orderMessage:sent to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: message:new')
+ cleanup()
+ })
+
+ it('ignores orderMessage:sent for non-admin', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('orderMessage:sent', { orderId: 'o1', authorType: 'user', messageId: 'm1', preview: 'Hello' })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('forwards order:statusChanged to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:statusChanged', { orderId: 'o1', userId: 'user-1', oldStatus: 'PENDING_PAYMENT', newStatus: 'PAID' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
+ expect(write.mock.calls[0][0]).toContain('"newStatus":"PAID"')
+ cleanup()
+ })
+
+ it('forwards payment:statusChanged to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('payment:statusChanged', { orderId: 'o1', userId: 'user-1', paymentStatus: 'paid' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:statusChanged')
+ cleanup()
+ })
+
+ it('forwards order:deliveryFeeAdjusted to matching userId', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:deliveryFeeAdjusted', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:updated')
+ cleanup()
+ })
+
+ it('forwards order:created to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:new')
+ cleanup()
+ })
+
+ it('forwards order:created:admin to admin', () => {
+ const cleanup = buildSseListeners('admin-1', true, eventBus, write)
+ eventBus.emit('order:created:admin', { orderId: 'o1', userId: 'user-1', userEmail: 'user@test.com' })
+ expect(write).toHaveBeenCalledTimes(1)
+ expect(write.mock.calls[0][0]).toContain('event: order:new')
+ cleanup()
+ })
+
+ it('ignores order:created for non-admin', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ eventBus.emit('order:created', { orderId: 'o1', userId: 'user-1', totalCents: 50000 })
+ expect(write).not.toHaveBeenCalled()
+ cleanup()
+ })
+
+ it('cleanup removes all listeners', () => {
+ const cleanup = buildSseListeners('user-1', false, eventBus, write)
+ cleanup()
+ eventBus.emit('orderMessage:adminReply', { orderId: 'o1', userId: 'user-1', messageId: 'm1', preview: 'Hi' })
+ expect(write).not.toHaveBeenCalled()
+ })
+})
+
+describe('GET /api/sse/stream (integration)', () => {
+ let app
+
+ beforeAll(async () => {
+ app = Fastify({ logger: false })
+ app.decorate('authenticate', async function (request, reply) {
+ try {
+ const token = request.query?.token
+ if (!token) throw new Error('no token')
+ if (token === 'user-token') {
+ request.user = { sub: 'user-1', email: 'user@test.com' }
+ } else if (token === 'admin-token') {
+ request.user = { sub: 'admin-1', email: process.env.ADMIN_EMAIL || 'admin@test.com' }
+ } else {
+ throw new Error('bad token')
+ }
+ } catch {
+ return reply.code(401).send({ error: 'Unauthorized' })
+ }
+ })
+ app.decorate('eventBus', new EventEmitter())
+ await registerSseRoutes(app)
+ await app.ready()
+ })
+
+ afterAll(async () => {
+ await app.close()
+ })
+
+ it('returns 401 without token', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream' })
+ expect(res.statusCode).toBe(401)
+ })
+
+ it('returns 200 and event-stream headers for authenticated user', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true })
+ expect(res.statusCode).toBe(200)
+ expect(res.headers['content-type']).toBe('text/event-stream')
+ expect(res.headers['cache-control']).toBe('no-cache')
+ expect(res.headers['connection']).toBe('keep-alive')
+ })
+
+ it('sends initial heartbit', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/sse/stream?token=user-token', payloadAsStream: true })
+ const body = res.stream().read().toString()
+ expect(body).toContain(':heartbit')
+ })
+})
diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js
index 3d9d5e6..082dd7b 100644
--- a/server/src/routes/api/_product-helpers.js
+++ b/server/src/routes/api/_product-helpers.js
@@ -5,7 +5,7 @@ export function slugify(input) {
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
- .replace(/[^a-z0-9-а-яё]/gi, '')
+ .replace(/[^a-z0-9-]/gi, '')
}
export function safeExtFromFilename(filename) {
diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js
index 58265c1..ebf81c5 100644
--- a/server/src/routes/api/public-reviews.js
+++ b/server/src/routes/api/public-reviews.js
@@ -38,9 +38,9 @@ export async function registerPublicReviewRoutes(fastify) {
const take = Math.min(parsed, 5)
const rows = await prisma.review.findMany({
- where: { status: 'approved', product: { published: true } },
+ where: { status: 'approved' },
include: {
- user: { select: { email: true, displayName: true, avatar: true, avatarStyle: true } },
+ user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
product: { select: { id: true, title: true, published: true, slug: true } },
},
orderBy: { createdAt: 'desc' },
@@ -53,6 +53,7 @@ export async function registerPublicReviewRoutes(fastify) {
text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt,
+ authorId: r.user?.id ?? r.userId,
authorDisplay: publicReviewAuthorDisplay(r.user),
authorAvatar: r.user?.avatar ?? null,
authorAvatarStyle: r.user?.avatarStyle ?? null,
@@ -87,7 +88,7 @@ export async function registerPublicReviewRoutes(fastify) {
const rawItems = await prisma.review.findMany({
where,
include: {
- user: { select: { email: true, displayName: true, avatar: true, avatarStyle: true } },
+ user: { select: { id: true, email: true, displayName: true, avatar: true, avatarStyle: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
@@ -100,6 +101,7 @@ export async function registerPublicReviewRoutes(fastify) {
text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt,
+ authorId: r.user?.id ?? r.userId,
authorDisplay: publicReviewAuthorDisplay(r.user),
authorAvatar: r.user?.avatar ?? null,
authorAvatarStyle: r.user?.avatarStyle ?? null,
diff --git a/server/src/routes/auth-session.js b/server/src/routes/auth-session.js
index 636f712..e12dabc 100644
--- a/server/src/routes/auth-session.js
+++ b/server/src/routes/auth-session.js
@@ -1,3 +1,5 @@
+import crypto from 'node:crypto'
+import { normalizeEmail } from '../lib/auth.js'
import { prisma } from '../lib/prisma.js'
import { mapUserForClient } from './auth.js'
@@ -26,4 +28,54 @@ export async function registerAuthSessionRoutes(fastify) {
],
}
})
+
+ fastify.patch('/api/me/email', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ const userId = request.user.sub
+ const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : ''
+
+ if (!rawEmail || !rawEmail.includes('@')) {
+ return reply.code(400).send({ error: 'Некорректная почта' })
+ }
+
+ const email = normalizeEmail(rawEmail)
+
+ const existing = await prisma.user.findUnique({ where: { email } })
+ if (existing && existing.id !== userId) {
+ return reply.code(409).send({ error: 'Эта почта уже используется' })
+ }
+
+ await prisma.pendingEmail.deleteMany({ where: { userId } })
+
+ const token = crypto.randomUUID()
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
+
+ await prisma.pendingEmail.create({
+ data: { userId, email, token, expiresAt },
+ })
+
+ return { verificationUrl: `/api/me/verify-email?token=${token}` }
+ })
+
+ fastify.get('/api/me/verify-email', async (request, reply) => {
+ const token = typeof request.query?.token === 'string' ? request.query.token : ''
+
+ if (!token) {
+ return reply.code(400).send({ error: 'Отсутствует токен подтверждения' })
+ }
+
+ const pending = await prisma.pendingEmail.findUnique({ where: { token } })
+ if (!pending || pending.expiresAt < new Date()) {
+ return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' })
+ }
+
+ await prisma.user.update({
+ where: { id: pending.userId },
+ data: { email: pending.email },
+ })
+
+ await prisma.pendingEmail.delete({ where: { id: pending.id } })
+
+ const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '')
+ return reply.redirect(`${clientUrl}/me?emailVerified=1`)
+ })
}
diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js
index 0fe9e20..719e90e 100644
--- a/server/src/routes/auth.js
+++ b/server/src/routes/auth.js
@@ -8,6 +8,7 @@ import {
validatePassword,
verifyEmailCode,
} from '../lib/auth.js'
+import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js'
@@ -18,9 +19,6 @@ export function mapUserForClient(user) {
id: user.id,
email: user.email,
displayName: user.displayName,
- firstName: user.firstName,
- lastName: user.lastName,
- gender: user.gender,
avatar: user.avatar,
avatarStyle: user.avatarStyle,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
@@ -55,10 +53,11 @@ export async function registerAuthRoutes(fastify) {
const ok = await verifyEmailCode({ email, purpose: 'login', code })
if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' })
+ const avatarUri = await generateAvatar(email)
const user = await prisma.user.upsert({
where: { email },
update: {},
- create: { email },
+ create: { email, avatar: avatarUri, avatarStyle: 'avataaars' },
})
// Ensure notification preference exists
@@ -88,12 +87,13 @@ export async function registerAuthRoutes(fastify) {
if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' })
const passwordHash = await hashPassword(password)
+ const avatarUri = await generateAvatar(email)
const user = await prisma.user.create({
data: {
email,
passwordHash,
displayName: displayName || null,
- avatar: null,
+ avatar: avatarUri,
avatarStyle: 'avataaars',
},
})
diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js
index cfb3c9e..faf74b3 100644
--- a/server/src/routes/oauth-social.js
+++ b/server/src/routes/oauth-social.js
@@ -1,6 +1,39 @@
+import crypto from 'node:crypto'
import { normalizeEmail } from '../lib/auth.js'
+import { generateAvatar } from '../lib/generate-avatar.js'
import { prisma } from '../lib/prisma.js'
+const pkceStore = new Map()
+
+function storePkce(state, codeVerifier, meta = {}) {
+ pkceStore.set(state, { codeVerifier, meta, createdAt: Date.now() })
+}
+
+function consumePkce(state) {
+ const entry = pkceStore.get(state)
+ if (entry) {
+ pkceStore.delete(state)
+ return { codeVerifier: entry.codeVerifier, meta: entry.meta }
+ }
+ return null
+}
+
+function generatePkcePair() {
+ const verifier = crypto.randomBytes(48).toString('base64url').slice(0, 64)
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
+ return { codeVerifier: verifier, codeChallenge: challenge }
+}
+
+function decodeIdTokenPayload(idToken) {
+ const parts = idToken.split('.')
+ if (parts.length !== 3) return null
+ try {
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
+ } catch {
+ return null
+ }
+}
+
function clientRedirect(fastify, reply, token) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
@@ -36,7 +69,6 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
const norm = trimmed ? normalizeEmail(trimmed) : null
if (linkToUserId) {
- if (!norm) return null
await prisma.oAuthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
})
@@ -51,13 +83,13 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
return user
}
- if (!norm) return null
+ const email = norm || `${provider}_${providerUserId}@vk.local`
user = await prisma.user.create({
data: {
- email: norm,
- displayName: norm.split('@')[0],
- avatar: null,
+ email,
+ displayName: norm ? norm.split('@')[0] : 'Пользователь',
+ avatar: await generateAvatar(email),
avatarStyle: 'avataaars',
},
})
@@ -71,7 +103,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
}
export async function registerOAuthSocialRoutes(fastify) {
- const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333'
+ const serverPublic = (process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333').replace(/\/$/, '')
/** --- VK --- */
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
@@ -80,15 +112,17 @@ export async function registerOAuthSocialRoutes(fastify) {
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
- const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' })
+ const { codeVerifier, codeChallenge } = generatePkcePair()
+ const state = crypto.randomUUID()
+ storePkce(state, codeVerifier)
- const url = new URL('https://oauth.vk.com/authorize')
+ const url = new URL('https://id.vk.ru/authorize')
url.searchParams.set('client_id', clientId)
- url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri)
- url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code')
- url.searchParams.set('v', '5.199')
+ url.searchParams.set('scope', 'email')
+ url.searchParams.set('code_challenge', codeChallenge)
+ url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
@@ -105,15 +139,17 @@ export async function registerOAuthSocialRoutes(fastify) {
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' })
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
- const state = fastify.jwt.sign({ oauth: 'vk', action: 'link', userId: request.user.sub }, { expiresIn: '15m' })
+ const { codeVerifier, codeChallenge } = generatePkcePair()
+ const state = crypto.randomUUID()
+ storePkce(state, codeVerifier, { action: 'link', userId: request.user.sub })
- const url = new URL('https://oauth.vk.com/authorize')
+ const url = new URL('https://id.vk.ru/authorize')
url.searchParams.set('client_id', clientId)
- url.searchParams.set('display', 'page')
url.searchParams.set('redirect_uri', redirectUri)
- url.searchParams.set('scope', 'email')
url.searchParams.set('response_type', 'code')
- url.searchParams.set('v', '5.199')
+ url.searchParams.set('scope', 'email')
+ url.searchParams.set('code_challenge', codeChallenge)
+ url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
@@ -125,55 +161,61 @@ export async function registerOAuthSocialRoutes(fastify) {
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
}
- const statePayload = (() => {
- try {
- const raw = typeof query.state === 'string' ? query.state : ''
- return fastify.jwt.verify(raw || '')
- } catch {
- return null
- }
- })()
- if (!statePayload) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
+ const state = typeof query.state === 'string' ? query.state.trim() : ''
+ if (!state) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
+
+ const pkceEntry = consumePkce(state)
+ if (!pkceEntry) return oauthErrorRedirect(reply, 'Недействительный state OAuth')
const code = typeof query.code === 'string' ? query.code.trim() : ''
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
+ const deviceId = typeof query.device_id === 'string' ? query.device_id : null
+
const clientId = process.env.VK_CLIENT_ID
const clientSecret = process.env.VK_CLIENT_SECRET
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
- const tokenUrl = new URL('https://oauth.vk.com/access_token')
- tokenUrl.searchParams.set('client_id', clientId)
- tokenUrl.searchParams.set('client_secret', clientSecret)
- tokenUrl.searchParams.set('redirect_uri', redirectUri)
- tokenUrl.searchParams.set('code', code)
+ const body = new URLSearchParams()
+ body.set('grant_type', 'authorization_code')
+ body.set('client_id', clientId)
+ body.set('client_secret', clientSecret)
+ body.set('code', code)
+ body.set('code_verifier', pkceEntry.codeVerifier)
+ body.set('redirect_uri', redirectUri)
+ if (deviceId) {
+ body.set('device_id', deviceId)
+ }
- const tokenRes = await fetch(tokenUrl.toString())
+ const tokenRes = await fetch('https://id.vk.ru/oauth2/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString(),
+ })
const tokenBody = await tokenRes.json()
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
}
- const vkUserId = tokenBody?.user_id
- const accessTokenVk = tokenBody?.access_token
+ const idToken = typeof tokenBody?.id_token === 'string' ? tokenBody.id_token : null
+ const claims = idToken ? decodeIdTokenPayload(idToken) : null
- const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
+ const vkUserId = claims?.sub ?? tokenBody?.user_id
+ const emailSuggestion = claims?.email ?? tokenBody?.email ?? null
- if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')
+ if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id')
- const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
+ const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined
const user = await findOrCreateUserFromOAuth({
provider: 'vk',
providerUserId: String(vkUserId),
- accessToken: accessTokenVk ?? null,
+ accessToken: tokenBody?.access_token ?? null,
suggestedEmail: emailSuggestion,
linkToUserId,
})
- if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK')
-
if (linkToUserId) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
diff --git a/server/src/routes/sse.js b/server/src/routes/sse.js
new file mode 100644
index 0000000..d96e125
--- /dev/null
+++ b/server/src/routes/sse.js
@@ -0,0 +1,140 @@
+import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
+
+const {
+ ORDER_CREATED,
+ ORDER_STATUS_CHANGED,
+ ORDER_MESSAGE_SENT,
+ ORDER_MESSAGE_ADMIN_REPLY,
+ PAYMENT_STATUS_CHANGED,
+ DELIVERY_FEE_ADJUSTED,
+} = NOTIFICATION_EVENTS
+
+export function isAdminUser(user) {
+ return !!(process.env.ADMIN_EMAIL && user?.email === process.env.ADMIN_EMAIL)
+}
+
+export function formatSSE(event, data) {
+ const lines = [`event: ${event}`]
+ if (data !== undefined) {
+ lines.push(`data: ${JSON.stringify(data)}`)
+ }
+ return lines.join('\n') + '\n\n'
+}
+
+export function formatHeartbit() {
+ return ':heartbit\n\n'
+}
+
+export function buildSseListeners(userId, admin, eventBus, write) {
+ const listeners = []
+
+ function on(eventName, filterFn, sseEvent, dataFn) {
+ function handler(payload) {
+ if (!filterFn(payload)) return
+ write(formatSSE(sseEvent, dataFn(payload)))
+ }
+ listeners.push({ eventName, handler })
+ eventBus.on(eventName, handler)
+ }
+
+ on(
+ ORDER_MESSAGE_ADMIN_REPLY,
+ (p) => p.userId === userId,
+ 'message:new',
+ (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
+ )
+
+ on(
+ ORDER_MESSAGE_SENT,
+ () => admin,
+ 'message:new',
+ (p) => ({ orderId: p.orderId, messageId: p.messageId, preview: p.preview }),
+ )
+
+ on(
+ ORDER_STATUS_CHANGED,
+ (p) => p.userId === userId,
+ 'order:statusChanged',
+ (p) => ({ orderId: p.orderId, newStatus: p.newStatus }),
+ )
+
+ on(
+ PAYMENT_STATUS_CHANGED,
+ (p) => p.userId === userId,
+ 'order:statusChanged',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ DELIVERY_FEE_ADJUSTED,
+ (p) => p.userId === userId,
+ 'order:updated',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ ORDER_CREATED,
+ () => admin,
+ 'order:new',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ on(
+ 'order:created:admin',
+ () => admin,
+ 'order:new',
+ (p) => ({ orderId: p.orderId }),
+ )
+
+ return function cleanup() {
+ for (const { eventName, handler } of listeners) {
+ eventBus.off(eventName, handler)
+ }
+ }
+}
+
+export async function registerSseRoutes(fastify) {
+ fastify.get('/api/sse/stream', { preHandler: [fastify.authenticate] }, async (request, reply) => {
+ reply.hijack()
+
+ reply.raw.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ 'X-Accel-Buffering': 'no',
+ })
+
+ let closed = false
+
+ function safeWrite(chunk) {
+ if (closed) return
+ try {
+ reply.raw.write(chunk)
+ } catch {
+ closed = true
+ cleanUp()
+ }
+ }
+
+ const userId = request.user.sub
+ const admin = isAdminUser(request.user)
+
+ safeWrite(formatHeartbit())
+
+ const heartbitTimer = setInterval(() => {
+ safeWrite(formatHeartbit())
+ }, 30_000)
+
+ const removeListeners = buildSseListeners(userId, admin, fastify.eventBus, safeWrite)
+
+ function cleanUp() {
+ if (closed) return
+ closed = true
+ clearInterval(heartbitTimer)
+ removeListeners()
+ }
+
+ request.raw.on('close', cleanUp)
+ request.raw.on('error', cleanUp)
+ })
+}
diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js
index 6916bb1..2c2da0f 100644
--- a/server/src/routes/user-orders.js
+++ b/server/src/routes/user-orders.js
@@ -37,7 +37,7 @@ export async function registerUserOrderRoutes(fastify) {
carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim()
if (!isDeliveryCarrier(carrierStr)) {
return reply.code(400).send({
- error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST',
+ error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST | WB_PVZ',
})
}
deliveryCarrier = carrierStr
diff --git a/shared/constants/delivery-carrier.d.ts b/shared/constants/delivery-carrier.d.ts
index b9ece2f..d48d06e 100644
--- a/shared/constants/delivery-carrier.d.ts
+++ b/shared/constants/delivery-carrier.d.ts
@@ -1,10 +1,11 @@
-export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
+export declare const DELIVERY_CARRIERS: readonly ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ']
export declare const DELIVERY_CARRIER_LABELS: {
readonly RUSSIAN_POST: 'Почта России'
readonly OZON_PVZ: 'Озон доставка (пункт выдачи)'
readonly YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)'
readonly FIVE_POST: '5Post (пункт выдачи)'
+ readonly WB_PVZ: 'WB доставка (пункт выдачи)'
}
export declare function deliveryCarrierLabelRu(code: string | null | undefined): string | null
diff --git a/shared/constants/delivery-carrier.js b/shared/constants/delivery-carrier.js
index 06bc1d5..f3afe51 100644
--- a/shared/constants/delivery-carrier.js
+++ b/shared/constants/delivery-carrier.js
@@ -1,10 +1,11 @@
-export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'])
+export const DELIVERY_CARRIERS = Object.freeze(['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST', 'WB_PVZ'])
export const DELIVERY_CARRIER_LABELS = Object.freeze({
RUSSIAN_POST: 'Почта России',
OZON_PVZ: 'Озон доставка (пункт выдачи)',
YANDEX_PVZ: 'Яндекс доставка (пункт выдачи)',
FIVE_POST: '5Post (пункт выдачи)',
+ WB_PVZ: 'WB доставка (пункт выдачи)',
})
export function deliveryCarrierLabelRu(code) {