Merge branch 'sitefixws'
This commit is contained in:
@@ -0,0 +1 @@
|
||||
1531
|
||||
@@ -0,0 +1 @@
|
||||
1702
|
||||
@@ -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' } }}
|
||||
>
|
||||
<SvgIcon sx={{ fontSize: 20 }}>
|
||||
<path d="M12.776 2.553a.267.267 0 0 0-.104-.002C10.184 2.973 4.08 7.575 2.34 9.478c-.19.208-.33.505-.33.85 0 .344.122.634.3.85.556.688 2.005 1.759 2.005 1.759s1.168 2.52 1.76 3.677c.232.463.45.858.604 1.13.348.611.534.857.926 1.077.392.22.776.22 1.194.01.417-.212 2.452-1.61 3.46-2.36.256-.19.49-.19.73.01 1.517 1.256 3.2 2.743 4.003 3.586.373.391.701.548 1.104.548.402 0 .683-.283.805-.86.053-.25 1.025-5.076 1.025-5.076s.633-2.24 2.763-3.897c.292-.228.462-.57.462-.95 0-.38-.143-.68-.38-.895-1.198-1.088-5.9-3.039-6.365-3.226z" />
|
||||
</SvgIcon>
|
||||
<Box component="img" src={vkLogoSrc} alt="VK" sx={{ width: 20, height: 20 }} />
|
||||
VK
|
||||
</Link>
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SseProvider />
|
||||
<ThemeControllerProvider>
|
||||
<AppThemeInner>{children}</AppThemeInner>
|
||||
</ThemeControllerProvider>
|
||||
|
||||
@@ -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<EventSource | null>(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
|
||||
}
|
||||
@@ -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<string, (event: MessageEvent) => 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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SseProvider />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<UserAvatar
|
||||
userId={rv.authorDisplay}
|
||||
userId={rv.authorId}
|
||||
avatarUrl={rv.authorAvatar}
|
||||
avatarStyle={rv.authorAvatarStyle}
|
||||
size={32}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Link from '@mui/material/Link'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import Map, { Marker } from 'react-map-gl/maplibre'
|
||||
import { STORE_EMAIL, STORE_PHONE, VK_URL } from '@/shared/config'
|
||||
import { PICKUP_ADDRESS_FULL, PICKUP_COORDINATES } from '@/shared/constants/pickup-point'
|
||||
|
||||
const rasterStyle = {
|
||||
@@ -39,6 +41,23 @@ export function AboutPage() {
|
||||
Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже):
|
||||
</Typography>
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap', fontWeight: 600 }}>{PICKUP_ADDRESS_FULL}</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Email:{' '}
|
||||
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
||||
{STORE_EMAIL}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Телефон:{' '}
|
||||
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
|
||||
{STORE_PHONE}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Link href={VK_URL} target="_blank" rel="noopener noreferrer" underline="hover">
|
||||
ВКонтакте
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||
Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче.
|
||||
</Typography>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -191,8 +191,8 @@ export function CheckoutPage() {
|
||||
)}
|
||||
|
||||
<Alert severity="info">
|
||||
Стоимость доставки ориентировочно 300 ₽. Точная цена будет скорректирована после расчёта. В сумме заказа
|
||||
сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
|
||||
Сумма доставки зависит от региона и способа доставки. Точная цена будет скорректирована после расчёта. В
|
||||
сумме заказа сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [verificationUrl, setVerificationUrl] = useState<string | null>(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 (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Почта
|
||||
</Typography>
|
||||
|
||||
{emailVerified === '1' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Почта успешно подтверждена
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography sx={{ mb: 2 }} color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
|
||||
{!verificationUrl && (
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
label="Новая почта"
|
||||
type="email"
|
||||
size="small"
|
||||
{...emailForm.register('email')}
|
||||
error={Boolean(emailChangeError)}
|
||||
helperText={emailChangeError}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!emailForm.watch('email') || emailChangeMutation.isPending}
|
||||
onClick={() => {
|
||||
const email = emailForm.getValues('email')
|
||||
if (email) emailChangeMutation.mutate(email)
|
||||
}}
|
||||
>
|
||||
Сменить
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{verificationUrl && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
<Stack spacing={1} direction="row" sx={{ alignItems: 'center' }}>
|
||||
<span>Ссылка подтверждения готова.</span>
|
||||
<Button size="small" variant="contained" href={verificationUrl}>
|
||||
Подтвердить email
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||
Методы входа
|
||||
</Typography>
|
||||
{fetchError && (
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AuthMethodsSection />
|
||||
<MemoryRouter>
|
||||
<AuthMethodsSection />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="101" height="100" viewBox="0 0 101 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2_40)">
|
||||
<path d="M0.5 48C0.5 25.3726 0.5 14.0589 7.52944 7.02944C14.5589 0 25.8726 0 48.5 0H52.5C75.1274 0 86.4411 0 93.4706 7.02944C100.5 14.0589 100.5 25.3726 100.5 48V52C100.5 74.6274 100.5 85.9411 93.4706 92.9706C86.4411 100 75.1274 100 52.5 100H48.5C25.8726 100 14.5589 100 7.52944 92.9706C0.5 85.9411 0.5 74.6274 0.5 52V48Z" fill="#0077FF"/>
|
||||
<path d="M53.7085 72.042C30.9168 72.042 17.9169 56.417 17.3752 30.417H28.7919C29.1669 49.5003 37.5834 57.5836 44.25 59.2503V30.417H55.0004V46.8752C61.5837 46.1669 68.4995 38.667 70.8329 30.417H81.5832C79.7915 40.5837 72.2915 48.0836 66.9582 51.1669C72.2915 53.6669 80.8336 60.2086 84.0836 72.042H72.2499C69.7082 64.1253 63.3754 58.0003 55.0004 57.1669V72.042H53.7085Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2_40">
|
||||
<rect width="100" height="100" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 996 B |
@@ -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'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function createEventStream(token: string): EventSource {
|
||||
return new EventSource(`/api/sse/stream?token=${encodeURIComponent(token)}`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -102,7 +102,7 @@ export function ReviewsBlock() {
|
||||
)}
|
||||
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
|
||||
<UserAvatar
|
||||
userId={r.authorDisplay}
|
||||
userId={r.authorId}
|
||||
avatarUrl={r.authorAvatar}
|
||||
avatarStyle={r.authorAvatarStyle}
|
||||
size={40}
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
# SSE Realtime Implementation Plan
|
||||
|
||||
> **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<string, (event: MessageEvent) => 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(<QueryClientProvider client={qc}><SseProvider /></QueryClientProvider>)
|
||||
}
|
||||
|
||||
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<EventSource | null>(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 `<SseProvider />` as first child of `QueryClientProvider`:
|
||||
|
||||
```tsx
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SseProvider />
|
||||
<ThemeControllerProvider>
|
||||
<AppThemeInner>{children}</AppThemeInner>
|
||||
</ThemeControllerProvider>
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
- [ ] **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
|
||||
@@ -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 `<SseProvider>` 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.
|
||||
@@ -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_<providerUserId>@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_<providerUserId>@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=<uuid>' }` (отправка 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)
|
||||
Generated
+432
-1
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Vendored
+3
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user