Merge branch 'notification'

This commit is contained in:
Kirill
2026-05-18 12:19:44 +05:00
54 changed files with 5421 additions and 227 deletions
@@ -1,5 +1,7 @@
import type { GalleryImageItem } from '@/entities/gallery/model/types' import type { GalleryImageItem } from '@/entities/gallery/model/types'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { apiBaseURL } from '@/shared/config'
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> { export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery') const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
@@ -9,3 +11,42 @@ export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }
export async function deleteGalleryImage(id: string): Promise<void> { export async function deleteGalleryImage(id: string): Promise<void> {
await apiClient.delete(`admin/gallery/${id}`) await apiClient.delete(`admin/gallery/${id}`)
} }
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
for (const f of files) {
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
throw new Error(
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
)
}
}
const fd = new FormData()
for (const f of files) {
fd.append('files', f, f.name)
}
const token = localStorage.getItem('craftshop_auth_token')
const base = apiBaseURL.replace(/\/$/, '')
const res = await fetch(`${base}/admin/gallery/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
})
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
if (!res.ok) {
if (res.status === 413) {
throw new Error(
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
)
}
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
}
if (!Array.isArray(payload.urls)) {
throw new Error('Некорректный ответ сервера')
}
return payload.urls
}
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
return data
}
+1 -1
View File
@@ -1,3 +1,3 @@
export { fetchAdminGallery, deleteGalleryImage } from './api/gallery-api' export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types' export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid' export { GalleryGrid } from './ui/GalleryGrid'
@@ -1,5 +1,6 @@
export type GalleryImageItem = { export type GalleryImageItem = {
id: string id: string
url: string url: string
isResized: boolean
createdAt: string createdAt: string
} }
+56 -18
View File
@@ -1,5 +1,8 @@
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined' import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Chip from '@mui/material/Chip'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip' import Tooltip from '@mui/material/Tooltip'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
@@ -8,10 +11,12 @@ import type { GalleryImageItem } from '../model/types'
type Props = { type Props = {
items: GalleryImageItem[] items: GalleryImageItem[]
deleting?: boolean deleting?: boolean
resizing?: string | null
onDelete: (id: string) => void onDelete: (id: string) => void
onResize: (id: string) => void
} }
export function GalleryGrid({ items, deleting, onDelete }: Props) { export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
return ( return (
<Box <Box
sx={{ sx={{
@@ -38,23 +43,56 @@ export function GalleryGrid({ items, deleting, onDelete }: Props) {
sizes="140px" sizes="140px"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
<Tooltip title="Удалить из галереи"> <Box sx={{ position: 'absolute', top: 4, left: 4 }}>
<IconButton {item.isResized ? (
size="small" <Chip
color="error" label="Готово"
sx={{ size="small"
position: 'absolute', color="success"
top: 4, icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
right: 4, sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
bgcolor: 'background.paper', />
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' }, ) : (
}} <Chip
disabled={deleting} label="Не обработано"
onClick={() => onDelete(item.id)} size="small"
> color="warning"
<DeleteOutlineOutlinedIcon fontSize="small" /> sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
</IconButton> />
</Tooltip> )}
</Box>
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
{!item.isResized && (
<Tooltip title="Обработать (resize)">
<IconButton
size="small"
color="primary"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
}}
disabled={resizing === item.id}
onClick={() => onResize(item.id)}
>
<AutoFixHighOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Удалить из галереи">
<IconButton
size="small"
color="error"
sx={{
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
}}
disabled={deleting}
onClick={() => onDelete(item.id)}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box> </Box>
))} ))}
</Box> </Box>
@@ -0,0 +1,53 @@
import { apiClient } from '@/shared/api/client'
export interface UserNotificationSettings {
id: string
userId: string
globalEnabled: boolean
orderCreated: boolean
orderStatusChanged: boolean
orderMessageReceived: boolean
paymentStatusChanged: boolean
createdAt: string
updatedAt: string
}
export interface AdminNotificationSettings {
id: string
emailEnabled: boolean
telegramEnabled: boolean
telegramChatId: string | null
newOrder: boolean
newOrderMessage: boolean
newReview: boolean
authCodeDuplicate: boolean
createdAt: string
updatedAt: string
}
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
return data
}
export async function updateUserNotificationSettings(
settings: Partial<UserNotificationSettings>,
): Promise<{ settings: UserNotificationSettings }> {
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
return data
}
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
return data
}
export async function updateAdminNotificationSettings(
settings: Partial<AdminNotificationSettings>,
): Promise<{ settings: AdminNotificationSettings }> {
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
'admin/notifications/settings',
settings,
)
return data
}
@@ -1,4 +1,4 @@
import { useRef } from 'react' import { useRef, useState } from 'react'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
@@ -6,8 +6,13 @@ import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider' import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery' import {
import { uploadAdminProductImages } from '@/entities/product/api/product-api' deleteGalleryImage,
fetchAdminGallery,
GalleryGrid,
resizeGalleryImage,
uploadGalleryImages,
} from '@/entities/gallery'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits' import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { GallerySliderSection } from './GallerySliderSection' import { GallerySliderSection } from './GallerySliderSection'
@@ -22,6 +27,7 @@ function getApiErrorMessage(error: unknown): string | null {
export function AdminGalleryPage() { export function AdminGalleryPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [resizingId, setResizingId] = useState<string | null>(null)
const sliderQuery = useQuery({ const sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'], queryKey: ['admin', 'catalog-slider'],
@@ -34,7 +40,7 @@ export function AdminGalleryPage() {
}) })
const uploadMut = useMutation({ const uploadMut = useMutation({
mutationFn: (files: File[]) => uploadAdminProductImages(files), mutationFn: (files: File[]) => uploadGalleryImages(files),
onSuccess: () => { onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery']]) void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
if (fileInputRef.current) { if (fileInputRef.current) {
@@ -50,6 +56,20 @@ export function AdminGalleryPage() {
}, },
}) })
const resizeMut = useMutation({
mutationFn: async (id: string) => {
setResizingId(id)
try {
return await resizeGalleryImage(id)
} finally {
setResizingId(null)
}
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const items = galleryQuery.data?.items ?? [] const items = galleryQuery.data?.items ?? []
return ( return (
@@ -58,8 +78,8 @@ export function AdminGalleryPage() {
Галерея Галерея
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре. изображения доступны для добавления в карточку товара и слайдер.
</Typography> </Typography>
{sliderQuery.isError && ( {sliderQuery.isError && (
@@ -114,6 +134,9 @@ export function AdminGalleryPage() {
{deleteMut.isError && ( {deleteMut.isError && (
<Typography color="error">{getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'}</Typography> <Typography color="error">{getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'}</Typography>
)} )}
{resizeMut.isError && (
<Typography color="error">{getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'}</Typography>
)}
</Stack> </Stack>
{galleryQuery.isError && ( {galleryQuery.isError && (
@@ -122,7 +145,13 @@ export function AdminGalleryPage() {
</Typography> </Typography>
)} )}
<GalleryGrid items={items} deleting={deleteMut.isPending} onDelete={(id) => deleteMut.mutate(id)} /> <GalleryGrid
items={items}
deleting={deleteMut.isPending}
resizing={resizingId}
onDelete={(id) => deleteMut.mutate(id)}
onResize={(id) => resizeMut.mutate(id)}
/>
{!galleryQuery.isLoading && items.length === 0 && ( {!galleryQuery.isLoading && items.length === 0 && (
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography> <Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
@@ -31,7 +31,7 @@ export function GallerySliderSection({ initialSlides, galleryItems }: Props) {
const [pickOpen, setPickOpen] = useState(false) const [pickOpen, setPickOpen] = useState(false)
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId)) const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id)) const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id) && i.isResized)
const saveSliderMut = useMutation({ const saveSliderMut = useMutation({
mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }), mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }),
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { FileText, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react' import { Bell, FileText, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminCategoriesPage } from '@/pages/admin-categories' import { AdminCategoriesPage } from '@/pages/admin-categories'
@@ -26,6 +26,7 @@ import { AdminProductsPage } from '@/pages/admin-products'
import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users' import { AdminUsersPage } from '@/pages/admin-users'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
import { AdminNotificationsPage } from './AdminNotificationsPage'
type NavItem = { type NavItem = {
to: string to: string
@@ -61,6 +62,7 @@ export function AdminLayoutPage() {
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> }, { to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> }, { to: '/admin/users', label: 'Пользователи', icon: <Users /> },
{ to: '/admin/info', label: 'Инфо-страница', icon: <FileText /> }, { to: '/admin/info', label: 'Инфо-страница', icon: <FileText /> },
{ to: '/admin/notifications', label: 'Оповещения', icon: <Bell /> },
], ],
[], [],
) )
@@ -188,6 +190,7 @@ export function AdminLayoutPage() {
<Route path="reviews" element={<AdminReviewsPage />} /> <Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="info" element={<AdminInfoPage />} /> <Route path="info" element={<AdminInfoPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Routes> </Routes>
</Box> </Box>
@@ -0,0 +1,132 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from '@/entities/notification/api/notifications-api'
export function AdminNotificationsPage() {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'notifications', 'settings'],
queryFn: fetchAdminNotificationSettings,
})
const mutation = useMutation({
mutationFn: updateAdminNotificationSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'notifications', 'settings'] })
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
},
onError: (err: { response?: { data?: { error?: string } } }) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
},
})
if (isLoading) return <Typography>Загрузка...</Typography>
const s = data?.settings
if (!s) return <Alert severity="error">Не удалось загрузить настройки</Alert>
const save = (updates: Record<string, unknown>) => {
setError(null)
mutation.mutate(updates)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройка оповещений администратора.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }}>
Настройки сохранены
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
<Box>
<Typography variant="h6" gutterBottom>
Email
</Typography>
<FormControlLabel
control={<Switch checked={s.emailEnabled} onChange={(e) => save({ emailEnabled: e.target.checked })} />}
label="Получать уведомления на почту"
/>
</Box>
<Box>
<Typography variant="h6" gutterBottom>
Telegram
</Typography>
<FormControlLabel
control={
<Switch checked={s.telegramEnabled} onChange={(e) => save({ telegramEnabled: e.target.checked })} />
}
label="Получать уведомления в Telegram"
/>
{s.telegramEnabled && (
<Box sx={{ mt: 1, ml: 4 }}>
<TextField
label="Telegram Chat ID"
value={s.telegramChatId || ''}
onChange={(e) => save({ telegramChatId: e.target.value })}
helperText="Заполняется автоматически при /start бота"
fullWidth
size="small"
/>
</Box>
)}
</Box>
<Box>
<Typography variant="h6" gutterBottom>
Типы уведомлений
</Typography>
<Stack spacing={1}>
<FormControlLabel
control={<Switch checked={s.newOrder} onChange={(e) => save({ newOrder: e.target.checked })} />}
label="Новый заказ"
/>
<FormControlLabel
control={
<Switch checked={s.newOrderMessage} onChange={(e) => save({ newOrderMessage: e.target.checked })} />
}
label="Сообщение в заказе"
/>
<FormControlLabel
control={<Switch checked={s.newReview} onChange={(e) => save({ newReview: e.target.checked })} />}
label="Новый отзыв"
/>
<FormControlLabel
control={
<Switch checked={s.authCodeDuplicate} onChange={(e) => save({ authCodeDuplicate: e.target.checked })} />
}
label="Дублировать код входа в Telegram"
/>
</Stack>
</Box>
</Stack>
</Box>
)
}
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react' import { useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
@@ -31,10 +31,8 @@ import {
fetchAdminProducts, fetchAdminProducts,
fetchCategories, fetchCategories,
updateProduct, updateProduct,
uploadAdminProductImages,
} from '@/entities/product/api/product-api' } from '@/entities/product/api/product-api'
import type { Category, Product } from '@/entities/product/model/types' import type { Category, Product } from '@/entities/product/model/types'
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { getErrorMessage } from '@/shared/lib/get-error-message' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
@@ -203,20 +201,7 @@ export function AdminProductsPage() {
else createMut.mutate() else createMut.mutate()
} }
const productImagesInputRef = useRef<HTMLInputElement>(null) const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
const uploadImagesMut = useMutation({
mutationFn: (picked: File[]) => uploadAdminProductImages(picked),
onSuccess: (urls) => {
const current = productForm.getValues('imageUrls')
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
if (productImagesInputRef.current) {
productImagesInputRef.current.value = ''
}
},
})
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? uploadImagesMut.error
const removeImage = (url: string) => { const removeImage = (url: string) => {
const current = productForm.getValues('imageUrls') const current = productForm.getValues('imageUrls')
@@ -401,11 +386,11 @@ export function AdminProductsPage() {
/> />
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}> <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (загрузка) Фото (из галереи)
</Typography> </Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}> <FormHelperText sx={{ mt: 0, mb: 1 }}>
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
карточки; файл остаётся на сервере и в галерее. остаётся на сервере и в галерее.
</FormHelperText> </FormHelperText>
<Box <Box
sx={{ sx={{
@@ -416,21 +401,6 @@ export function AdminProductsPage() {
flexWrap: 'wrap', flexWrap: 'wrap',
}} }}
> >
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
Выбрать файлы
<input
ref={productImagesInputRef}
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(e) => {
const files = e.target.files
if (!files || files.length === 0) return
uploadImagesMut.mutate(Array.from(files))
}}
/>
</Button>
<Button <Button
variant="outlined" variant="outlined"
onClick={() => { onClick={() => {
@@ -440,8 +410,6 @@ export function AdminProductsPage() {
> >
Из галереи Из галереи
</Button> </Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
</Box> </Box>
{productForm.watch('imageUrls').length > 0 && ( {productForm.watch('imageUrls').length > 0 && (
@@ -558,6 +526,14 @@ export function AdminProductsPage() {
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && ( {galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography> <Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)} )}
{galleryForPickQuery.data &&
galleryForPickQuery.data.items.length > 0 &&
galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryForPickQuery.isLoading && (
<Typography color="text.secondary">
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<Box <Box
sx={{ sx={{
display: 'grid', display: 'grid',
@@ -566,33 +542,35 @@ export function AdminProductsPage() {
pt: 1, pt: 1,
}} }}
> >
{(galleryForPickQuery.data?.items ?? []).map((item) => { {(galleryForPickQuery.data?.items ?? [])
const alreadyInCard = productForm.watch('imageUrls').includes(item.url) .filter((item) => item.isResized)
return ( .map((item) => {
<FormControlLabel const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
key={item.id} return (
sx={{ m: 0, alignItems: 'flex-start' }} <FormControlLabel
control={ key={item.id}
<Checkbox sx={{ m: 0, alignItems: 'flex-start' }}
checked={alreadyInCard || gallerySelectedUrls.has(item.url)} control={
disabled={alreadyInCard} <Checkbox
onChange={() => toggleGalleryPickUrl(item.url)} checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
/> disabled={alreadyInCard}
} onChange={() => toggleGalleryPickUrl(item.url)}
label={
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
</Box> }
} label={
/> <Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
) <OptimizedImage
})} src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
}
/>
)
})}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
+4 -1
View File
@@ -16,11 +16,12 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck } from 'lucide-react' import { MapPin, MessageCircle, Settings, SlidersHorizontal, Truck, Bell } from 'lucide-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api' import { fetchUnreadMessageCount } from '@/entities/user/api/messages-api'
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage' import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage' import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage'
import { NotificationsPage } from '@/pages/me/ui/sections/NotificationsPage'
import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage' import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage'
import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage' import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage'
import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage' import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage'
@@ -56,6 +57,7 @@ export function MeLayoutPage() {
{ to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> }, { to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> },
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> }, { to: '/me/settings', label: 'Настройки', icon: <Settings /> },
{ to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> }, { to: '/me/addresses', label: 'Адреса доставки', icon: <MapPin /> },
{ to: '/me/notifications', label: 'Оповещения', icon: <Bell /> },
], ],
[], [],
) )
@@ -189,6 +191,7 @@ export function MeLayoutPage() {
<Route path="messages" element={<MessagesPage />} /> <Route path="messages" element={<MessagesPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="addresses" element={<AddressesPage />} /> <Route path="addresses" element={<AddressesPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="*" element={<Navigate to="/me/settings" replace />} /> <Route path="*" element={<Navigate to="/me/settings" replace />} />
</Routes> </Routes>
</Box> </Box>
@@ -0,0 +1,99 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchUserNotificationSettings,
updateUserNotificationSettings,
} from '@/entities/notification/api/notifications-api'
const eventFields = [
{ key: 'orderCreated' as const, label: 'Заказ создан' },
{ key: 'orderStatusChanged' as const, label: 'Изменение статуса заказа' },
{ key: 'orderMessageReceived' as const, label: 'Сообщение в чате заказа' },
{ key: 'paymentStatusChanged' as const, label: 'Изменение статуса оплаты' },
]
export function NotificationsPage() {
const queryClient = useQueryClient()
const [error, setError] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['me', 'notifications', 'settings'],
queryFn: fetchUserNotificationSettings,
})
const mutation = useMutation({
mutationFn: updateUserNotificationSettings,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['me', 'notifications', 'settings'] })
},
onError: (err: { response?: { data?: { error?: string } } }) => {
setError(err.response?.data?.error || 'Ошибка сохранения')
},
})
if (isLoading) return <Typography>Загрузка...</Typography>
const settings = data?.settings
if (!settings) return <Alert severity="error">Не удалось загрузить настройки</Alert>
const handleToggle = (field: string, value: boolean) => {
setError(null)
mutation.mutate({ [field]: value } as Record<string, boolean>)
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Оповещения
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Настройте, какие уведомления вы хотите получать на почту.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 480 }}>
<Box>
<FormControlLabel
control={
<Switch
checked={settings.globalEnabled}
onChange={(e) => handleToggle('globalEnabled', e.target.checked)}
/>
}
label={<Typography sx={{ fontWeight: 600 }}>Получать оповещения</Typography>}
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 4 }}>
Включите, чтобы получать уведомления о заказах на почту.
</Typography>
</Box>
<Box sx={{ pl: 4 }}>
{eventFields.map(({ key, label }) => (
<FormControlLabel
key={key}
control={
<Switch
checked={settings[key]}
disabled={!settings.globalEnabled}
onChange={(e) => handleToggle(key, e.target.checked)}
/>
}
label={label}
/>
))}
</Box>
</Stack>
</Box>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
# Admin Image Redesign — Separate Upload, Resize & Attach
## Problem
Current admin image flow bundles three concerns into one `POST /api/admin/uploads` call:
1. Upload file to disk
2. Eager resize (generate all `.cache` sizes + convert original to WebP)
3. Register in gallery (upsert GalleryImage)
This prevents the admin from uploading raw images and deciding later when to process them. Photos attached to products are always "ready", but the admin has no control over when processing happens.
## Goal
Separate the concerns into three explicit steps:
1. **Upload** — file lands in gallery, no processing
2. **Resize** — admin triggers image processing per image
3. **Attach** — only processed images can be attached to products / slider
## Prisma Schema Change
Add `isResized` field to `GalleryImage`:
```prisma
model GalleryImage {
id String @id @default(cuid())
url String @unique
isResized Boolean @default(false)
createdAt DateTime @default(now())
catalogSliderSlides CatalogSliderSlide[]
}
```
**Existing data**: after deploy, run a one-time migration script:
```ts
await prisma.galleryImage.updateMany({
where: { isResized: false },
data: { isResized: true },
})
```
Existing images are already on disk in their processed state (WebP + `.cache`), so marking them `isResized = true` is correct.
## API Routes
### New: `POST /api/admin/gallery/upload`
- Multipart file upload
- Saves to `/uploads/<uuid>.<ext>` (original extension preserved, NO WebP conversion)
- Creates `GalleryImage { url, isResized: false }`
- Returns `{ url: string }`
### New: `POST /api/admin/gallery/:id/resize`
- Reads original from `/uploads/<uuid>.<ext>`
- Calls `convertOriginalToWebp` (converts to `/uploads/<uuid>.webp`, deletes original)
- Calls `generateAllSizes` (populates `.cache/`)
- Updates `GalleryImage.url` to `/uploads/<uuid>.webp`, sets `isResized = true`
- Returns `{ url: string }`
- Errors if already resized (409) or image not found (404)
### Modified: `GET /api/admin/gallery`
- Already returns all fields via Prisma — just add `isResized` to the response
- No endpoint changes needed; client type updates only
### Modified: `POST /api/admin/uploads` → **REMOVED**
- The old combined upload endpoint is deleted
- It was only used by admin product form
### Modified: `POST /api/admin/products` / `PATCH /api/admin/products/:id`
- Validate that all passed `imageUrls` have `isResized = true` in GalleryImage
- If any image is not resized → `400 Bad Request` with explanation
### No changes to:
- `DELETE /api/admin/gallery/:id` (already works correctly)
- `PUT /api/admin/catalog-slider` (slider picks from GalleryImage — handled by gallery endpoint filter)
- `GET /uploads-resized/` (on-demand resizer unchanged)
- All public routes
## Admin UI — Gallery Page
**Upload**: Stays as `<input type="file" multiple>` → calls new `POST /api/admin/gallery/upload`.
**Gallery card**: Each image now shows:
- OptimizedImage preview (on-demand resizer still works for display)
- Status badge: "Не обработано" (if `!isResized`) or "Готово" (if `isResized`)
- If `!isResized`: a "Resize" button visible
- If `isResized`: "Resize" hidden, delete button remains
- Existing delete behaviour unchanged (checks usage before deletion)
**GalleryGrid**: Updated to accept and render `isResized` property, conditionally show resize button.
**React Query**: Add `resizeGalleryImage` mutation + `uploadGalleryImages` mutation.
## Admin UI — Product Form
- Remove direct file upload from `AdminProductsPage` (the `<input>` that calls `uploadAdminProductImages`)
- Keep only "Выбрать из галереи" dialog
- Gallery selection dialog: filter to show only `isResized = true` images
- Existing preview/sort/delete within product card unchanged
## Admin UI — Slider Section
- `GallerySliderSection` already uses gallery for selection
- When picking an image for a slide, filter to `isResized = true`
## Data Flow Summary
```
1. Upload
[Admin] → POST /api/admin/gallery/upload → /uploads/<uuid>.png
→ GalleryImage { url: "/uploads/<uuid>.png", isResized: false }
2. Resize (triggered manually in gallery)
[Admin] → POST /api/admin/gallery/:id/resize
→ convertOriginalToWebp → /uploads/<uuid>.webp
→ generateAllSizes → .cache/<uuid>_w{320,640,1024,1600}.{avif,webp}
→ GalleryImage { url: "/uploads/<uuid>.webp", isResized: true }
3. Attach to product / slider
[Admin] → product form / slider form
→ gallery picker shows only isResized = true images
→ write chosen URLs to ProductImage / CatalogSliderSlide
```
## Error Handling
- Resize of already-resized image → 409 Conflict
- Resize of missing file → 404 Not Found
- Attach unprocessed image to product → 400 Bad Request with message
- Upload invalid file type → 400 (existing validation reused)
- Upload over size limit → 413 (existing validation reused)
## Testing
### Server
- Upload endpoint: file saved, no processing, GalleryImage created with `isResized: false`
- Resize endpoint: original converted to WebP, .cache populated, `isResized` flipped to `true`
- Product creation: rejects imageUrls with `isResized: false`
- Gallery GET: includes `isResized` field
### Client
- Gallery page: badge visible for unprocessed, hidden for processed
- Resize button click → mutation → refetch → updated state
- Product form: no upload button, only "from gallery" picker
- Gallery picker in product/slider: unprocessed images hidden or disabled
## Rollout
1. Deploy server changes first (schema migration + new routes, remove old upload route)
2. One-time migration to mark existing images as resized
3. Deploy client changes
@@ -0,0 +1,221 @@
# Design: Notification System
**Date:** 2026-05-18
**Status:** Draft — awaiting review
## 1. Overview
Система оповещений для craftshop: email для пользователей, email + Telegram для админа.
Архитектура — event-driven с in-memory очередью и retry. Задел на подключение новых каналов (WhatsApp, Viber, push).
## 2. Architecture
### 2.1 Components
```
server/src/
lib/
email.js ← расширяется: sendNotificationEmail()
notifications/
event-bus.js ← EventEmitter, центральный хаб
queue.js ← in-memory очередь + воркер
channels/
email-channel.js ← отправка email через nodemailer
telegram-channel.js ← отправка через Telegram Bot API
templates/
email-templates.js ← HTML-шаблоны писем
telegram-templates.js ← форматированные сообщения TG
preferences.js ← CRUD настроек оповещений
routes/
api/
admin/
notifications.js ← GET/PUT настройки админа
user/
notifications.js ← GET/PUT настройки пользователя
```
### 2.2 Database (Prisma)
#### NotificationPreference (настройки пользователя)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| userId | String (cuid) | FK → User (unique) |
| globalEnabled | Boolean | Главный переключатель |
| orderCreated | Boolean | Заказ создан |
| orderStatusChanged | Boolean | Статус заказа изменён |
| orderMessageReceived | Boolean | Новое сообщение в чате заказа |
| paymentStatusChanged | Boolean | Статус оплаты изменён |
| createdAt | DateTime | |
| updatedAt | DateTime | |
#### AdminNotificationSettings (настройки админа)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| emailEnabled | Boolean | Email вкл/выкл |
| telegramEnabled | Boolean | Telegram вкл/выкл |
| telegramChatId | String? | ID чата админа с ботом |
| newOrder | Boolean | Новый заказ |
| newOrderMessage | Boolean | Новое сообщение в заказе |
| newReview | Boolean | Новый отзыв |
| authCodeDuplicate | Boolean | Дублировать код входа в TG |
| createdAt | DateTime | |
| updatedAt | DateTime | |
#### NotificationLog (лог отправки)
| Field | Type | Description |
|---|---|---|
| id | String (cuid) | Primary key |
| userId | String? | FK → User (null для админа) |
| eventType | String | Тип события |
| channel | String | 'email' | 'telegram' |
| status | String | 'pending' | 'sent' | 'failed' |
| error | String? | Текст ошибки |
| payload | Json | Данные события |
| attempts | Int | Количество попыток |
| createdAt | DateTime | |
| updatedAt | DateTime | |
### 2.3 Queue
- In-memory массив задач
- Воркер: `setInterval` каждые 2 секунды, максимум 5 параллельных отправок
- Retry: 3 попытки с задержкой 5с, 30с, 120с
- При рестарте сервера: все `pending` записи помечаются как `failed`
## 3. Events
| Event | Triggered in | Payload | Recipients |
|---|---|---|---|
| `order:created` | user-orders.js | orderId, userId, orderData | User (orderCreated), Admin (newOrder) |
| `order:statusChanged` | admin-orders.js | orderId, userId, oldStatus, newStatus | User (orderStatusChanged) |
| `orderMessage:sent` | user-messages.js | orderId, authorType, messageId | Admin (newOrderMessage) |
| `orderMessage:adminReply` | admin-orders.js | orderId, userId, messageId | User (orderMessageReceived) |
| `payment:statusChanged` | user-payments.js | orderId, userId, paymentStatus | User (paymentStatusChanged) |
| `auth:codeRequested` | auth.js | email, code, isAdmin | User (email), Admin (authCodeDuplicate if isAdmin) |
## 4. Data Flow
```
Роут → eventBus.emit(eventType, payload)
→ preferences.resolveRecipients(eventType, payload)
→ для каждого получателя:
→ NotificationLog.create({ status: 'pending' })
→ queue.enqueue({ recipient, channel, eventType, payload })
→ ответ API (без ожидания отправки)
Воркер (каждые 2с, до 5 параллельно):
→ queue.dequeue()
→ channel.send(job)
→ NotificationLog.update({ status: 'sent' | 'failed', attempts++ })
→ если failed и attempts < 3 → re-enqueue с delay
```
## 5. Channel Interface
Каждый канал реализует:
```js
{
name: 'email' | 'telegram',
send(job: { recipient, payload, template }): Promise<{ success: boolean, error?: string }>
}
```
### 5.1 Email Channel
- Использует существующий nodemailer transporter из `email.js`
- `sendNotificationEmail({ to, subject, html })`
- HTML-шаблоны в `email-templates.js`
### 5.2 Telegram Channel
- Telegram Bot API: `POST https://api.telegram.org/bot<TOKEN>/sendMessage`
- `node-telegram-bot-api` или прямой fetch
- Форматирование: HTML parse mode
- Шаблоны в `telegram-templates.js`
## 6. Client-Side
### 6.1 User Notification Settings Page
- Route: `/me/notifications`
- MUI переключатели:
- Главный toggle "Получать оповещения" (globalEnabled)
- При включённом: 4 toggles для каждого типа события
- При выключенном: все остальные toggles disabled
- Сохранение через `apiClient` + `@tanstack/react-query` mutation + invalidate
### 6.2 Admin Notification Settings
- Встраивается в существующую админку
- Toggle email, toggle telegram
- Если telegram включён — поле telegramChatId (заполняется автоматически при /start бота)
- Toggle для каждого типа события + toggle дублирования кода входа
## 7. Error Handling
| Scenario | Behavior |
|---|---|
| SMTP/Telegram недоступен | Retry 3 раза (5с → 30с → 120с), затем failed |
| Невалидный email / chatId | Сразу failed, без retry |
| Ошибка рендера шаблона | failed, лог в NotificationLog.error |
| Сервер рестарт | pending → failed при старте воркера |
## 8. Security
- `TELEGRAM_BOT_TOKEN` — только в `.dev_env`, не коммитится
- Telegram chatId запоминается при `/start` от админа
- Настройки пользователя — только через `fastify.authenticate`
- Настройки админа — только через `fastify.verifyAdmin`
## 9. Extensibility
### Adding a new channel (WhatsApp, Viber, push)
1. Новый файл в `channels/` с интерфейсом `{ name, send(job) }`
2. Регистрация в `queue.js`
3. Никакие другие файлы не меняются
### Adding a new event type
1. Добавить в константы типов событий
2. Добавить поле в `NotificationPreference` / `AdminNotificationSettings`
3. Эмитить через `eventBus.emit()` в нужном роуте
### Adding new recipients (broadcasts)
- `NotificationLog.userId` nullable — поддерживает системные события
- Очередь поддерживает batch-задачи
## 10. Environment Variables
| Variable | Description | Required |
|---|---|---|
| SMTP_HOST | SMTP сервер | Да (для email) |
| SMTP_PORT | SMTP порт | Да |
| SMTP_SECURE | SSL/TLS | Да |
| SMTP_USER | SMTP логин | Да |
| SMTP_PASS | SMTP пароль | Да |
| MAIL_FROM | From address | Да |
| TELEGRAM_BOT_TOKEN | Токен Telegram бота | Для Telegram канала |
## 11. Implementation Notes
- `eventBus` декорируется на fastify instance (как `slugify`, `parseMaterialsInput`)
- `bootstrap-admin.js` создаёт `AdminNotificationSettings` при создании админа
- При создании пользователя — создаётся `NotificationPreference` с defaults (всё включено)
- Существующий `sendLoginCodeEmail` остаётся, добавляется `sendNotificationEmail`
## 12. Telegram Bot — Setup Flow
1. Админ запускает бота командой `/start`
2. Бот проверяет, что sender — админ (сверка email через webhook или ручной ввод `TELEGRAM_ADMIN_CHAT_ID` в `.dev_env`)
3. Если совпадает — сохраняет `chatId` в `AdminNotificationSettings.telegramChatId`
4. Если `telegramChatId` уже установлен — бот просто подтверждает подписку
**Fallback:** если webhook не настроен, админ вручную вписывает свой chatId в настройки админки.
+4
View File
@@ -6,5 +6,9 @@
"type": "remote", "type": "remote",
"url": "https://mcp.context7.com/mcp", "url": "https://mcp.context7.com/mcp",
}, },
"chrome-devtools": {
"type": "local",
"command": ["npx", "-y", "chrome-devtools-mcp@latest"],
},
}, },
} }
+3
View File
@@ -28,3 +28,6 @@ VK_CLIENT_SECRET=
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback # Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
YANDEX_CLIENT_ID= YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET= YANDEX_CLIENT_SECRET=
# Telegram Bot (оповещения админа)
TELEGRAM_BOT_TOKEN=
@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_GalleryImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"isResized" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_GalleryImage" ("createdAt", "id", "url") SELECT "createdAt", "id", "url" FROM "GalleryImage";
DROP TABLE "GalleryImage";
ALTER TABLE "new_GalleryImage" RENAME TO "GalleryImage";
CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,54 @@
-- CreateTable
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"globalEnabled" BOOLEAN NOT NULL DEFAULT true,
"orderCreated" BOOLEAN NOT NULL DEFAULT true,
"orderStatusChanged" BOOLEAN NOT NULL DEFAULT true,
"orderMessageReceived" BOOLEAN NOT NULL DEFAULT true,
"paymentStatusChanged" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AdminNotificationSettings" (
"id" TEXT NOT NULL PRIMARY KEY,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramChatId" TEXT,
"newOrder" BOOLEAN NOT NULL DEFAULT true,
"newOrderMessage" BOOLEAN NOT NULL DEFAULT true,
"newReview" BOOLEAN NOT NULL DEFAULT true,
"authCodeDuplicate" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "NotificationLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT,
"eventType" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"status" TEXT NOT NULL,
"error" TEXT,
"payload" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
-- CreateIndex
CREATE INDEX "NotificationPreference_userId_idx" ON "NotificationPreference"("userId");
-- CreateIndex
CREATE INDEX "NotificationLog_status_createdAt_idx" ON "NotificationLog"("status", "createdAt");
-- CreateIndex
CREATE INDEX "NotificationLog_userId_createdAt_idx" ON "NotificationLog"("userId", "createdAt");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "NotificationPreference_userId_idx";
+51
View File
@@ -57,6 +57,7 @@ model ProductImage {
model GalleryImage { model GalleryImage {
id String @id @default(cuid()) id String @id @default(cuid())
url String @unique url String @unique
isResized Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
catalogSliderSlides CatalogSliderSlide[] catalogSliderSlides CatalogSliderSlide[]
@@ -89,6 +90,8 @@ model User {
reviews Review[] reviews Review[]
orderMessageReadStates UserOrderMessageReadState[] orderMessageReadStates UserOrderMessageReadState[]
oauthAccounts OAuthAccount[] oauthAccounts OAuthAccount[]
notificationPreference NotificationPreference?
notificationLogs NotificationLog[]
} }
/// Прочитанность чата по заказу (для сообщений от админа после lastReadAt) /// Прочитанность чата по заказу (для сообщений от админа после lastReadAt)
@@ -268,3 +271,51 @@ model InfoPageBlock {
@@index([published, sort]) @@index([published, sort])
} }
/// Настройки оповещений пользователя
model NotificationPreference {
id String @id @default(cuid())
userId String @unique
globalEnabled Boolean @default(true)
orderCreated Boolean @default(true)
orderStatusChanged Boolean @default(true)
orderMessageReceived Boolean @default(true)
paymentStatusChanged Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
/// Настройки оповещений админа
model AdminNotificationSettings {
id String @id @default(cuid())
emailEnabled Boolean @default(true)
telegramEnabled Boolean @default(false)
telegramChatId String?
newOrder Boolean @default(true)
newOrderMessage Boolean @default(true)
newReview Boolean @default(true)
authCodeDuplicate Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// Лог отправки оповещений
model NotificationLog {
id String @id @default(cuid())
userId String?
eventType String
channel String
status String
error String?
payload String
attempts Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([status, createdAt])
@@index([userId, createdAt])
}
+13
View File
@@ -0,0 +1,13 @@
import { prisma } from '../src/lib/prisma.js'
async function main() {
const { count } = await prisma.galleryImage.updateMany({
where: { isResized: false },
data: { isResized: true },
})
console.log(`Marked ${count} existing images as resized`)
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
+74 -2
View File
@@ -8,6 +8,15 @@ import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js' import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js' import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { createEventBus } from './lib/notifications/event-bus.js'
import { createNotificationQueue } from './lib/notifications/queue.js'
import { prisma } from './lib/prisma.js'
import {
resolveUserNotificationTargets,
resolveAdminNotificationTargets,
resolveAuthCodeTargets,
} from './lib/notifications/preferences.js'
import { NOTIFICATION_EVENTS, NOTIFICATION_CHANNELS } from './shared/constants/notification-events.js'
import { registerAuth } from './plugins/auth.js' import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
@@ -16,6 +25,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
import { registerUploadsResized } from './routes/uploads-resized.js' import { registerUploadsResized } from './routes/uploads-resized.js'
@@ -42,7 +52,6 @@ await fastify.register(jwt, {
await fastify.register(multipart, { await fastify.register(multipart, {
limits: { limits: {
files: 10, files: 10,
/** Совпадает с лимитом одного файла для `POST /api/admin/uploads` (товары, галерея). */
fileSize: getProductImageMaxFileBytes(), fileSize: getProductImageMaxFileBytes(),
}, },
}) })
@@ -70,6 +79,11 @@ fastify.decorate('authenticate', async function authenticate(request, reply) {
} }
}) })
const eventBus = createEventBus()
const notificationQueue = createNotificationQueue()
fastify.decorate('eventBus', eventBus)
fastify.decorate('notificationQueue', notificationQueue)
registerAuth(fastify) registerAuth(fastify)
await registerAuthRoutes(fastify) await registerAuthRoutes(fastify)
await registerUserAddressRoutes(fastify) await registerUserAddressRoutes(fastify)
@@ -77,12 +91,70 @@ await registerUserCartRoutes(fastify)
await registerUserMessageRoutes(fastify) await registerUserMessageRoutes(fastify)
await registerUserOrderRoutes(fastify) await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify) await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
await ensureAdminUser() await ensureAdminUser()
await getOrCreateUnspecifiedCategory() await getOrCreateUnspecifiedCategory()
fastify.get('/health', async () => ({ ok: true })) await notificationQueue.flushPendingOnStartup()
notificationQueue.start()
const {
ORDER_CREATED,
ORDER_STATUS_CHANGED,
ORDER_MESSAGE_SENT,
ORDER_MESSAGE_ADMIN_REPLY,
PAYMENT_STATUS_CHANGED,
AUTH_CODE_REQUESTED,
} = NOTIFICATION_EVENTS
async function dispatchNotification(eventType, payload) {
const userTargets = await resolveUserNotificationTargets(eventType, payload)
for (const target of userTargets) {
const log = await prisma.notificationLog.create({
data: {
userId: payload.userId,
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
}
const adminEventType = eventType === 'order:created:admin' ? ORDER_CREATED : eventType
const adminTargets = await resolveAdminNotificationTargets(adminEventType, payload)
for (const target of adminTargets) {
const log = await prisma.notificationLog.create({
data: {
eventType,
channel: target.channel,
status: 'pending',
payload: JSON.stringify(payload),
},
})
notificationQueue.enqueue({ ...target, eventType, payload, logId: log.id })
}
}
eventBus.on(ORDER_CREATED, dispatchNotification)
eventBus.on(ORDER_STATUS_CHANGED, dispatchNotification)
eventBus.on(ORDER_MESSAGE_SENT, dispatchNotification)
eventBus.on(ORDER_MESSAGE_ADMIN_REPLY, dispatchNotification)
eventBus.on(PAYMENT_STATUS_CHANGED, dispatchNotification)
eventBus.on(AUTH_CODE_REQUESTED, dispatchNotification)
eventBus.on('order:created:admin', dispatchNotification)
eventBus.on('review:created', dispatchNotification)
async function shutdown() {
notificationQueue.stop()
await fastify.close()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
try { try {
await fastify.listen({ port, host: '0.0.0.0' }) await fastify.listen({ port, host: '0.0.0.0' })
+2 -76
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, afterEach } from 'vitest'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { persistMultipartImages } from '../upload-images.js' import { persistMultipartImages } from '../upload-images.js'
@@ -6,7 +6,7 @@ import { persistMultipartImages } from '../upload-images.js'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads') const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
const TEST_PREFIX = 'upload-test-' const TEST_PREFIX = 'upload-test-'
describe('persistMultipartImages with eager mode', () => { describe('persistMultipartImages with eager=false', () => {
afterEach(async () => { afterEach(async () => {
const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => []) const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => [])
for (const file of files) { for (const file of files) {
@@ -16,50 +16,6 @@ describe('persistMultipartImages with eager mode', () => {
} }
}) })
it('returns WebP URLs when eager=true', async () => {
const sharp = (await import('sharp')).default
const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original.png`)
const filesBefore = await fs.promises.readdir(UPLOADS_DIR)
await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testImagePath)
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
const buffer = await fs.promises.readFile(testImagePath)
yield {
file: true,
filename: 'test.png',
toBuffer: async () => buffer,
}
},
}
const urls = await persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
})
expect(urls).toHaveLength(1)
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.webp$/)
// Verify the intermediate PNG file written by persistMultipartImages was deleted
const filesAfter = await fs.promises.readdir(UPLOADS_DIR)
const newPngFiles = filesAfter.filter(
(f) =>
!filesBefore.includes(f) &&
f.endsWith('.png') &&
f !== path.basename(testImagePath) &&
!f.startsWith('test-eager-uuid-'),
)
expect(newPngFiles).toHaveLength(0)
})
it('returns original format URLs when eager=false', async () => { it('returns original format URLs when eager=false', async () => {
const sharp = (await import('sharp')).default const sharp = (await import('sharp')).default
const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`) const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`)
@@ -90,34 +46,4 @@ describe('persistMultipartImages with eager mode', () => {
expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/) expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/)
}) })
it('cleans up original file on eager processing error', async () => {
const invalidBuffer = Buffer.from('not an image')
const filesBefore = await fs.promises.readdir(UPLOADS_DIR)
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
yield {
file: true,
filename: 'test.png',
toBuffer: async () => invalidBuffer,
}
},
}
await expect(
persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
}),
).rejects.toThrow()
// The intermediate file written by persistMultipartImages should be cleaned up
const filesAfter = await fs.promises.readdir(UPLOADS_DIR)
const newFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f !== '.cache')
expect(newFiles).toHaveLength(0)
})
}) })
+1
View File
@@ -27,6 +27,7 @@ export async function issueEmailCode({ email, purpose, userId = null }) {
}, },
}) })
await sendLoginCodeEmail({ to: email, code }) await sendLoginCodeEmail({ to: email, code })
return code
} }
function parseEnvBool(raw) { function parseEnvBool(raw) {
+16 -1
View File
@@ -8,9 +8,24 @@ export async function ensureAdminUser() {
throw new Error('ADMIN_EMAIL должен быть валидным email') throw new Error('ADMIN_EMAIL должен быть валидным email')
} }
await prisma.user.upsert({ const admin = await prisma.user.upsert({
where: { email: adminEmail }, where: { email: adminEmail },
update: {}, update: {},
create: { email: adminEmail }, create: { email: adminEmail },
}) })
// Ensure admin notification settings exist
const existing = await prisma.adminNotificationSettings.findFirst()
if (!existing) {
await prisma.adminNotificationSettings.create({
data: {
emailEnabled: true,
telegramEnabled: false,
newOrder: true,
newOrderMessage: true,
newReview: true,
authCodeDuplicate: false,
},
})
}
} }
+31 -8
View File
@@ -4,14 +4,8 @@ function hasSmtpEnv() {
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS) return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
} }
export async function sendLoginCodeEmail({ to, code }) { function createTransporter() {
if (!hasSmtpEnv()) { return nodemailer.createTransport({
// dev fallback
console.log(`[DEV] login code for ${to}: ${code}`)
return
}
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT), port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true', secure: process.env.SMTP_SECURE === 'true',
@@ -20,7 +14,15 @@ export async function sendLoginCodeEmail({ to, code }) {
pass: process.env.SMTP_PASS, pass: process.env.SMTP_PASS,
}, },
}) })
}
export async function sendLoginCodeEmail({ to, code }) {
if (!hasSmtpEnv()) {
console.log(`[DEV] login code for ${to}: ${code}`)
return
}
const transporter = createTransporter()
const from = process.env.MAIL_FROM || process.env.SMTP_USER const from = process.env.MAIL_FROM || process.env.SMTP_USER
await transporter.sendMail({ await transporter.sendMail({
@@ -31,3 +33,24 @@ export async function sendLoginCodeEmail({ to, code }) {
}) })
} }
export async function sendNotificationEmail({ to, subject, html }) {
if (!hasSmtpEnv()) {
console.log(`[DEV] notification email to ${to}: ${subject}`)
return { success: true }
}
try {
const transporter = createTransporter()
const from = process.env.MAIL_FROM || process.env.SMTP_USER
await transporter.sendMail({
from,
to,
subject,
html,
})
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
-12
View File
@@ -1,12 +0,0 @@
import { prisma } from './prisma.js'
/** Регистрация загруженных путей в медиатеке (идемпотентно). */
export async function upsertGalleryImagesByUrls(urls) {
for (const url of urls) {
await prisma.galleryImage.upsert({
where: { url },
create: { url },
update: {},
})
}
}
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { prisma } from '../../prisma.js'
import {
resolveUserNotificationTargets,
resolveAdminNotificationTargets,
resolveAuthCodeTargets,
ensureUserNotificationPreference,
} from '../preferences.js'
const ORDER_CREATED = 'order:created'
const AUTH_CODE_REQUESTED = 'auth:codeRequested'
describe('preferences', () => {
beforeEach(async () => {
await prisma.notificationPreference.deleteMany()
await prisma.adminNotificationSettings.deleteMany()
await prisma.user.deleteMany()
})
afterEach(async () => {
await prisma.notificationPreference.deleteMany()
await prisma.adminNotificationSettings.deleteMany()
await prisma.user.deleteMany()
})
it('returns empty targets when user has no preferences', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('returns email target when user has preferences enabled', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true, orderCreated: true },
})
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
expect(targets).toHaveLength(1)
expect(targets[0]).toEqual({ channel: 'email', recipient: 'test@test.com' })
})
it('returns no targets when globalEnabled is false', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: false, orderCreated: true },
})
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('returns no targets when specific event is disabled', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true, orderCreated: false },
})
const targets = await resolveUserNotificationTargets(ORDER_CREATED, { userId: user.id })
expect(targets).toEqual([])
})
it('ensures user preference is created if not exists', async () => {
const user = await prisma.user.create({ data: { email: 'test@test.com' } })
const prefs = await ensureUserNotificationPreference(user.id)
expect(prefs.globalEnabled).toBe(true)
expect(prefs.userId).toBe(user.id)
})
it('returns admin targets when settings enabled', async () => {
const admin = await prisma.user.create({ data: { email: 'admin@test.com' } })
const origAdminEmail = process.env.ADMIN_EMAIL
process.env.ADMIN_EMAIL = 'admin@test.com'
await prisma.adminNotificationSettings.create({
data: { emailEnabled: true, newOrder: true },
})
const targets = await resolveAdminNotificationTargets(ORDER_CREATED, {})
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'admin@test.com')).toBe(true)
process.env.ADMIN_EMAIL = origAdminEmail
})
it('resolveAuthCodeTargets returns email for user and telegram for admin', async () => {
await prisma.adminNotificationSettings.create({
data: { telegramEnabled: true, telegramChatId: '12345', authCodeDuplicate: true },
})
const targets = await resolveAuthCodeTargets(AUTH_CODE_REQUESTED, {
email: 'user@test.com',
code: '123456',
isAdmin: true,
})
expect(targets.some((t) => t.channel === 'email' && t.recipient === 'user@test.com')).toBe(true)
expect(targets.some((t) => t.channel === 'telegram' && t.recipient === '12345')).toBe(true)
})
})
@@ -0,0 +1,37 @@
// server/src/lib/notifications/channels/email-channel.js
import { sendNotificationEmail } from '../../email.js'
import {
renderOrderCreatedEmail,
renderOrderStatusChangedEmail,
renderOrderMessageEmail,
renderPaymentStatusChangedEmail,
renderAdminOrderCreatedEmail,
renderAdminNewReviewEmail,
renderAuthCodeEmail,
} from '../templates/email-templates.js'
const templateRenderers = {
'order:created': renderOrderCreatedEmail,
'order:statusChanged': renderOrderStatusChangedEmail,
'orderMessage:adminReply': renderOrderMessageEmail,
'payment:statusChanged': renderPaymentStatusChangedEmail,
'order:created:admin': renderAdminOrderCreatedEmail,
'orderMessage:sent': renderOrderMessageEmail,
'review:created': renderAdminNewReviewEmail,
'auth:codeRequested': renderAuthCodeEmail,
}
export const emailChannel = {
name: 'email',
async send({ recipient, eventType, payload }) {
const renderer = templateRenderers[eventType]
if (!renderer) {
return { success: false, error: `No email template for event: ${eventType}` }
}
const { subject, html } = renderer(payload)
const result = await sendNotificationEmail({ to: recipient, subject, html })
return result
},
}
@@ -0,0 +1,67 @@
import {
renderOrderCreatedTg,
renderOrderStatusChangedTg,
renderOrderMessageTg,
renderPaymentStatusChangedTg,
renderAdminOrderCreatedTg,
renderAdminNewReviewTg,
renderAuthCodeTg,
} from '../templates/telegram-templates.js'
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
const templateRenderers = {
'order:created': renderOrderCreatedTg,
'order:statusChanged': renderOrderStatusChangedTg,
'orderMessage:adminReply': renderOrderMessageTg,
'payment:statusChanged': renderPaymentStatusChangedTg,
'order:created:admin': renderAdminOrderCreatedTg,
'orderMessage:sent': renderOrderMessageTg,
'review:created': renderAdminNewReviewTg,
'auth:codeRequested': renderAuthCodeTg,
}
async function postToTelegram(chatId, text) {
if (!TELEGRAM_BOT_TOKEN) {
console.log(`[DEV] telegram to ${chatId}: ${text.slice(0, 80)}`)
return { success: true }
}
try {
const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: 'HTML',
}),
})
const data = await res.json()
if (!data.ok) {
return { success: false, error: data.description || 'Telegram API error' }
}
return { success: true }
} catch (err) {
return { success: false, error: err.message }
}
}
export const telegramChannel = {
name: 'telegram',
async send({ recipient: chatId, eventType, payload }) {
if (!chatId) {
return { success: false, error: 'No telegram chatId' }
}
const renderer = templateRenderers[eventType]
if (!renderer) {
return { success: false, error: `No telegram template for event: ${eventType}` }
}
const text = renderer(payload)
return postToTelegram(chatId, text)
},
}
@@ -0,0 +1,7 @@
import { EventEmitter } from 'node:events'
export function createEventBus() {
const bus = new EventEmitter()
bus.setMaxListeners(50)
return bus
}
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from '../prisma.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,
AUTH_CODE_REQUESTED,
} = NOTIFICATION_EVENTS
const userEventFieldMap = {
[ORDER_CREATED]: 'orderCreated',
[ORDER_STATUS_CHANGED]: 'orderStatusChanged',
[ORDER_MESSAGE_ADMIN_REPLY]: 'orderMessageReceived',
[PAYMENT_STATUS_CHANGED]: 'paymentStatusChanged',
}
const adminEventFieldMap = {
[ORDER_CREATED]: 'newOrder',
[ORDER_MESSAGE_SENT]: 'newOrderMessage',
'review:created': 'newReview',
}
export async function resolveUserNotificationTargets(eventType, payload) {
const targets = []
if (payload.userId) {
const prefs = await prisma.notificationPreference.findUnique({
where: { userId: payload.userId },
})
if (prefs && prefs.globalEnabled) {
const field = userEventFieldMap[eventType]
if (field && prefs[field]) {
const user = await prisma.user.findUnique({ where: { id: payload.userId }, select: { email: true } })
if (user) {
targets.push({ channel: 'email', recipient: user.email })
}
}
}
}
return targets
}
export async function resolveAdminNotificationTargets(eventType, payload) {
const targets = []
const settings = await prisma.adminNotificationSettings.findFirst()
if (!settings) return targets
const field = adminEventFieldMap[eventType]
if (field === 'newReview') {
if (!settings.newReview) return targets
} else if (field && !settings[field]) {
return targets
}
if (settings.emailEnabled) {
const admin = await prisma.user.findFirst({
where: { email: process.env.ADMIN_EMAIL },
select: { email: true },
})
if (admin) {
targets.push({ channel: 'email', recipient: admin.email })
}
}
if (settings.telegramEnabled && settings.telegramChatId) {
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
}
return targets
}
export async function resolveAuthCodeTargets(eventType, payload) {
const targets = []
if (payload.email) {
targets.push({ channel: 'email', recipient: payload.email })
}
if (payload.isAdmin) {
const settings = await prisma.adminNotificationSettings.findFirst()
if (settings && settings.telegramEnabled && settings.telegramChatId && settings.authCodeDuplicate) {
targets.push({ channel: 'telegram', recipient: settings.telegramChatId })
}
}
return targets
}
export async function ensureUserNotificationPreference(userId) {
const existing = await prisma.notificationPreference.findUnique({ where: { userId } })
if (existing) return existing
return prisma.notificationPreference.create({
data: { userId, globalEnabled: true },
})
}
+130
View File
@@ -0,0 +1,130 @@
import { prisma } from '../prisma.js'
import { NOTIFICATION_STATUSES, MAX_RETRY_ATTEMPTS, RETRY_DELAYS_MS } from '../../../../shared/constants/notification-events.js'
import { emailChannel } from './channels/email-channel.js'
import { telegramChannel } from './channels/telegram-channel.js'
const { PENDING, SENT, FAILED } = NOTIFICATION_STATUSES
const channels = {
email: emailChannel,
telegram: telegramChannel,
}
class NotificationQueue {
constructor() {
this.tasks = []
this.processing = 0
this.maxConcurrent = 5
this.intervalMs = 2000
this.running = false
}
enqueue(task) {
this.tasks.push({ ...task, enqueuedAt: Date.now() })
}
start() {
if (this.running) return
this.running = true
this._tick()
}
stop() {
this.running = false
}
_tick() {
if (!this.running) return
this._processAvailable()
setTimeout(() => this._tick(), this.intervalMs)
}
_processAvailable() {
while (this.tasks.length > 0 && this.processing < this.maxConcurrent) {
const task = this.tasks.shift()
this.processing++
this._execute(task).finally(() => {
this.processing--
})
}
}
async _execute(task) {
const channel = channels[task.channel]
if (!channel) {
await this._markFailed(task.logId, `Unknown channel: ${task.channel}`)
return
}
try {
const result = await channel.send({
recipient: task.recipient,
eventType: task.eventType,
payload: task.payload,
})
if (result.success) {
await this._markSent(task.logId)
} else {
await this._handleFailure(task.logId, task, result.error)
}
} catch (err) {
await this._handleFailure(task.logId, task, err.message)
}
}
async _markSent(logId) {
await prisma.notificationLog.update({
where: { id: logId },
data: { status: SENT },
})
}
async _markFailed(logId, error) {
await prisma.notificationLog.update({
where: { id: logId },
data: { status: FAILED, error },
})
}
async _handleFailure(logId, task, error) {
const log = await prisma.notificationLog.findUnique({ where: { id: logId } })
const newAttempts = (log?.attempts || 0) + 1
if (newAttempts >= MAX_RETRY_ATTEMPTS) {
await this._markFailed(logId, error)
return
}
await prisma.notificationLog.update({
where: { id: logId },
data: { attempts: newAttempts },
})
const delay = RETRY_DELAYS_MS[newAttempts - 1] || RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]
setTimeout(() => {
this.enqueue({ ...task, logId })
}, delay)
}
async flushPendingOnStartup() {
const pending = await prisma.notificationLog.findMany({
where: { status: PENDING },
})
for (const log of pending) {
await prisma.notificationLog.update({
where: { id: log.id },
data: { status: FAILED, error: 'Server restarted, pending notification lost' },
})
}
if (pending.length > 0) {
console.log(`[notifications] Marked ${pending.length} pending notifications as failed on startup`)
}
}
}
export function createNotificationQueue() {
return new NotificationQueue()
}
@@ -0,0 +1,96 @@
function baseLayout(title, body) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a1a;">
<div style="background:#f8f9fa;padding:16px;border-radius:8px;margin-bottom:16px;">
<h2 style="margin:0;">${title}</h2>
</div>
${body}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid #e0e0e0;color:#666;font-size:14px;">
<p>Craftshop — магазин handmade изделий</p>
</div>
</body>
</html>`
}
export function renderOrderCreatedEmail({ orderId, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const body = `
<p>Ваш заказ <b>#${orderId.slice(0, 8)}</b> успешно создан.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
<p>Мы сообщим вам об изменениях статуса.</p>
`
return { subject: 'Заказ создан', html: baseLayout('Заказ создан', body) }
}
export function renderOrderStatusChangedEmail({ orderId, oldStatus, newStatus }) {
const statusLabels = {
DRAFT: 'Черновик',
PENDING_PAYMENT: 'Ожидает оплаты',
IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче',
SHIPPED: 'Отправлен',
DONE: 'Выполнен',
CANCELLED: 'Отменён',
}
const oldLabel = statusLabels[oldStatus] || oldStatus
const newLabel = statusLabels[newStatus] || newStatus
const body = `
<p>Статус заказа <b>#${orderId.slice(0, 8)}</b> изменён.</p>
<p><b>${oldLabel}</b> → <b>${newLabel}</b></p>
`
return { subject: `Статус заказа изменён — ${newLabel}`, html: baseLayout('Статус заказа изменён', body) }
}
export function renderOrderMessageEmail({ orderId, preview }) {
const truncated = preview.length > 200 ? preview.slice(0, 197) + '...' : preview
const body = `
<p>Новое сообщение к заказу <b>#${orderId.slice(0, 8)}</b>:</p>
<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">
${truncated}
</div>
<p>Ответьте в личном кабинете.</p>
`
return { subject: 'Новое сообщение к заказу', html: baseLayout('Новое сообщение', body) }
}
export function renderPaymentStatusChangedEmail({ orderId, paymentStatus }) {
const statusLabels = {
pending: 'Ожидает',
confirmed: 'Подтверждён',
rejected: 'Отклонён',
}
const label = statusLabels[paymentStatus] || paymentStatus
const body = `
<p>Статус оплаты заказа <b>#${orderId.slice(0, 8)}</b>: <b>${label}</b>.</p>
`
return { subject: `Оплата заказа — ${label}`, html: baseLayout('Оплата заказа', body) }
}
export function renderAdminOrderCreatedEmail({ orderId, userEmail, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
const body = `
<p>Новый заказ <b>#${orderId.slice(0, 8)}</b> от <b>${userEmail}</b>.</p>
<p>Товаров: ${itemsCount} | Сумма: <b>${total} ₽</b></p>
`
return { subject: 'Новый заказ', html: baseLayout('Новый заказ', body) }
}
export function renderAdminNewReviewEmail({ rating, text, productTitle, userName }) {
const stars = '★'.repeat(rating) + '☆'.repeat(5 - rating)
const body = `
<p>Новый отзыв ${stars} на товар <b>${productTitle}</b> от <b>${userName}</b>.</p>
${text ? `<div style="background:#f0f0f0;padding:12px;border-radius:6px;margin:12px 0;">${text}</div>` : ''}
<p>Проверьте отзыв в админ-панели.</p>
`
return { subject: 'Новый отзыв', html: baseLayout('Новый отзыв', body) }
}
export function renderAuthCodeEmail({ code }) {
const body = `
<p>Ваш код входа: <b style="font-size:24px;letter-spacing:4px;">${code}</b></p>
<p>Если это были не вы — просто проигнорируйте письмо.</p>
`
return { subject: 'Код входа', html: baseLayout('Код входа', body) }
}
@@ -0,0 +1,36 @@
export function renderOrderCreatedTg({ orderId, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `📦 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nТоваров: ${itemsCount} | Сумма: ${total}`
}
export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) {
const labels = {
DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', IN_PROGRESS: 'В работе',
READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', DONE: 'Выполнен', CANCELLED: 'Отменён',
}
return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → <b>${labels[newStatus] || newStatus}</b>`
}
export function renderOrderMessageTg({ orderId, preview }) {
const truncated = preview.length > 300 ? preview.slice(0, 297) + '...' : preview
return `💬 Сообщение к заказу #${orderId.slice(0, 8)}\n\n${truncated}`
}
export function renderPaymentStatusChangedTg({ orderId, paymentStatus }) {
const labels = { pending: 'Ожидает', confirmed: 'Подтверждён', rejected: 'Отклонён' }
return `💳 Оплата заказа #${orderId.slice(0, 8)}: <b>${labels[paymentStatus] || paymentStatus}</b>`
}
export function renderAdminOrderCreatedTg({ orderId, userEmail, totalCents, itemsCount }) {
const total = (totalCents / 100).toLocaleString('ru-RU')
return `🛒 <b>Новый заказ</b> #${orderId.slice(0, 8)}\nОт: ${userEmail}\nТоваров: ${itemsCount} | Сумма: ${total}`
}
export function renderAdminNewReviewTg({ rating, text, productTitle, userName }) {
const stars = '⭐'.repeat(rating)
return `📝 <b>Новый отзыв</b> ${stars}\nТовар: ${productTitle}\nАвтор: ${userName}${text ? '\n\n' + text : ''}`
}
export function renderAuthCodeTg({ code }) {
return `🔐 Код входа: <b>${code}</b>`
}
+2
View File
@@ -10,6 +10,7 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.js' import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
import { registerInfoPageRoutes } from './api/info-page.js' import { registerInfoPageRoutes } from './api/info-page.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js' import { registerPublicReviewRoutes } from './api/public-reviews.js'
@@ -30,5 +31,6 @@ export async function registerApiRoutes(fastify) {
await registerAdminOrderRoutes(fastify) await registerAdminOrderRoutes(fastify)
await registerAdminReviewRoutes(fastify) await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify) await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify)
} }
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
import { generateAllSizes, convertOriginalToWebp } from '../../../lib/image-resize.js'
describe('Admin gallery resize integration', () => {
const testUuid = 'gallery-test-resize-uuid'
const testOriginalPath = path.join(UPLOADS_DIR, `${testUuid}.png`)
beforeAll(async () => {
const sharp = (await import('sharp')).default
await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testOriginalPath)
})
afterAll(async () => {
await fs.promises.unlink(testOriginalPath).catch(() => {})
const webpPath = path.join(UPLOADS_DIR, `${testUuid}.webp`)
await fs.promises.unlink(webpPath).catch(() => {})
const cacheDir = path.join(UPLOADS_DIR, '.cache')
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
await fs.promises.unlink(path.join(cacheDir, `${testUuid}_w${width}.${format}`)).catch(() => {})
}
}
})
it('generateAllSizes + convertOriginalToWebp works on raw upload', async () => {
await generateAllSizes(testUuid, '', testOriginalPath)
const newUrl = await convertOriginalToWebp(testUuid, '')
expect(newUrl).toBe(`/uploads/${testUuid}.webp`)
// Verify original PNG is deleted
const pngExists = await fs.promises.access(testOriginalPath).then(() => true).catch(() => false)
expect(pngExists).toBe(false)
// Verify cached files exist
const cacheDir = path.join(UPLOADS_DIR, '.cache')
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(cacheDir, `${testUuid}_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
expect(exists).toBe(true)
}
}
// Verify webp original exists
const webpExists = await fs.promises.access(path.join(UPLOADS_DIR, `${testUuid}.webp`)).then(() => true).catch(() => false)
expect(webpExists).toBe(true)
})
})
+75
View File
@@ -1,6 +1,12 @@
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
import {
formatFileTooLargeMessage,
getProductImageMaxFileBytes,
isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js'
export async function registerAdminGalleryRoutes(fastify) { export async function registerAdminGalleryRoutes(fastify) {
fastify.get( fastify.get(
@@ -14,6 +20,75 @@ export async function registerAdminGalleryRoutes(fastify) {
}, },
) )
fastify.post(
'/api/admin/gallery/upload',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
subdir: '',
eager: false,
})
for (const url of urls) {
await prisma.galleryImage.create({
data: { url, isResized: false },
})
}
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
fastify.post(
'/api/admin/gallery/:id/resize',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Изображение не найдено' })
}
if (row.isResized) {
return reply.code(409).send({ error: 'Изображение уже обработано' })
}
const urlParts = row.url.replace(/^\//, '').split('/')
const fileName = urlParts[urlParts.length - 1]
const uuid = path.parse(fileName).name
try {
const { generateAllSizes, convertOriginalToWebp } = await import('../../lib/image-resize.js')
const fullPath = path.join(process.cwd(), urlParts.slice(0, -1).join('/'), fileName)
await generateAllSizes(uuid, '', fullPath)
const newUrl = await convertOriginalToWebp(uuid, '')
await prisma.galleryImage.update({
where: { id },
data: { url: newUrl, isResized: true },
})
return { url: newUrl }
} catch (error) {
request.log.error(error, 'Resize failed')
return reply.code(500).send({ error: 'Ошибка обработки изображения' })
}
},
)
fastify.delete( fastify.delete(
'/api/admin/gallery/:id', '/api/admin/gallery/:id',
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
+17
View File
@@ -1,5 +1,6 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js' import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
export async function registerAdminOrderRoutes(fastify) { export async function registerAdminOrderRoutes(fastify) {
fastify.get( fastify.get(
@@ -108,6 +109,14 @@ export async function registerAdminOrderRoutes(fastify) {
} }
const updated = await prisma.order.update({ where: { id }, data: { status: next } }) const updated = await prisma.order.update({ where: { id }, data: { status: next } })
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_STATUS_CHANGED, {
orderId: updated.id,
userId: existing.userId,
oldStatus: existing.status,
newStatus: next,
})
return { item: updated } return { item: updated }
}, },
) )
@@ -156,6 +165,14 @@ export async function registerAdminOrderRoutes(fastify) {
if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } }) const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_ADMIN_REPLY, {
orderId: id,
userId: order.userId,
messageId: msg.id,
preview: text,
})
return reply.code(201).send({ item: msg }) return reply.code(201).send({ item: msg })
}, },
) )
+38 -34
View File
@@ -1,11 +1,4 @@
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import {
formatFileTooLargeMessage,
getProductImageMaxFileBytes,
isMultipartFileTooLargeError,
} from '../../lib/upload-limits.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
const CREATE_PRODUCT_SCHEMA = { const CREATE_PRODUCT_SCHEMA = {
body: { body: {
@@ -59,33 +52,6 @@ export async function registerAdminProductRoutes(fastify) {
}, },
) )
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
eager: true,
})
await upsertGalleryImagesByUrls(urls)
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
fastify.post( fastify.post(
'/api/admin/products', '/api/admin/products',
{ preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA }, { preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
@@ -122,6 +88,25 @@ export async function registerAdminProductRoutes(fastify) {
return return
} }
if (Array.isArray(body.imageUrls) && body.imageUrls.length > 0) {
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
if (urls.length > 0) {
const galleryImages = await prisma.galleryImage.findMany({
where: { url: { in: urls } },
select: { url: true, isResized: true },
})
const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
const notFound = urls.filter((u) => !galleryMap.has(u))
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
if (notFound.length > 0) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
}
}
}
const n = Number(body.quantity) const n = Number(body.quantity)
if (!Number.isInteger(n) || n < 0 || n > 10) { if (!Number.isInteger(n) || n < 0 || n > 10) {
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' }) reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
@@ -228,6 +213,25 @@ export async function registerAdminProductRoutes(fastify) {
data.categoryId = cid data.categoryId = cid
} }
if (body.imageUrls !== undefined && Array.isArray(body.imageUrls)) {
const urls = body.imageUrls.map((u) => String(u || '').trim()).filter(Boolean)
if (urls.length > 0) {
const galleryImages = await prisma.galleryImage.findMany({
where: { url: { in: urls } },
select: { url: true, isResized: true },
})
const galleryMap = new Map(galleryImages.map((g) => [g.url, g]))
const notFound = urls.filter((u) => !galleryMap.has(u))
const notResized = urls.filter((u) => galleryMap.get(u) && !galleryMap.get(u).isResized)
if (notFound.length > 0) {
return reply.code(400).send({ error: 'Некоторые изображения не найдены в галерее' })
}
if (notResized.length > 0) {
return reply.code(400).send({ error: 'Изображения должны быть обработаны (resize) перед прикреплением к товару' })
}
}
}
const imagesUpdate = const imagesUpdate =
body.imageUrls !== undefined body.imageUrls !== undefined
? { ? {
+13 -1
View File
@@ -1,4 +1,5 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
export async function registerAdminReviewRoutes(fastify) { export async function registerAdminReviewRoutes(fastify) {
fastify.get( fastify.get(
@@ -43,7 +44,10 @@ export async function registerAdminReviewRoutes(fastify) {
return reply.code(400).send({ error: 'action должен быть approve или reject' }) return reply.code(400).send({ error: 'action должен быть approve или reject' })
} }
const existing = await prisma.review.findUnique({ where: { id } }) const existing = await prisma.review.findUnique({
where: { id },
include: { product: { select: { title: true } }, user: { select: { name: true, email: true } } },
})
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
const updated = await prisma.review.update({ const updated = await prisma.review.update({
@@ -53,6 +57,14 @@ export async function registerAdminReviewRoutes(fastify) {
moderatedAt: new Date(), moderatedAt: new Date(),
}, },
}) })
request.server.eventBus.emit('review:created', {
rating: updated.rating,
text: updated.text || '',
productTitle: existing.product?.title || '',
userName: existing.user?.name || existing.user?.email || '',
reviewId: updated.id,
})
return { item: updated } return { item: updated }
}, },
) )
@@ -0,0 +1,89 @@
import { prisma } from '../../lib/prisma.js'
export async function registerAdminNotificationRoutes(fastify) {
fastify.get(
'/api/admin/notifications/settings',
{ preHandler: [fastify.verifyAdmin] },
async () => {
let settings = await prisma.adminNotificationSettings.findFirst()
if (!settings) {
settings = await prisma.adminNotificationSettings.create({
data: {
emailEnabled: true,
telegramEnabled: false,
newOrder: true,
newOrderMessage: true,
newReview: true,
authCodeDuplicate: false,
},
})
}
return { settings }
},
)
fastify.put(
'/api/admin/notifications/settings',
{ preHandler: [fastify.verifyAdmin] },
async (request) => {
const body = request.body || {}
let settings = await prisma.adminNotificationSettings.findFirst()
const data = {}
if ('emailEnabled' in body) data.emailEnabled = Boolean(body.emailEnabled)
if ('telegramEnabled' in body) data.telegramEnabled = Boolean(body.telegramEnabled)
if ('telegramChatId' in body) data.telegramChatId = body.telegramChatId || null
if ('newOrder' in body) data.newOrder = Boolean(body.newOrder)
if ('newOrderMessage' in body) data.newOrderMessage = Boolean(body.newOrderMessage)
if ('newReview' in body) data.newReview = Boolean(body.newReview)
if ('authCodeDuplicate' in body) data.authCodeDuplicate = Boolean(body.authCodeDuplicate)
if (!settings) {
settings = await prisma.adminNotificationSettings.create({ data })
} else {
settings = await prisma.adminNotificationSettings.update({
where: { id: settings.id },
data,
})
}
return { settings }
},
)
fastify.post(
'/api/admin/notifications/telegram/webhook',
async (request) => {
const update = request.body || {}
const message = update.message
if (!message || !message.text || message.text !== '/start') return { ok: true }
const chatId = String(message.chat.id)
const settings = await prisma.adminNotificationSettings.findFirst()
if (settings) {
await prisma.adminNotificationSettings.update({
where: { id: settings.id },
data: { telegramChatId: chatId },
})
} else {
await prisma.adminNotificationSettings.create({
data: { telegramChatId: chatId },
})
}
if (process.env.TELEGRAM_BOT_TOKEN) {
await fetch(`https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: 'Вы подписаны на уведомления Craftshop.',
}),
})
}
return { ok: true }
},
)
}
+19 -1
View File
@@ -1,5 +1,6 @@
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
function mapUserForClient(user) { function mapUserForClient(user) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
@@ -18,7 +19,17 @@ export async function registerAuthRoutes(fastify) {
const email = normalizeEmail(request.body?.email) const email = normalizeEmail(request.body?.email)
if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' })
await issueEmailCode({ email, purpose: 'login' }) const code = await issueEmailCode({ email, purpose: 'login' })
const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase()
const isAdmin = email === adminEmail
request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, {
email,
code,
isAdmin,
})
return { ok: true } return { ok: true }
}) })
@@ -37,6 +48,13 @@ export async function registerAuthRoutes(fastify) {
create: { email }, create: { email },
}) })
// Ensure notification preference exists
await prisma.notificationPreference.upsert({
where: { userId: user.id },
create: { userId: user.id, globalEnabled: true },
update: {},
})
const token = fastify.jwt.sign({ sub: user.id, email: user.email }) const token = fastify.jwt.sign({ sub: user.id, email: user.email })
return { token, user: mapUserForClient(user) } return { token, user: mapUserForClient(user) }
}) })
+9
View File
@@ -1,4 +1,5 @@
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
export async function registerUserMessageRoutes(fastify) { export async function registerUserMessageRoutes(fastify) {
fastify.get( fastify.get(
@@ -26,6 +27,14 @@ export async function registerUserMessageRoutes(fastify) {
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' }) if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' }) if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } }) const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'user', text } })
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_MESSAGE_SENT, {
orderId: id,
authorType: 'user',
messageId: msg.id,
preview: text,
})
return reply.code(201).send({ item: msg }) return reply.code(201).send({ item: msg })
}, },
) )
+18
View File
@@ -1,5 +1,6 @@
import { isDeliveryCarrier } from '../lib/delivery-carrier.js' import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
export async function registerUserOrderRoutes(fastify) { export async function registerUserOrderRoutes(fastify) {
// ---- Создание заказа (checkout) ---- // ---- Создание заказа (checkout) ----
@@ -156,6 +157,23 @@ export async function registerUserOrderRoutes(fastify) {
return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' }) return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
} }
// Emit notification events
request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, {
orderId: created.id,
userId,
totalCents: created.totalCents,
itemsCount: cartItems.length,
})
// Also emit admin notification
request.server.eventBus.emit('order:created:admin', {
orderId: created.id,
userId,
userEmail: request.user.email || '',
totalCents: created.totalCents,
itemsCount: cartItems.length,
})
return reply.code(201).send({ orderId: created.id }) return reply.code(201).send({ orderId: created.id })
}, },
) )
+7
View File
@@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js'
import { escapeHtml } from '../lib/escape-html.js' import { escapeHtml } from '../lib/escape-html.js'
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js' import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
import { saveImageBufferToUploads } from '../lib/upload-images.js' import { saveImageBufferToUploads } from '../lib/upload-images.js'
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
export async function registerUserPaymentRoutes(fastify) { export async function registerUserPaymentRoutes(fastify) {
fastify.post( fastify.post(
@@ -105,6 +106,12 @@ export async function registerUserPaymentRoutes(fastify) {
return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
} }
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: id,
userId,
paymentStatus: 'pending',
})
return { ok: true, status: 'PENDING_PAYMENT' } return { ok: true, status: 'PENDING_PAYMENT' }
}, },
) )
+38
View File
@@ -0,0 +1,38 @@
import { prisma } from '../../lib/prisma.js'
import { ensureUserNotificationPreference } from '../../lib/notifications/preferences.js'
export async function registerUserNotificationRoutes(fastify) {
fastify.get(
'/api/me/notifications/settings',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub
const prefs = await ensureUserNotificationPreference(userId)
return { settings: prefs }
},
)
fastify.put(
'/api/me/notifications/settings',
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub
const body = request.body || {}
const data = {}
if ('globalEnabled' in body) data.globalEnabled = Boolean(body.globalEnabled)
if ('orderCreated' in body) data.orderCreated = Boolean(body.orderCreated)
if ('orderStatusChanged' in body) data.orderStatusChanged = Boolean(body.orderStatusChanged)
if ('orderMessageReceived' in body) data.orderMessageReceived = Boolean(body.orderMessageReceived)
if ('paymentStatusChanged' in body) data.paymentStatusChanged = Boolean(body.paymentStatusChanged)
const prefs = await prisma.notificationPreference.upsert({
where: { userId },
create: { userId, ...data },
update: data,
})
return { settings: prefs }
},
)
}
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path'
import { defineConfig } from 'vitest/config'
const projectRoot = path.resolve(__dirname, '..')
export default defineConfig({
resolve: {
alias: {
'@shared': path.resolve(projectRoot, 'shared'),
},
},
server: {
fs: {
allow: [projectRoot],
},
},
})
+34
View File
@@ -0,0 +1,34 @@
export type NotificationEventType =
| 'order:created'
| 'order:statusChanged'
| 'orderMessage:sent'
| 'orderMessage:adminReply'
| 'payment:statusChanged'
| 'auth:codeRequested'
export type NotificationChannel = 'email' | 'telegram'
export type NotificationStatus = 'pending' | 'sent' | 'failed'
export const NOTIFICATION_EVENTS: {
ORDER_CREATED: NotificationEventType
ORDER_STATUS_CHANGED: NotificationEventType
ORDER_MESSAGE_SENT: NotificationEventType
ORDER_MESSAGE_ADMIN_REPLY: NotificationEventType
PAYMENT_STATUS_CHANGED: NotificationEventType
AUTH_CODE_REQUESTED: NotificationEventType
}
export const NOTIFICATION_CHANNELS: {
EMAIL: NotificationChannel
TELEGRAM: NotificationChannel
}
export const NOTIFICATION_STATUSES: {
PENDING: NotificationStatus
SENT: NotificationStatus
FAILED: NotificationStatus
}
export const MAX_RETRY_ATTEMPTS: number
export const RETRY_DELAYS_MS: number[]
+25
View File
@@ -0,0 +1,25 @@
/** @typedef {'order:created' | 'order:statusChanged' | 'orderMessage:sent' | 'orderMessage:adminReply' | 'payment:statusChanged' | 'auth:codeRequested'} NotificationEventType */
export const NOTIFICATION_EVENTS = {
ORDER_CREATED: 'order:created',
ORDER_STATUS_CHANGED: 'order:statusChanged',
ORDER_MESSAGE_SENT: 'orderMessage:sent',
ORDER_MESSAGE_ADMIN_REPLY: 'orderMessage:adminReply',
PAYMENT_STATUS_CHANGED: 'payment:statusChanged',
AUTH_CODE_REQUESTED: 'auth:codeRequested',
}
export const NOTIFICATION_CHANNELS = {
EMAIL: 'email',
TELEGRAM: 'telegram',
}
export const NOTIFICATION_STATUSES = {
PENDING: 'pending',
SENT: 'sent',
FAILED: 'failed',
}
export const MAX_RETRY_ATTEMPTS = 3
export const RETRY_DELAYS_MS = [5_000, 30_000, 120_000]
+4
View File
@@ -1 +1,5 @@
export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520 export declare const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT: 20971520
export declare const ADMIN_UPLOAD_IMAGE_MAX_BYTES: 20971520
export declare function formatAdminImageMaxSizeHint(): string
+6
View File
@@ -1,3 +1,9 @@
const MB = 1024 * 1024 const MB = 1024 * 1024
export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB export const ADMIN_UPLOAD_IMAGE_MAX_FILE_BYTES_DEFAULT = 20 * MB
export const ADMIN_UPLOAD_IMAGE_MAX_BYTES = 20 * MB
export function formatAdminImageMaxSizeHint() {
return '20 МБ'
}