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