Merge branch 'notification'
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||
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[] }> {
|
||||
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> {
|
||||
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,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 { GalleryGrid } from './ui/GalleryGrid'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type GalleryImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
isResized: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -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 Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
@@ -8,10 +11,12 @@ import type { GalleryImageItem } from '../model/types'
|
||||
type Props = {
|
||||
items: GalleryImageItem[]
|
||||
deleting?: boolean
|
||||
resizing?: string | null
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -38,23 +43,56 @@ export function GalleryGrid({ items, deleting, onDelete }: Props) {
|
||||
sizes="140px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
|
||||
{item.isResized ? (
|
||||
<Chip
|
||||
label="Готово"
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="Не обработано"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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 Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
@@ -6,8 +6,13 @@ import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||
import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery'
|
||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||
import {
|
||||
deleteGalleryImage,
|
||||
fetchAdminGallery,
|
||||
GalleryGrid,
|
||||
resizeGalleryImage,
|
||||
uploadGalleryImages,
|
||||
} from '@/entities/gallery'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { GallerySliderSection } from './GallerySliderSection'
|
||||
@@ -22,6 +27,7 @@ function getApiErrorMessage(error: unknown): string | null {
|
||||
export function AdminGalleryPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [resizingId, setResizingId] = useState<string | null>(null)
|
||||
|
||||
const sliderQuery = useQuery({
|
||||
queryKey: ['admin', 'catalog-slider'],
|
||||
@@ -34,7 +40,7 @@ export function AdminGalleryPage() {
|
||||
})
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: (files: File[]) => uploadAdminProductImages(files),
|
||||
mutationFn: (files: File[]) => uploadGalleryImages(files),
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
|
||||
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 ?? []
|
||||
|
||||
return (
|
||||
@@ -58,8 +78,8 @@ export function AdminGalleryPage() {
|
||||
Галерея
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из
|
||||
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
|
||||
Изображения загружаются без обработки. После загрузки нажмите «Resize» для подготовки к публикации. Обработанные
|
||||
изображения доступны для добавления в карточку товара и слайдер.
|
||||
</Typography>
|
||||
|
||||
{sliderQuery.isError && (
|
||||
@@ -114,6 +134,9 @@ export function AdminGalleryPage() {
|
||||
{deleteMut.isError && (
|
||||
<Typography color="error">{getApiErrorMessage(deleteMut.error) ?? 'Ошибка удаления'}</Typography>
|
||||
)}
|
||||
{resizeMut.isError && (
|
||||
<Typography color="error">{getApiErrorMessage(resizeMut.error) ?? 'Ошибка обработки'}</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{galleryQuery.isError && (
|
||||
@@ -122,7 +145,13 @@ export function AdminGalleryPage() {
|
||||
</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 && (
|
||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function GallerySliderSection({ initialSlides, galleryItems }: Props) {
|
||||
const [pickOpen, setPickOpen] = useState(false)
|
||||
|
||||
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({
|
||||
mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }),
|
||||
|
||||
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||
@@ -26,6 +26,7 @@ import { AdminProductsPage } from '@/pages/admin-products'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { AdminNotificationsPage } from './AdminNotificationsPage'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
@@ -61,6 +62,7 @@ export function AdminLayoutPage() {
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||
{ 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="users" element={<AdminUsersPage />} />
|
||||
<Route path="info" element={<AdminInfoPage />} />
|
||||
<Route path="notifications" element={<AdminNotificationsPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</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 Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
@@ -31,10 +31,8 @@ import {
|
||||
fetchAdminProducts,
|
||||
fetchCategories,
|
||||
updateProduct,
|
||||
uploadAdminProductImages,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
@@ -203,20 +201,7 @@ export function AdminProductsPage() {
|
||||
else createMut.mutate()
|
||||
}
|
||||
|
||||
const productImagesInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
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 mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
@@ -401,11 +386,11 @@ export function AdminProductsPage() {
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (загрузка)
|
||||
Фото (из галереи)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из
|
||||
карточки; файл остаётся на сервере и в галерее.
|
||||
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
|
||||
остаётся на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
@@ -416,21 +401,6 @@ export function AdminProductsPage() {
|
||||
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
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
@@ -440,8 +410,6 @@ export function AdminProductsPage() {
|
||||
>
|
||||
Из галереи
|
||||
</Button>
|
||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
||||
</Box>
|
||||
|
||||
{productForm.watch('imageUrls').length > 0 && (
|
||||
@@ -558,6 +526,14 @@ export function AdminProductsPage() {
|
||||
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
|
||||
<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
|
||||
sx={{
|
||||
display: 'grid',
|
||||
@@ -566,33 +542,35 @@ export function AdminProductsPage() {
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryForPickQuery.data?.items ?? []).map((item) => {
|
||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
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' }}
|
||||
{(galleryForPickQuery.data?.items ?? [])
|
||||
.filter((item) => item.isResized)
|
||||
.map((item) => {
|
||||
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={item.id}
|
||||
sx={{ m: 0, alignItems: 'flex-start' }}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
|
||||
disabled={alreadyInCard}
|
||||
onChange={() => toggleGalleryPickUrl(item.url)}
|
||||
/>
|
||||
</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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -16,11 +16,12 @@ import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { fetchUnreadMessageCount } from '@/entities/user/api/messages-api'
|
||||
import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage'
|
||||
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 { OrdersPage } from '@/pages/me/ui/sections/OrdersPage'
|
||||
import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage'
|
||||
@@ -56,6 +57,7 @@ export function MeLayoutPage() {
|
||||
{ to: '/me/messages', label: 'Сообщения', icon: <MessageCircle /> },
|
||||
{ to: '/me/settings', label: 'Настройки', icon: <Settings /> },
|
||||
{ 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="settings" element={<SettingsPage />} />
|
||||
<Route path="addresses" element={<AddressesPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="*" element={<Navigate to="/me/settings" replace />} />
|
||||
</Routes>
|
||||
</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 в настройки админки.
|
||||
@@ -6,5 +6,9 @@
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "chrome-devtools-mcp@latest"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,3 +28,6 @@ VK_CLIENT_SECRET=
|
||||
# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
|
||||
YANDEX_CLIENT_ID=
|
||||
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";
|
||||
@@ -57,6 +57,7 @@ model ProductImage {
|
||||
model GalleryImage {
|
||||
id String @id @default(cuid())
|
||||
url String @unique
|
||||
isResized Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
catalogSliderSlides CatalogSliderSlide[]
|
||||
@@ -89,6 +90,8 @@ model User {
|
||||
reviews Review[]
|
||||
orderMessageReadStates UserOrderMessageReadState[]
|
||||
oauthAccounts OAuthAccount[]
|
||||
notificationPreference NotificationPreference?
|
||||
notificationLogs NotificationLog[]
|
||||
}
|
||||
|
||||
/// Прочитанность чата по заказу (для сообщений от админа после lastReadAt)
|
||||
@@ -268,3 +271,51 @@ model InfoPageBlock {
|
||||
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -8,6 +8,15 @@ import path from 'node:path'
|
||||
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
||||
import { getOrCreateUnspecifiedCategory } from './lib/default-category.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 { registerApiRoutes } from './routes/api.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 { registerUserOrderRoutes } from './routes/user-orders.js'
|
||||
import { registerUserPaymentRoutes } from './routes/user-payments.js'
|
||||
import { registerUserNotificationRoutes } from './routes/user/notifications.js'
|
||||
import { registerOAuthSocialRoutes } from './routes/oauth-social.js'
|
||||
import { registerUploadsResized } from './routes/uploads-resized.js'
|
||||
|
||||
@@ -42,7 +52,6 @@ await fastify.register(jwt, {
|
||||
await fastify.register(multipart, {
|
||||
limits: {
|
||||
files: 10,
|
||||
/** Совпадает с лимитом одного файла для `POST /api/admin/uploads` (товары, галерея). */
|
||||
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)
|
||||
await registerAuthRoutes(fastify)
|
||||
await registerUserAddressRoutes(fastify)
|
||||
@@ -77,12 +91,70 @@ await registerUserCartRoutes(fastify)
|
||||
await registerUserMessageRoutes(fastify)
|
||||
await registerUserOrderRoutes(fastify)
|
||||
await registerUserPaymentRoutes(fastify)
|
||||
await registerUserNotificationRoutes(fastify)
|
||||
await registerOAuthSocialRoutes(fastify)
|
||||
await registerApiRoutes(fastify)
|
||||
await ensureAdminUser()
|
||||
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 {
|
||||
await fastify.listen({ port, host: '0.0.0.0' })
|
||||
|
||||
@@ -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 path from 'node:path'
|
||||
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 TEST_PREFIX = 'upload-test-'
|
||||
|
||||
describe('persistMultipartImages with eager mode', () => {
|
||||
describe('persistMultipartImages with eager=false', () => {
|
||||
afterEach(async () => {
|
||||
const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => [])
|
||||
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 () => {
|
||||
const sharp = (await import('sharp')).default
|
||||
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$/)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function issueEmailCode({ email, purpose, userId = null }) {
|
||||
},
|
||||
})
|
||||
await sendLoginCodeEmail({ to: email, code })
|
||||
return code
|
||||
}
|
||||
|
||||
function parseEnvBool(raw) {
|
||||
|
||||
Vendored
+16
-1
@@ -8,9 +8,24 @@ export async function ensureAdminUser() {
|
||||
throw new Error('ADMIN_EMAIL должен быть валидным email')
|
||||
}
|
||||
|
||||
await prisma.user.upsert({
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: adminEmail },
|
||||
update: {},
|
||||
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
@@ -4,14 +4,8 @@ function hasSmtpEnv() {
|
||||
return Boolean(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_USER && process.env.SMTP_PASS)
|
||||
}
|
||||
|
||||
export async function sendLoginCodeEmail({ to, code }) {
|
||||
if (!hasSmtpEnv()) {
|
||||
// dev fallback
|
||||
console.log(`[DEV] login code for ${to}: ${code}`)
|
||||
return
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
function createTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
@@ -20,7 +14,15 @@ export async function sendLoginCodeEmail({ to, code }) {
|
||||
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
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
||||
import { registerAdminUserRoutes } from './api/admin-users.js'
|
||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||
import { registerInfoPageRoutes } from './api/info-page.js'
|
||||
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||
@@ -30,5 +31,6 @@ export async function registerApiRoutes(fastify) {
|
||||
await registerAdminOrderRoutes(fastify)
|
||||
await registerAdminReviewRoutes(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)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,12 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
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) {
|
||||
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(
|
||||
'/api/admin/gallery/:id',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { canTransitionAdminOrderStatus } from '../../lib/order-status.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
export async function registerAdminOrderRoutes(fastify) {
|
||||
fastify.get(
|
||||
@@ -108,6 +109,14 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
}
|
||||
|
||||
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 }
|
||||
},
|
||||
)
|
||||
@@ -156,6 +165,14 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
|
||||
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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { upsertGalleryImagesByUrls } from '../../lib/gallery.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 = {
|
||||
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(
|
||||
'/api/admin/products',
|
||||
{ preHandler: [fastify.verifyAdmin], schema: CREATE_PRODUCT_SCHEMA },
|
||||
@@ -122,6 +88,25 @@ export async function registerAdminProductRoutes(fastify) {
|
||||
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)
|
||||
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
||||
reply.code(400).send({ error: 'Количество — целое число от 0 до 10' })
|
||||
@@ -228,6 +213,25 @@ export async function registerAdminProductRoutes(fastify) {
|
||||
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 =
|
||||
body.imageUrls !== undefined
|
||||
? {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '../../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
export async function registerAdminReviewRoutes(fastify) {
|
||||
fastify.get(
|
||||
@@ -43,7 +44,10 @@ export async function registerAdminReviewRoutes(fastify) {
|
||||
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: 'Отзыв не найден' })
|
||||
|
||||
const updated = await prisma.review.update({
|
||||
@@ -53,6 +57,14 @@ export async function registerAdminReviewRoutes(fastify) {
|
||||
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 }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
function mapUserForClient(user) {
|
||||
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
|
||||
@@ -18,7 +19,17 @@ export async function registerAuthRoutes(fastify) {
|
||||
const email = normalizeEmail(request.body?.email)
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -37,6 +48,13 @@ export async function registerAuthRoutes(fastify) {
|
||||
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 })
|
||||
return { token, user: mapUserForClient(user) }
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
export async function registerUserMessageRoutes(fastify) {
|
||||
fastify.get(
|
||||
@@ -26,6 +27,14 @@ export async function registerUserMessageRoutes(fastify) {
|
||||
if (!text) 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 } })
|
||||
|
||||
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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
export async function registerUserOrderRoutes(fastify) {
|
||||
// ---- Создание заказа (checkout) ----
|
||||
@@ -156,6 +157,23 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
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 })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '../lib/prisma.js'
|
||||
import { escapeHtml } from '../lib/escape-html.js'
|
||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||
import { NOTIFICATION_EVENTS } from '../../shared/constants/notification-events.js'
|
||||
|
||||
export async function registerUserPaymentRoutes(fastify) {
|
||||
fastify.post(
|
||||
@@ -105,6 +106,12 @@ export async function registerUserPaymentRoutes(fastify) {
|
||||
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' }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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[]
|
||||
@@ -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]
|
||||
Vendored
+4
@@ -1 +1,5 @@
|
||||
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
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const MB = 1024 * 1024
|
||||
|
||||
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 МБ'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user