base commit
This commit is contained in:
@@ -1,29 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import Button from '@mui/material/Button'
|
import Box from '@mui/material/Box'
|
||||||
import Card from '@mui/material/Card'
|
import Card from '@mui/material/Card'
|
||||||
import CardContent from '@mui/material/CardContent'
|
import CardContent from '@mui/material/CardContent'
|
||||||
import CardMedia from '@mui/material/CardMedia'
|
import CardMedia from '@mui/material/CardMedia'
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
import Box from '@mui/material/Box'
|
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
|
||||||
import type { Swiper as SwiperType } from 'swiper/types'
|
|
||||||
import 'swiper/css'
|
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { useUnit } from 'effector-react'
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
import 'swiper/css'
|
||||||
import type { Product } from '@/entities/product/model/types'
|
import type { Product } from '@/entities/product/model/types'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { $user } from '@/shared/model/auth'
|
import type { Swiper as SwiperType } from 'swiper/types'
|
||||||
|
|
||||||
type Props = { product: Product; mediaHeight?: number }
|
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
||||||
|
|
||||||
export function ProductCard({ product, mediaHeight = 200 }: Props) {
|
export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||||
const qc = useQueryClient()
|
|
||||||
const user = useUnit($user)
|
|
||||||
const swiperRef = useRef<SwiperType | null>(null)
|
const swiperRef = useRef<SwiperType | null>(null)
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
const fromImages = (product.images ?? [])
|
const fromImages = (product.images ?? [])
|
||||||
@@ -46,11 +40,6 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) {
|
|||||||
swiperRef.current.slideTo(idx, 0)
|
swiperRef.current.slideTo(idx, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMut = useMutation({
|
|
||||||
mutationFn: () => addToCart({ productId: product.id, qty: 1 }),
|
|
||||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -160,18 +149,7 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) {
|
|||||||
<Typography variant="h6" color="primary">
|
<Typography variant="h6" color="primary">
|
||||||
{formatPriceRub(product.priceCents)}
|
{formatPriceRub(product.priceCents)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
{actions}
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
disabled={!user || addMut.isPending}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
addMut.mutate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user ? 'В корзину' : 'Войдите, чтобы купить'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
@@ -10,11 +11,8 @@ import Stack from '@mui/material/Stack'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
|
|
||||||
import * as maplibregl from 'maplibre-gl'
|
import * as maplibregl from 'maplibre-gl'
|
||||||
import Map, { Marker } from 'react-map-gl/maplibre'
|
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||||
import type { MapMouseEvent } from 'react-map-gl/maplibre'
|
|
||||||
import type { MapRef } from 'react-map-gl/maplibre'
|
|
||||||
|
|
||||||
type NominatimItem = { display_name: string; lat: string; lon: string }
|
type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AddToCartButton } from './ui/AddToCartButton'
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import type { ButtonProps } from '@mui/material/Button'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string
|
||||||
|
qty?: number
|
||||||
|
loggedOutLabel?: string
|
||||||
|
} & Omit<ButtonProps, 'onClick'>
|
||||||
|
|
||||||
|
export function AddToCartButton(props: Props) {
|
||||||
|
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
|
const addMut = useMutation({
|
||||||
|
mutationFn: () => addToCart({ productId, qty }),
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
{...rest}
|
||||||
|
disabled={Boolean(disabled) || !user || addMut.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
addMut.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user ? (children ?? 'В корзину') : loggedOutLabel}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
postAdminOrderMessage,
|
postAdminOrderMessage,
|
||||||
setAdminOrderStatus,
|
setAdminOrderStatus,
|
||||||
} from '@/entities/order/api/admin-order-api'
|
} from '@/entities/order/api/admin-order-api'
|
||||||
|
import { ORDER_STATUS_TRANSITIONS, type OrderStatus } from '@/shared/constants/order'
|
||||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
|
||||||
@@ -98,14 +99,7 @@ export function AdminOrdersPage() {
|
|||||||
const nextStatuses = useMemo(() => {
|
const nextStatuses = useMemo(() => {
|
||||||
const s = detail?.status
|
const s = detail?.status
|
||||||
if (!s) return []
|
if (!s) return []
|
||||||
const map: Record<string, string[]> = {
|
return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? []
|
||||||
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
|
|
||||||
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
|
|
||||||
PAID: ['IN_PROGRESS', 'CANCELLED'],
|
|
||||||
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
|
|
||||||
SHIPPED: ['DONE'],
|
|
||||||
}
|
|
||||||
return map[s] ?? []
|
|
||||||
}, [detail?.status])
|
}, [detail?.status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,7 +162,7 @@ export function AdminOrdersPage() {
|
|||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<em>Все</em>
|
<em>Все</em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'DONE', 'CANCELLED'].map((s) => (
|
{Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => (
|
||||||
<MenuItem key={s} value={s}>
|
<MenuItem key={s} value={s}>
|
||||||
{s}
|
{s}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import Stack from '@mui/material/Stack'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { apiClient } from '@/shared/api/client'
|
import { apiClient } from '@/shared/api/client'
|
||||||
import { $user, tokenSet } from '@/shared/model/auth'
|
import { $user, tokenSet } from '@/shared/model/auth'
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
|
import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api'
|
||||||
import { $user } from '@/shared/model/auth'
|
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
|
||||||
export function CartPage() {
|
export function CartPage() {
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
@@ -9,9 +10,8 @@ import Stack from '@mui/material/Stack'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||||
import { createOrder } from '@/entities/order/api/order-api'
|
import { createOrder } from '@/entities/order/api/order-api'
|
||||||
import { fetchMyAddresses } from '@/entities/user/api/address-api'
|
import { fetchMyAddresses } from '@/entities/user/api/address-api'
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
||||||
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
||||||
|
import { AddToCartButton } from '@/features/cart/add-to-cart'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||||
@@ -315,7 +316,11 @@ export function HomePage() {
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||||
<ProductCard product={p} mediaHeight={mediaHeight} />
|
<ProductCard
|
||||||
|
product={p}
|
||||||
|
mediaHeight={mediaHeight}
|
||||||
|
actions={<AddToCartButton productId={p.id} variant="contained" size="small" />}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
@@ -6,7 +7,6 @@ import Stack from '@mui/material/Stack'
|
|||||||
import TextField from '@mui/material/TextField'
|
import TextField from '@mui/material/TextField'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||||||
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
|
import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
|||||||
@@ -2,28 +2,23 @@ import { useMemo, useState } from 'react'
|
|||||||
import CloseIcon from '@mui/icons-material/Close'
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
|
||||||
import Chip from '@mui/material/Chip'
|
import Chip from '@mui/material/Chip'
|
||||||
import Dialog from '@mui/material/Dialog'
|
import Dialog from '@mui/material/Dialog'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import Skeleton from '@mui/material/Skeleton'
|
import Skeleton from '@mui/material/Skeleton'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
|
||||||
import { Navigation } from 'swiper/modules'
|
import { Navigation } from 'swiper/modules'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
import 'swiper/css'
|
import 'swiper/css'
|
||||||
import 'swiper/css/navigation'
|
import 'swiper/css/navigation'
|
||||||
import { useUnit } from 'effector-react'
|
|
||||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
|
||||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||||
|
import { AddToCartButton } from '@/features/cart/add-to-cart'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { $user } from '@/shared/model/auth'
|
|
||||||
|
|
||||||
export function ProductPage() {
|
export function ProductPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const qc = useQueryClient()
|
|
||||||
const user = useUnit($user)
|
|
||||||
const [viewerOpen, setViewerOpen] = useState(false)
|
const [viewerOpen, setViewerOpen] = useState(false)
|
||||||
const [viewerIndex, setViewerIndex] = useState(0)
|
const [viewerIndex, setViewerIndex] = useState(0)
|
||||||
|
|
||||||
@@ -33,15 +28,6 @@ export function ProductPage() {
|
|||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
})
|
})
|
||||||
|
|
||||||
const addMut = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const pid = productQuery.data?.id
|
|
||||||
if (!pid) throw new Error('Товар ещё не загружен')
|
|
||||||
await addToCart({ productId: pid, qty: 1 })
|
|
||||||
},
|
|
||||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageUrls = useMemo(() => {
|
const imageUrls = useMemo(() => {
|
||||||
const p = productQuery.data
|
const p = productQuery.data
|
||||||
if (!p) return []
|
if (!p) return []
|
||||||
@@ -151,14 +137,7 @@ export function ProductPage() {
|
|||||||
{formatPriceRub(p.priceCents)}
|
{formatPriceRub(p.priceCents)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Button
|
<AddToCartButton productId={p.id} variant="contained" sx={{ alignSelf: 'flex-start' }} />
|
||||||
variant="contained"
|
|
||||||
disabled={!user || addMut.isPending || !p}
|
|
||||||
onClick={() => addMut.mutate()}
|
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
|
||||||
>
|
|
||||||
{user ? 'В корзину' : 'Войдите, чтобы купить'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{p.description ? (
|
{p.description ? (
|
||||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const ORDER_STATUSES = [
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||||
|
|
||||||
|
export const ORDER_STATUS_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
|
||||||
|
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
|
||||||
|
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
|
||||||
|
PAID: ['IN_PROGRESS', 'CANCELLED'],
|
||||||
|
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
|
||||||
|
SHIPPED: ['DONE'],
|
||||||
|
DONE: [],
|
||||||
|
CANCELLED: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canTransitionOrderStatus(from: string, to: string): boolean {
|
||||||
|
if (from === to) return true
|
||||||
|
const f = from as OrderStatus
|
||||||
|
const list = ORDER_STATUS_TRANSITIONS[f]
|
||||||
|
return Array.isArray(list) ? list.includes(to as OrderStatus) : false
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const ORDER_STATUSES = [
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ORDER_STATUS_TRANSITIONS = {
|
||||||
|
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
|
||||||
|
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
|
||||||
|
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
|
||||||
|
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
|
||||||
|
SHIPPED: new Set(['DONE']),
|
||||||
|
DONE: new Set([]),
|
||||||
|
CANCELLED: new Set([]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canTransitionOrderStatus(from, to) {
|
||||||
|
if (from === to) return true
|
||||||
|
const allowed = ORDER_STATUS_TRANSITIONS[from]
|
||||||
|
return Boolean(allowed?.has(to))
|
||||||
|
}
|
||||||
|
|
||||||
+26
-857
@@ -1,861 +1,30 @@
|
|||||||
import { prisma } from '../lib/prisma.js'
|
import {
|
||||||
import crypto from 'node:crypto'
|
mapProductForApi,
|
||||||
import fs from 'node:fs'
|
parseMaterialsInput,
|
||||||
import path from 'node:path'
|
safeExtFromFilename,
|
||||||
import { hashPassword, normalizeEmail } from '../lib/auth.js'
|
slugify,
|
||||||
|
} from './api/_product-helpers.js'
|
||||||
function slugify(input) {
|
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||||
return input
|
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||||
.toLowerCase()
|
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||||
.trim()
|
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
||||||
.replace(/\s+/g, '-')
|
import { registerAdminUserRoutes } from './api/admin-users.js'
|
||||||
.replace(/[^a-z0-9-а-яё]/gi, '')
|
import { registerPublicCatalogRoutes } from './api/public-catalog.js'
|
||||||
}
|
import { registerPublicReviewRoutes } from './api/public-reviews.js'
|
||||||
|
|
||||||
function safeExtFromFilename(filename) {
|
|
||||||
const ext = path.extname(String(filename || '')).toLowerCase()
|
|
||||||
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
|
||||||
return allowed.has(ext) ? ext : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMaterialsInput(input) {
|
|
||||||
if (Array.isArray(input)) {
|
|
||||||
return input
|
|
||||||
.map((x) => String(x || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 30)
|
|
||||||
}
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
const s = input.trim()
|
|
||||||
if (!s) return []
|
|
||||||
// поддержка: "хлопок, дерево"
|
|
||||||
return s
|
|
||||||
.split(',')
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 30)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function materialsFromDb(materials) {
|
|
||||||
if (Array.isArray(materials)) return materials
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(String(materials || '[]'))
|
|
||||||
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapProductForApi(p) {
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
materials: materialsFromDb(p.materials),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerApiRoutes(fastify) {
|
export async function registerApiRoutes(fastify) {
|
||||||
fastify.get('/api/categories', async () => {
|
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
|
||||||
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
await registerPublicReviewRoutes(fastify)
|
||||||
|
|
||||||
|
await registerAdminProductRoutes(fastify, {
|
||||||
|
slugify,
|
||||||
|
safeExtFromFilename,
|
||||||
|
parseMaterialsInput,
|
||||||
|
mapProductForApi,
|
||||||
})
|
})
|
||||||
|
await registerAdminCategoryRoutes(fastify, { slugify })
|
||||||
fastify.get('/api/products', async (request, reply) => {
|
await registerAdminOrderRoutes(fastify)
|
||||||
const { categorySlug } = request.query
|
await registerAdminReviewRoutes(fastify)
|
||||||
const qRaw = request.query?.q
|
await registerAdminUserRoutes(fastify)
|
||||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
|
||||||
|
|
||||||
const sortRaw = request.query?.sort
|
|
||||||
const sort = typeof sortRaw === 'string' ? sortRaw : ''
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
|
||||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
|
||||||
|
|
||||||
const pageSizeRaw = request.query?.pageSize
|
|
||||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
|
||||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12
|
|
||||||
|
|
||||||
const priceMinRaw = request.query?.priceMin
|
|
||||||
const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw)
|
|
||||||
const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null
|
|
||||||
|
|
||||||
const priceMaxRaw = request.query?.priceMax
|
|
||||||
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
|
|
||||||
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
|
|
||||||
|
|
||||||
const where = { published: true }
|
|
||||||
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
|
||||||
where.category = { slug: categorySlug }
|
|
||||||
}
|
|
||||||
if (q) {
|
|
||||||
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
|
|
||||||
}
|
|
||||||
const applyPriceFilter = !(
|
|
||||||
priceMin !== null &&
|
|
||||||
priceMax !== null &&
|
|
||||||
priceMin === 0 &&
|
|
||||||
priceMax === 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
|
|
||||||
if (priceMin !== null && priceMax !== null && priceMax < priceMin) {
|
|
||||||
// не молчим: пользователю проще понять, чем получить пустой список
|
|
||||||
return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' })
|
|
||||||
}
|
|
||||||
where.priceCents = {
|
|
||||||
...(priceMin !== null ? { gte: priceMin } : {}),
|
|
||||||
...(priceMax !== null ? { lte: priceMax } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderBy =
|
|
||||||
sort === 'price_asc'
|
|
||||||
? { priceCents: 'asc' }
|
|
||||||
: sort === 'price_desc'
|
|
||||||
? { priceCents: 'desc' }
|
|
||||||
: { createdAt: 'desc' }
|
|
||||||
|
|
||||||
const total = await prisma.product.count({ where })
|
|
||||||
const items = await prisma.product.findMany({
|
|
||||||
where,
|
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
|
||||||
orderBy,
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { items: items.map(mapProductForApi), total, page, pageSize }
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get('/api/products/:id', async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const product = await prisma.product.findFirst({
|
|
||||||
where: { id, published: true },
|
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
|
||||||
})
|
|
||||||
if (!product) {
|
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return mapProductForApi(product)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Отзывы к товарам ----
|
|
||||||
|
|
||||||
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
|
||||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
|
||||||
|
|
||||||
const pageSizeRaw = request.query?.pageSize
|
|
||||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
|
||||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 10
|
|
||||||
if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' })
|
|
||||||
|
|
||||||
const product = await prisma.product.findFirst({ where: { id, published: true } })
|
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
|
|
||||||
const where = { productId: id, status: 'approved' }
|
|
||||||
const total = await prisma.review.count({ where })
|
|
||||||
const items = await prisma.review.findMany({
|
|
||||||
where,
|
|
||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { items, total, page, pageSize }
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/products/:id/reviews',
|
|
||||||
{ preHandler: [fastify.authenticate] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const userId = request.user.sub
|
|
||||||
const { id: productId } = request.params
|
|
||||||
|
|
||||||
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
|
||||||
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
|
|
||||||
const rating = Number(request.body?.rating)
|
|
||||||
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
|
||||||
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
|
||||||
}
|
|
||||||
const textRaw = request.body?.text
|
|
||||||
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
|
||||||
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await prisma.review.create({
|
|
||||||
data: {
|
|
||||||
productId,
|
|
||||||
userId,
|
|
||||||
rating: Math.floor(rating),
|
|
||||||
text: text && text.length ? text : null,
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return reply.code(201).send({ item: created })
|
|
||||||
} catch {
|
|
||||||
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Админ (тот же фронт, другой раздел) ----
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/admin/products',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async () => {
|
|
||||||
const items = await prisma.product.findMany({
|
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
})
|
|
||||||
return items.map(mapProductForApi)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/uploads',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
if (!request.isMultipart()) {
|
|
||||||
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadsDir = path.join(process.cwd(), 'uploads')
|
|
||||||
await fs.promises.mkdir(uploadsDir, { recursive: true })
|
|
||||||
|
|
||||||
const urls = []
|
|
||||||
const parts = request.parts()
|
|
||||||
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.type !== 'file') continue
|
|
||||||
const ext = safeExtFromFilename(part.filename)
|
|
||||||
if (!ext) {
|
|
||||||
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const id = crypto.randomUUID()
|
|
||||||
const fileName = `${id}${ext}`
|
|
||||||
const fullPath = path.join(uploadsDir, fileName)
|
|
||||||
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
|
||||||
urls.push(`/uploads/${fileName}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { urls }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/products',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const body = request.body ?? {}
|
|
||||||
const title = String(body.title ?? '').trim()
|
|
||||||
if (!title) {
|
|
||||||
reply.code(400).send({ error: 'Укажите название' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug =
|
|
||||||
String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
|
|
||||||
const categoryId = String(body.categoryId ?? '').trim()
|
|
||||||
if (!categoryId) {
|
|
||||||
reply.code(400).send({ error: 'Укажите категорию' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const priceCents = Number(body.priceCents)
|
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
|
||||||
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const inStock =
|
|
||||||
body.inStock === undefined || body.inStock === null ? true : Boolean(body.inStock)
|
|
||||||
const leadTimeDaysRaw = body.leadTimeDays
|
|
||||||
const leadTimeDays =
|
|
||||||
leadTimeDaysRaw === undefined || leadTimeDaysRaw === null || leadTimeDaysRaw === ''
|
|
||||||
? null
|
|
||||||
: Number(leadTimeDaysRaw)
|
|
||||||
if (!inStock) {
|
|
||||||
if (!Number.isFinite(leadTimeDays) || leadTimeDays <= 0) {
|
|
||||||
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const exists = await prisma.product.findUnique({ where: { slug } })
|
|
||||||
if (exists) {
|
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let quantity = null
|
|
||||||
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
|
|
||||||
const n = Number(body.quantity)
|
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
|
||||||
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
quantity = Math.floor(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const product = await prisma.product.create({
|
|
||||||
data: {
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
|
||||||
description: body.description ? String(body.description) : null,
|
|
||||||
quantity,
|
|
||||||
materials: JSON.stringify(parseMaterialsInput(body.materials)),
|
|
||||||
priceCents: Math.round(priceCents),
|
|
||||||
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
|
||||||
published: Boolean(body.published),
|
|
||||||
inStock,
|
|
||||||
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
|
|
||||||
categoryId,
|
|
||||||
images: Array.isArray(body.imageUrls)
|
|
||||||
? {
|
|
||||||
create: body.imageUrls
|
|
||||||
.map((u) => String(u || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((u, idx) => ({ url: u, sort: idx })),
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
|
||||||
})
|
|
||||||
reply.code(201).send(mapProductForApi(product))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/admin/products/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const body = request.body ?? {}
|
|
||||||
const existing = await prisma.product.findUnique({ where: { id } })
|
|
||||||
if (!existing) {
|
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = {}
|
|
||||||
if (body.title !== undefined) data.title = String(body.title).trim()
|
|
||||||
if (body.slug !== undefined) {
|
|
||||||
const s = String(body.slug).trim()
|
|
||||||
if (s && s !== existing.slug) {
|
|
||||||
const clash = await prisma.product.findFirst({
|
|
||||||
where: { slug: s, NOT: { id } },
|
|
||||||
})
|
|
||||||
if (clash) {
|
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.slug = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (body.shortDescription !== undefined) {
|
|
||||||
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
|
|
||||||
}
|
|
||||||
if (body.description !== undefined) {
|
|
||||||
data.description = body.description ? String(body.description) : null
|
|
||||||
}
|
|
||||||
if (body.quantity !== undefined) {
|
|
||||||
const v = body.quantity
|
|
||||||
if (v === null || v === '') {
|
|
||||||
data.quantity = null
|
|
||||||
} else {
|
|
||||||
const n = Number(v)
|
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
|
||||||
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.quantity = Math.floor(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (body.materials !== undefined) {
|
|
||||||
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
|
|
||||||
}
|
|
||||||
if (body.priceCents !== undefined) {
|
|
||||||
const p = Number(body.priceCents)
|
|
||||||
if (!Number.isFinite(p) || p < 0) {
|
|
||||||
reply.code(400).send({ error: 'Некорректная цена' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.priceCents = Math.round(p)
|
|
||||||
}
|
|
||||||
if (body.imageUrl !== undefined) {
|
|
||||||
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
|
|
||||||
}
|
|
||||||
if (body.published !== undefined) data.published = Boolean(body.published)
|
|
||||||
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
|
|
||||||
|
|
||||||
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
|
|
||||||
if (body.leadTimeDays !== undefined) {
|
|
||||||
const v = body.leadTimeDays
|
|
||||||
const n = v === null || v === '' ? null : Number(v)
|
|
||||||
if (n !== null && (!Number.isFinite(n) || n <= 0)) {
|
|
||||||
reply.code(400).send({ error: 'Срок исполнения должен быть числом дней > 0' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.leadTimeDays = n === null ? null : Math.round(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextInStock = data.inStock ?? existing.inStock
|
|
||||||
const nextLead = data.leadTimeDays ?? existing.leadTimeDays
|
|
||||||
if (!nextInStock && (!Number.isFinite(nextLead) || nextLead === null || nextLead <= 0)) {
|
|
||||||
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextInStock && data.leadTimeDays !== undefined) {
|
|
||||||
data.leadTimeDays = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const imagesUpdate =
|
|
||||||
body.imageUrls !== undefined
|
|
||||||
? {
|
|
||||||
deleteMany: {},
|
|
||||||
create: Array.isArray(body.imageUrls)
|
|
||||||
? body.imageUrls
|
|
||||||
.map((u) => String(u || '').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((u, idx) => ({ url: u, sort: idx }))
|
|
||||||
: [],
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const product = await prisma.product.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...data,
|
|
||||||
images: imagesUpdate,
|
|
||||||
},
|
|
||||||
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
|
||||||
})
|
|
||||||
return mapProductForApi(product)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/admin/products/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
try {
|
|
||||||
await prisma.product.delete({ where: { id } })
|
|
||||||
reply.code(204).send()
|
|
||||||
} catch {
|
|
||||||
reply.code(404).send({ error: 'Товар не найден' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/categories',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const body = request.body ?? {}
|
|
||||||
const name = String(body.name ?? '').trim()
|
|
||||||
if (!name) {
|
|
||||||
reply.code(400).send({ error: 'Укажите название категории' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
|
|
||||||
const sort =
|
|
||||||
body.sort !== undefined && body.sort !== null && body.sort !== ''
|
|
||||||
? Number(body.sort)
|
|
||||||
: undefined
|
|
||||||
const exists = await prisma.category.findUnique({ where: { slug } })
|
|
||||||
if (exists) {
|
|
||||||
reply.code(409).send({ error: 'Такой slug уже занят' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const category = await prisma.category.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reply.code(201).send(category)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Админ: заказы ----
|
|
||||||
|
|
||||||
function canTransition(from, to) {
|
|
||||||
if (from === to) return true
|
|
||||||
const allowed = {
|
|
||||||
DRAFT: new Set(['PENDING_PAYMENT', 'CANCELLED']),
|
|
||||||
PENDING_PAYMENT: new Set(['PAID', 'CANCELLED']),
|
|
||||||
PAID: new Set(['IN_PROGRESS', 'CANCELLED']),
|
|
||||||
IN_PROGRESS: new Set(['SHIPPED', 'CANCELLED']),
|
|
||||||
SHIPPED: new Set(['DONE']),
|
|
||||||
DONE: new Set([]),
|
|
||||||
CANCELLED: new Set([]),
|
|
||||||
}
|
|
||||||
return Boolean(allowed[from]?.has(to))
|
|
||||||
}
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/admin/orders',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
|
||||||
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
|
||||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
|
||||||
|
|
||||||
const pageSizeRaw = request.query?.pageSize
|
|
||||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
|
||||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
|
||||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
|
||||||
|
|
||||||
const where = {}
|
|
||||||
if (status) where.status = status
|
|
||||||
if (q) {
|
|
||||||
where.OR = [
|
|
||||||
{ id: { contains: q } },
|
|
||||||
{ user: { email: { contains: q } } },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = await prisma.order.count({ where })
|
|
||||||
const items = await prisma.order.findMany({
|
|
||||||
where,
|
|
||||||
include: { user: { select: { id: true, email: true } }, items: true },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: items.map((o) => ({
|
|
||||||
id: o.id,
|
|
||||||
status: o.status,
|
|
||||||
totalCents: o.totalCents,
|
|
||||||
currency: o.currency,
|
|
||||||
createdAt: o.createdAt,
|
|
||||||
updatedAt: o.updatedAt,
|
|
||||||
user: o.user,
|
|
||||||
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/admin/orders/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const order = await prisma.order.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, email: true, name: true, phone: true } },
|
|
||||||
items: true,
|
|
||||||
messages: { orderBy: { createdAt: 'asc' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
return { item: order }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/admin/orders/:id/status',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const next = String(request.body?.status || '').trim()
|
|
||||||
if (!next) return reply.code(400).send({ error: 'status обязателен' })
|
|
||||||
|
|
||||||
const existing = await prisma.order.findUnique({ where: { id } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
if (!canTransition(existing.status, next)) {
|
|
||||||
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.order.update({ where: { id }, data: { status: next } })
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/orders/:id/messages',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const text = String(request.body?.text || '').trim()
|
|
||||||
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
|
||||||
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
|
||||||
|
|
||||||
const order = await prisma.order.findUnique({ where: { id } })
|
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
|
||||||
|
|
||||||
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
|
|
||||||
return reply.code(201).send({ item: msg })
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Админ: отзывы (модерация) ----
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/admin/reviews',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
|
||||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
|
||||||
|
|
||||||
const pageSizeRaw = request.query?.pageSize
|
|
||||||
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
|
||||||
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
|
||||||
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
|
||||||
|
|
||||||
const where = status ? { status } : {}
|
|
||||||
const total = await prisma.review.count({ where })
|
|
||||||
const items = await prisma.review.findMany({
|
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
user: { select: { id: true, email: true, name: true } },
|
|
||||||
product: { select: { id: true, title: true } },
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
})
|
|
||||||
|
|
||||||
return { items, total, page, pageSize }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/admin/reviews/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const action = String(request.body?.action || '').trim()
|
|
||||||
if (action !== 'approve' && action !== 'reject') {
|
|
||||||
return reply.code(400).send({ error: 'action должен быть approve или reject' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.review.findUnique({ where: { id } })
|
|
||||||
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
|
||||||
|
|
||||||
const updated = await prisma.review.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: action === 'approve' ? 'approved' : 'rejected',
|
|
||||||
moderatedAt: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { item: updated }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---- Админ: пользователи ----
|
|
||||||
|
|
||||||
fastify.get(
|
|
||||||
'/api/admin/users',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const qRaw = request.query?.q
|
|
||||||
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
|
||||||
|
|
||||||
const pageRaw = request.query?.page
|
|
||||||
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
|
||||||
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
|
||||||
|
|
||||||
const pageSizeRaw = request.query?.pageSize
|
|
||||||
const pageSizeParsed =
|
|
||||||
typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
|
||||||
const pageSize =
|
|
||||||
Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
|
||||||
|
|
||||||
if (pageSize > 100) {
|
|
||||||
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = q
|
|
||||||
? {
|
|
||||||
OR: [
|
|
||||||
{ email: { contains: q } },
|
|
||||||
{ name: { contains: q } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const total = await prisma.user.count({ where })
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
name: true,
|
|
||||||
passwordHash: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { updatedAt: 'desc' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
})
|
|
||||||
const items = users.map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
email: u.email,
|
|
||||||
name: u.name,
|
|
||||||
hasPassword: Boolean(u.passwordHash),
|
|
||||||
createdAt: u.createdAt,
|
|
||||||
updatedAt: u.updatedAt,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { items, total, page, pageSize }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.post(
|
|
||||||
'/api/admin/users',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const body = request.body ?? {}
|
|
||||||
|
|
||||||
const email = normalizeEmail(body.email)
|
|
||||||
if (!email || !email.includes('@')) {
|
|
||||||
reply.code(400).send({ error: 'Некорректная почта' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nameRaw = body.name
|
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
|
||||||
if (name !== null && name.length > 40) {
|
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = body.password ? String(body.password) : ''
|
|
||||||
if (password && password.length < 8) {
|
|
||||||
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await prisma.user.findUnique({ where: { email } })
|
|
||||||
if (exists) {
|
|
||||||
reply.code(409).send({ error: 'Почта уже занята' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = password ? await hashPassword(password) : null
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
name: name && name.length ? name : null,
|
|
||||||
passwordHash: passwordHash ?? undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
reply.code(201).send({
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
hasPassword: Boolean(user.passwordHash),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.patch(
|
|
||||||
'/api/admin/users/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
const body = request.body ?? {}
|
|
||||||
|
|
||||||
const existing = await prisma.user.findUnique({ where: { id } })
|
|
||||||
if (!existing) {
|
|
||||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {}
|
|
||||||
|
|
||||||
if (body.email !== undefined) {
|
|
||||||
const email = normalizeEmail(body.email)
|
|
||||||
if (!email || !email.includes('@')) {
|
|
||||||
reply.code(400).send({ error: 'Некорректная почта' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (email !== existing.email) {
|
|
||||||
const clash = await prisma.user.findUnique({ where: { email } })
|
|
||||||
if (clash) {
|
|
||||||
reply.code(409).send({ error: 'Почта уже занята' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.email = email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.name !== undefined) {
|
|
||||||
const nameRaw = body.name
|
|
||||||
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
|
||||||
if (name !== null && name.length > 40) {
|
|
||||||
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.name = name && name.length ? name : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.password !== undefined) {
|
|
||||||
const password = body.password ? String(body.password) : ''
|
|
||||||
if (password) {
|
|
||||||
if (password.length < 8) {
|
|
||||||
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.passwordHash = await hashPassword(password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.update({ where: { id }, data })
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
hasPassword: Boolean(user.passwordHash),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fastify.delete(
|
|
||||||
'/api/admin/users/:id',
|
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
|
||||||
async (request, reply) => {
|
|
||||||
const { id } = request.params
|
|
||||||
try {
|
|
||||||
await prisma.user.delete({ where: { id } })
|
|
||||||
reply.code(204).send()
|
|
||||||
} catch {
|
|
||||||
reply.code(404).send({ error: 'Пользователь не найден' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export function slugify(input) {
|
||||||
|
return String(input || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-а-яё]/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeExtFromFilename(filename) {
|
||||||
|
const ext = path.extname(String(filename || '')).toLowerCase()
|
||||||
|
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
|
||||||
|
return allowed.has(ext) ? ext : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMaterialsInput(input) {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input
|
||||||
|
.map((x) => String(x || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 30)
|
||||||
|
}
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const s = input.trim()
|
||||||
|
if (!s) return []
|
||||||
|
return s
|
||||||
|
.split(',')
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 30)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materialsFromDb(materials) {
|
||||||
|
if (Array.isArray(materials)) return materials
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(String(materials || '[]'))
|
||||||
|
return Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapProductForApi(p) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
materials: materialsFromDb(p.materials),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/categories',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = request.body ?? {}
|
||||||
|
const name = String(body.name ?? '').trim()
|
||||||
|
if (!name) {
|
||||||
|
reply.code(400).send({ error: 'Укажите название категории' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
|
||||||
|
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
||||||
|
const exists = await prisma.category.findUnique({ where: { slug } })
|
||||||
|
if (exists) {
|
||||||
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const category = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
sort: Number.isFinite(sort) ? Math.round(sort) : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reply.code(201).send(category)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { canTransitionOrderStatus } from '../../lib/order-status.js'
|
||||||
|
|
||||||
|
export async function registerAdminOrderRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/admin/orders',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : ''
|
||||||
|
const q = typeof request.query?.q === 'string' ? request.query.q.trim() : ''
|
||||||
|
|
||||||
|
const pageRaw = request.query?.page
|
||||||
|
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||||
|
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||||
|
|
||||||
|
const pageSizeRaw = request.query?.pageSize
|
||||||
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||||
|
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||||
|
|
||||||
|
const where = {}
|
||||||
|
if (status) where.status = status
|
||||||
|
if (q) {
|
||||||
|
where.OR = [{ id: { contains: q } }, { user: { email: { contains: q } } }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await prisma.order.count({ where })
|
||||||
|
const items = await prisma.order.findMany({
|
||||||
|
where,
|
||||||
|
include: { user: { select: { id: true, email: true } }, items: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
status: o.status,
|
||||||
|
totalCents: o.totalCents,
|
||||||
|
currency: o.currency,
|
||||||
|
createdAt: o.createdAt,
|
||||||
|
updatedAt: o.updatedAt,
|
||||||
|
user: o.user,
|
||||||
|
itemsCount: o.items.reduce((s, i) => s + i.qty, 0),
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.get(
|
||||||
|
'/api/admin/orders/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const order = await prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, email: true, name: true, phone: true } },
|
||||||
|
items: true,
|
||||||
|
messages: { orderBy: { createdAt: 'asc' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
return { item: order }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/orders/:id/status',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const next = String(request.body?.status || '').trim()
|
||||||
|
if (!next) return reply.code(400).send({ error: 'status обязателен' })
|
||||||
|
|
||||||
|
const existing = await prisma.order.findUnique({ where: { id } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
if (!canTransitionOrderStatus(existing.status, next)) {
|
||||||
|
return reply.code(409).send({ error: `Нельзя сменить статус ${existing.status} → ${next}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.order.update({ where: { id }, data: { status: next } })
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/orders/:id/messages',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const text = String(request.body?.text || '').trim()
|
||||||
|
if (!text) return reply.code(400).send({ error: 'Сообщение пустое' })
|
||||||
|
if (text.length > 2000) return reply.code(400).send({ error: 'Сообщение слишком длинное' })
|
||||||
|
|
||||||
|
const order = await prisma.order.findUnique({ where: { id } })
|
||||||
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
|
const msg = await prisma.orderMessage.create({ data: { orderId: id, authorType: 'admin', text } })
|
||||||
|
return reply.code(201).send({ item: msg })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerAdminProductRoutes(
|
||||||
|
fastify,
|
||||||
|
{ slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {},
|
||||||
|
) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/admin/products',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async () => {
|
||||||
|
const items = await prisma.product.findMany({
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
})
|
||||||
|
return items.map(mapProductForApi)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/uploads',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
reply.code(400).send({ error: 'Ожидается multipart/form-data' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true })
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const parts = request.parts()
|
||||||
|
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (part.type !== 'file') continue
|
||||||
|
const ext = safeExtFromFilename(part.filename)
|
||||||
|
if (!ext) {
|
||||||
|
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const fileName = `${id}${ext}`
|
||||||
|
const fullPath = path.join(uploadsDir, fileName)
|
||||||
|
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
||||||
|
urls.push(`/uploads/${fileName}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { urls }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/products',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = request.body ?? {}
|
||||||
|
const title = String(body.title ?? '').trim()
|
||||||
|
if (!title) {
|
||||||
|
reply.code(400).send({ error: 'Укажите название' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
|
||||||
|
const categoryId = String(body.categoryId ?? '').trim()
|
||||||
|
if (!categoryId) {
|
||||||
|
reply.code(400).send({ error: 'Укажите категорию' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const priceCents = Number(body.priceCents)
|
||||||
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
|
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inStock = body.inStock === undefined || body.inStock === null ? true : Boolean(body.inStock)
|
||||||
|
const leadTimeDaysRaw = body.leadTimeDays
|
||||||
|
const leadTimeDays =
|
||||||
|
leadTimeDaysRaw === undefined || leadTimeDaysRaw === null || leadTimeDaysRaw === '' ? null : Number(leadTimeDaysRaw)
|
||||||
|
if (!inStock) {
|
||||||
|
if (!Number.isFinite(leadTimeDays) || leadTimeDays <= 0) {
|
||||||
|
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const exists = await prisma.product.findUnique({ where: { slug } })
|
||||||
|
if (exists) {
|
||||||
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let quantity = null
|
||||||
|
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
|
||||||
|
const n = Number(body.quantity)
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quantity = Math.floor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
shortDescription: body.shortDescription ? String(body.shortDescription) : null,
|
||||||
|
description: body.description ? String(body.description) : null,
|
||||||
|
quantity,
|
||||||
|
materials: JSON.stringify(parseMaterialsInput(body.materials)),
|
||||||
|
priceCents: Math.round(priceCents),
|
||||||
|
imageUrl: body.imageUrl ? String(body.imageUrl) : null,
|
||||||
|
published: Boolean(body.published),
|
||||||
|
inStock,
|
||||||
|
leadTimeDays: inStock ? null : Math.round(leadTimeDays),
|
||||||
|
categoryId,
|
||||||
|
images: Array.isArray(body.imageUrls)
|
||||||
|
? {
|
||||||
|
create: body.imageUrls
|
||||||
|
.map((u) => String(u || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((u, idx) => ({ url: u, sort: idx })),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
|
})
|
||||||
|
reply.code(201).send(mapProductForApi(product))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/products/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const body = request.body ?? {}
|
||||||
|
const existing = await prisma.product.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = {}
|
||||||
|
if (body.title !== undefined) data.title = String(body.title).trim()
|
||||||
|
if (body.slug !== undefined) {
|
||||||
|
const s = String(body.slug).trim()
|
||||||
|
if (s && s !== existing.slug) {
|
||||||
|
const clash = await prisma.product.findFirst({ where: { slug: s, NOT: { id } } })
|
||||||
|
if (clash) {
|
||||||
|
reply.code(409).send({ error: 'Такой slug уже занят' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.slug = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.shortDescription !== undefined) {
|
||||||
|
data.shortDescription = body.shortDescription ? String(body.shortDescription) : null
|
||||||
|
}
|
||||||
|
if (body.description !== undefined) {
|
||||||
|
data.description = body.description ? String(body.description) : null
|
||||||
|
}
|
||||||
|
if (body.quantity !== undefined) {
|
||||||
|
const v = body.quantity
|
||||||
|
if (v === null || v === '') {
|
||||||
|
data.quantity = null
|
||||||
|
} else {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n) || n < 0) {
|
||||||
|
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.quantity = Math.floor(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.materials !== undefined) {
|
||||||
|
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
|
||||||
|
}
|
||||||
|
if (body.priceCents !== undefined) {
|
||||||
|
const p = Number(body.priceCents)
|
||||||
|
if (!Number.isFinite(p) || p < 0) {
|
||||||
|
reply.code(400).send({ error: 'Некорректная цена' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.priceCents = Math.round(p)
|
||||||
|
}
|
||||||
|
if (body.imageUrl !== undefined) {
|
||||||
|
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
|
||||||
|
}
|
||||||
|
if (body.published !== undefined) data.published = Boolean(body.published)
|
||||||
|
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
|
||||||
|
|
||||||
|
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
|
||||||
|
if (body.leadTimeDays !== undefined) {
|
||||||
|
const v = body.leadTimeDays
|
||||||
|
const n = v === null || v === '' ? null : Number(v)
|
||||||
|
if (n !== null && (!Number.isFinite(n) || n <= 0)) {
|
||||||
|
reply.code(400).send({ error: 'Срок исполнения должен быть числом дней > 0' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.leadTimeDays = n === null ? null : Math.round(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextInStock = data.inStock ?? existing.inStock
|
||||||
|
const nextLead = data.leadTimeDays ?? existing.leadTimeDays
|
||||||
|
if (!nextInStock && (!Number.isFinite(nextLead) || nextLead === null || nextLead <= 0)) {
|
||||||
|
reply.code(400).send({ error: 'Если "под заказ", укажите срок исполнения (дней) > 0' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextInStock && data.leadTimeDays !== undefined) {
|
||||||
|
data.leadTimeDays = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesUpdate =
|
||||||
|
body.imageUrls !== undefined
|
||||||
|
? {
|
||||||
|
deleteMany: {},
|
||||||
|
create: Array.isArray(body.imageUrls)
|
||||||
|
? body.imageUrls
|
||||||
|
.map((u) => String(u || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((u, idx) => ({ url: u, sort: idx }))
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const product = await prisma.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: { ...data, images: imagesUpdate },
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
|
})
|
||||||
|
return mapProductForApi(product)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
'/api/admin/products/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
try {
|
||||||
|
await prisma.product.delete({ where: { id } })
|
||||||
|
reply.code(204).send()
|
||||||
|
} catch {
|
||||||
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerAdminReviewRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/admin/reviews',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const status = typeof request.query?.status === 'string' ? request.query.status.trim() : 'pending'
|
||||||
|
|
||||||
|
const pageRaw = request.query?.page
|
||||||
|
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||||
|
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||||
|
|
||||||
|
const pageSizeRaw = request.query?.pageSize
|
||||||
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||||
|
if (pageSize > 100) return reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||||
|
|
||||||
|
const where = status ? { status } : {}
|
||||||
|
const total = await prisma.review.count({ where })
|
||||||
|
const items = await prisma.review.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, email: true, name: true } },
|
||||||
|
product: { select: { id: true, title: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items, total, page, pageSize }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/reviews/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const action = String(request.body?.action || '').trim()
|
||||||
|
if (action !== 'approve' && action !== 'reject') {
|
||||||
|
return reply.code(400).send({ error: 'action должен быть approve или reject' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.review.findUnique({ where: { id } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' })
|
||||||
|
|
||||||
|
const updated = await prisma.review.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: action === 'approve' ? 'approved' : 'rejected',
|
||||||
|
moderatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
import { hashPassword, normalizeEmail } from '../../lib/auth.js'
|
||||||
|
|
||||||
|
export async function registerAdminUserRoutes(fastify) {
|
||||||
|
fastify.get(
|
||||||
|
'/api/admin/users',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const qRaw = request.query?.q
|
||||||
|
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||||
|
|
||||||
|
const pageRaw = request.query?.page
|
||||||
|
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||||
|
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||||
|
|
||||||
|
const pageSizeRaw = request.query?.pageSize
|
||||||
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 20
|
||||||
|
|
||||||
|
if (pageSize > 100) {
|
||||||
|
reply.code(400).send({ error: 'pageSize должен быть ≤ 100' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = q
|
||||||
|
? {
|
||||||
|
OR: [{ email: { contains: q } }, { name: { contains: q } }],
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const total = await prisma.user.count({ where })
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
passwordHash: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
const items = users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
email: u.email,
|
||||||
|
name: u.name,
|
||||||
|
hasPassword: Boolean(u.passwordHash),
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
updatedAt: u.updatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { items, total, page, pageSize }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/users',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = request.body ?? {}
|
||||||
|
|
||||||
|
const email = normalizeEmail(body.email)
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = body.name
|
||||||
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
|
if (name !== null && name.length > 40) {
|
||||||
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = body.password ? String(body.password) : ''
|
||||||
|
if (password && password.length < 8) {
|
||||||
|
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (exists) {
|
||||||
|
reply.code(409).send({ error: 'Почта уже занята' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = password ? await hashPassword(password) : null
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name: name && name.length ? name : null,
|
||||||
|
passwordHash: passwordHash ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
reply.code(201).send({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
hasPassword: Boolean(user.passwordHash),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/users/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const body = request.body ?? {}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {}
|
||||||
|
|
||||||
|
if (body.email !== undefined) {
|
||||||
|
const email = normalizeEmail(body.email)
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
reply.code(400).send({ error: 'Некорректная почта' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (email !== existing.email) {
|
||||||
|
const clash = await prisma.user.findUnique({ where: { email } })
|
||||||
|
if (clash) {
|
||||||
|
reply.code(409).send({ error: 'Почта уже занята' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name !== undefined) {
|
||||||
|
const nameRaw = body.name
|
||||||
|
const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
|
||||||
|
if (name !== null && name.length > 40) {
|
||||||
|
reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.name = name && name.length ? name : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.password !== undefined) {
|
||||||
|
const password = body.password ? String(body.password) : ''
|
||||||
|
if (password) {
|
||||||
|
if (password.length < 8) {
|
||||||
|
reply.code(400).send({ error: 'Пароль минимум 8 символов' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.passwordHash = await hashPassword(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.update({ where: { id }, data })
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
hasPassword: Boolean(user.passwordHash),
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
fastify.delete(
|
||||||
|
'/api/admin/users/:id',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
try {
|
||||||
|
await prisma.user.delete({ where: { id } })
|
||||||
|
reply.code(204).send()
|
||||||
|
} catch {
|
||||||
|
reply.code(404).send({ error: 'Пользователь не найден' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerPublicCatalogRoutes(fastify, { mapProductForApi } = {}) {
|
||||||
|
fastify.get('/api/categories', async () => {
|
||||||
|
return prisma.category.findMany({ orderBy: { sort: 'asc' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.get('/api/products', async (request, reply) => {
|
||||||
|
const { categorySlug } = request.query
|
||||||
|
const qRaw = request.query?.q
|
||||||
|
const q = typeof qRaw === 'string' ? qRaw.trim() : ''
|
||||||
|
|
||||||
|
const sortRaw = request.query?.sort
|
||||||
|
const sort = typeof sortRaw === 'string' ? sortRaw : ''
|
||||||
|
|
||||||
|
const pageRaw = request.query?.page
|
||||||
|
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||||
|
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||||
|
|
||||||
|
const pageSizeRaw = request.query?.pageSize
|
||||||
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 12
|
||||||
|
|
||||||
|
const priceMinRaw = request.query?.priceMin
|
||||||
|
const priceMinParsed = typeof priceMinRaw === 'string' ? Number(priceMinRaw) : Number(priceMinRaw)
|
||||||
|
const priceMin = Number.isFinite(priceMinParsed) && priceMinParsed >= 0 ? Math.floor(priceMinParsed) : null
|
||||||
|
|
||||||
|
const priceMaxRaw = request.query?.priceMax
|
||||||
|
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
|
||||||
|
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
|
||||||
|
|
||||||
|
const where = { published: true }
|
||||||
|
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
|
||||||
|
where.category = { slug: categorySlug }
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
where.OR = [{ title: { contains: q } }, { shortDescription: { contains: q } }]
|
||||||
|
}
|
||||||
|
const applyPriceFilter = !(priceMin !== null && priceMax !== null && priceMin === 0 && priceMax === 0)
|
||||||
|
|
||||||
|
if (applyPriceFilter && (priceMin !== null || priceMax !== null)) {
|
||||||
|
if (priceMin !== null && priceMax !== null && priceMax < priceMin) {
|
||||||
|
return reply.code(400).send({ error: 'priceMax должен быть ≥ priceMin' })
|
||||||
|
}
|
||||||
|
where.priceCents = {
|
||||||
|
...(priceMin !== null ? { gte: priceMin } : {}),
|
||||||
|
...(priceMax !== null ? { lte: priceMax } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy =
|
||||||
|
sort === 'price_asc'
|
||||||
|
? { priceCents: 'asc' }
|
||||||
|
: sort === 'price_desc'
|
||||||
|
? { priceCents: 'desc' }
|
||||||
|
: { createdAt: 'desc' }
|
||||||
|
|
||||||
|
const total = await prisma.product.count({ where })
|
||||||
|
const items = await prisma.product.findMany({
|
||||||
|
where,
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
|
orderBy,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items: items.map(mapProductForApi), total, page, pageSize }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.get('/api/products/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id, published: true },
|
||||||
|
include: { category: true, images: { orderBy: { sort: 'asc' } } },
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return mapProductForApi(product)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
export async function registerPublicReviewRoutes(fastify) {
|
||||||
|
fastify.get('/api/products/:id/reviews', async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
|
const pageRaw = request.query?.page
|
||||||
|
const pageParsed = typeof pageRaw === 'string' ? Number(pageRaw) : Number(pageRaw)
|
||||||
|
const page = Number.isFinite(pageParsed) && pageParsed > 0 ? Math.floor(pageParsed) : 1
|
||||||
|
|
||||||
|
const pageSizeRaw = request.query?.pageSize
|
||||||
|
const pageSizeParsed = typeof pageSizeRaw === 'string' ? Number(pageSizeRaw) : Number(pageSizeRaw)
|
||||||
|
const pageSize = Number.isFinite(pageSizeParsed) && pageSizeParsed > 0 ? Math.floor(pageSizeParsed) : 10
|
||||||
|
if (pageSize > 50) return reply.code(400).send({ error: 'pageSize должен быть ≤ 50' })
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({ where: { id, published: true } })
|
||||||
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
|
const where = { productId: id, status: 'approved' }
|
||||||
|
const total = await prisma.review.count({ where })
|
||||||
|
const items = await prisma.review.findMany({
|
||||||
|
where,
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { items, total, page, pageSize }
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
'/api/products/:id/reviews',
|
||||||
|
{ preHandler: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = request.user.sub
|
||||||
|
const { id: productId } = request.params
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
|
||||||
|
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
|
||||||
|
|
||||||
|
const rating = Number(request.body?.rating)
|
||||||
|
if (!Number.isFinite(rating) || rating < 1 || rating > 5) {
|
||||||
|
return reply.code(400).send({ error: 'rating должен быть от 1 до 5' })
|
||||||
|
}
|
||||||
|
const textRaw = request.body?.text
|
||||||
|
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
|
||||||
|
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await prisma.review.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
userId,
|
||||||
|
rating: Math.floor(rating),
|
||||||
|
text: text && text.length ? text : null,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return reply.code(201).send({ item: created })
|
||||||
|
} catch {
|
||||||
|
return reply.code(409).send({ error: 'Вы уже оставляли отзыв на этот товар' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user