From f26223091a21925eee6b5c5b11b898b0b702f108 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Wed, 29 Apr 2026 19:29:24 +0500 Subject: [PATCH] base commit --- .../src/entities/product/ui/ProductCard.tsx | 38 +- .../ui/AddressMapPicker.tsx | 6 +- client/src/features/cart/add-to-cart/index.ts | 1 + .../cart/add-to-cart/ui/AddToCartButton.tsx | 37 + .../pages/admin-orders/ui/AdminOrdersPage.tsx | 12 +- client/src/pages/auth/ui/AuthPage.tsx | 2 +- client/src/pages/cart/ui/CartPage.tsx | 2 +- client/src/pages/checkout/ui/CheckoutPage.tsx | 4 +- client/src/pages/home/ui/HomePage.tsx | 7 +- .../pages/me/ui/sections/OrderDetailPage.tsx | 2 +- client/src/pages/product/ui/ProductPage.tsx | 29 +- client/src/shared/constants/order.ts | 28 + server/src/lib/order-status.js | 26 + server/src/routes/api.js | 883 +----------------- server/src/routes/api/_product-helpers.js | 52 ++ server/src/routes/api/admin-categories.js | 32 + server/src/routes/api/admin-orders.js | 108 +++ server/src/routes/api/admin-products.js | 250 +++++ server/src/routes/api/admin-reviews.js | 60 ++ server/src/routes/api/admin-users.js | 189 ++++ server/src/routes/api/public-catalog.js | 83 ++ server/src/routes/api/public-reviews.js | 67 ++ 22 files changed, 987 insertions(+), 931 deletions(-) create mode 100644 client/src/features/cart/add-to-cart/index.ts create mode 100644 client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx create mode 100644 client/src/shared/constants/order.ts create mode 100644 server/src/lib/order-status.js create mode 100644 server/src/routes/api/_product-helpers.js create mode 100644 server/src/routes/api/admin-categories.js create mode 100644 server/src/routes/api/admin-orders.js create mode 100644 server/src/routes/api/admin-products.js create mode 100644 server/src/routes/api/admin-reviews.js create mode 100644 server/src/routes/api/admin-users.js create mode 100644 server/src/routes/api/public-catalog.js create mode 100644 server/src/routes/api/public-reviews.js diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index 644267a..f0fd060 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -1,29 +1,23 @@ +import type { ReactNode } 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 CardContent from '@mui/material/CardContent' import CardMedia from '@mui/material/CardMedia' import Chip from '@mui/material/Chip' -import Box from '@mui/material/Box' import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' 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 { useUnit } from 'effector-react' -import { addToCart } from '@/entities/cart/api/cart-api' +import { Swiper, SwiperSlide } from 'swiper/react' +import 'swiper/css' import type { Product } from '@/entities/product/model/types' 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) { - const qc = useQueryClient() - const user = useUnit($user) +export function ProductCard({ product, mediaHeight = 200, actions }: Props) { const swiperRef = useRef(null) const imageUrls = useMemo(() => { const fromImages = (product.images ?? []) @@ -46,11 +40,6 @@ export function ProductCard({ product, mediaHeight = 200 }: Props) { swiperRef.current.slideTo(idx, 0) } - const addMut = useMutation({ - mutationFn: () => addToCart({ productId: product.id, qty: 1 }), - onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }), - }) - return ( {formatPriceRub(product.priceCents)} - + {actions} diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx index df9840c..3bebe61 100644 --- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import CircularProgress from '@mui/material/CircularProgress' @@ -10,11 +11,8 @@ import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' -import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined' import * as maplibregl from 'maplibre-gl' -import Map, { Marker } from 'react-map-gl/maplibre' -import type { MapMouseEvent } from 'react-map-gl/maplibre' -import type { MapRef } from 'react-map-gl/maplibre' +import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre' type NominatimItem = { display_name: string; lat: string; lon: string } diff --git a/client/src/features/cart/add-to-cart/index.ts b/client/src/features/cart/add-to-cart/index.ts new file mode 100644 index 0000000..6c055a5 --- /dev/null +++ b/client/src/features/cart/add-to-cart/index.ts @@ -0,0 +1 @@ +export { AddToCartButton } from './ui/AddToCartButton' diff --git a/client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx b/client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx new file mode 100644 index 0000000..15de055 --- /dev/null +++ b/client/src/features/cart/add-to-cart/ui/AddToCartButton.tsx @@ -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 + +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 ( + + ) +} diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index 979e385..dc512fc 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -27,6 +27,7 @@ import { postAdminOrderMessage, setAdminOrderStatus, } 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 { formatPriceRub } from '@/shared/lib/format-price' @@ -98,14 +99,7 @@ export function AdminOrdersPage() { const nextStatuses = useMemo(() => { const s = detail?.status if (!s) return [] - const map: Record = { - DRAFT: ['PENDING_PAYMENT', 'CANCELLED'], - PENDING_PAYMENT: ['PAID', 'CANCELLED'], - PAID: ['IN_PROGRESS', 'CANCELLED'], - IN_PROGRESS: ['SHIPPED', 'CANCELLED'], - SHIPPED: ['DONE'], - } - return map[s] ?? [] + return ORDER_STATUS_TRANSITIONS[s as OrderStatus] ?? [] }, [detail?.status]) return ( @@ -168,7 +162,7 @@ export function AdminOrdersPage() { Все - {['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'DONE', 'CANCELLED'].map((s) => ( + {Object.keys(ORDER_STATUS_TRANSITIONS).map((s) => ( {s} diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 05c4f07..7a6e126 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -7,8 +7,8 @@ import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' -import { useForm } from 'react-hook-form' import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { apiClient } from '@/shared/api/client' import { $user, tokenSet } from '@/shared/model/auth' diff --git a/client/src/pages/cart/ui/CartPage.tsx b/client/src/pages/cart/ui/CartPage.tsx index e0bf381..4d056be 100644 --- a/client/src/pages/cart/ui/CartPage.tsx +++ b/client/src/pages/cart/ui/CartPage.tsx @@ -12,8 +12,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { Link as RouterLink } from 'react-router-dom' import { fetchMyCart, removeCartItem, setCartQty } from '@/entities/cart/api/cart-api' -import { $user } from '@/shared/model/auth' import { formatPriceRub } from '@/shared/lib/format-price' +import { $user } from '@/shared/model/auth' export function CartPage() { const user = useUnit($user) diff --git a/client/src/pages/checkout/ui/CheckoutPage.tsx b/client/src/pages/checkout/ui/CheckoutPage.tsx index 29838e9..bce14b9 100644 --- a/client/src/pages/checkout/ui/CheckoutPage.tsx +++ b/client/src/pages/checkout/ui/CheckoutPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -9,9 +10,8 @@ import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' 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 { Link as RouterLink, useNavigate } from 'react-router-dom' import { fetchMyCart } from '@/entities/cart/api/cart-api' import { createOrder } from '@/entities/order/api/order-api' import { fetchMyAddresses } from '@/entities/user/api/address-api' diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx index 56e2d8b..4c79ad8 100644 --- a/client/src/pages/home/ui/HomePage.tsx +++ b/client/src/pages/home/ui/HomePage.tsx @@ -21,6 +21,7 @@ import Typography from '@mui/material/Typography' import { useQuery } from '@tanstack/react-query' import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api' import { ProductCard } from '@/entities/product/ui/ProductCard' +import { AddToCartButton } from '@/features/cart/add-to-cart' export function HomePage() { const [categorySlug, setCategorySlug] = useState('') @@ -315,7 +316,11 @@ export function HomePage() { {products.map((p) => ( - + } + /> ))} diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index f5cb979..69eff69 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -1,3 +1,4 @@ +import { useMemo, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -6,7 +7,6 @@ import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useMemo, useState } from 'react' import { Link as RouterLink, useParams } from 'react-router-dom' import { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api' import { formatPriceRub } from '@/shared/lib/format-price' diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index 6d9c0dc..25f6e9f 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -2,28 +2,23 @@ import { useMemo, useState } from 'react' import CloseIcon from '@mui/icons-material/Close' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' -import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' import Dialog from '@mui/material/Dialog' import IconButton from '@mui/material/IconButton' import Skeleton from '@mui/material/Skeleton' 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 { Swiper, SwiperSlide } from 'swiper/react' import { Navigation } from 'swiper/modules' +import { Swiper, SwiperSlide } from 'swiper/react' import 'swiper/css' 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 { AddToCartButton } from '@/features/cart/add-to-cart' import { formatPriceRub } from '@/shared/lib/format-price' -import { $user } from '@/shared/model/auth' export function ProductPage() { const { id } = useParams() - const qc = useQueryClient() - const user = useUnit($user) const [viewerOpen, setViewerOpen] = useState(false) const [viewerIndex, setViewerIndex] = useState(0) @@ -33,15 +28,6 @@ export function ProductPage() { 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 p = productQuery.data if (!p) return [] @@ -151,14 +137,7 @@ export function ProductPage() { {formatPriceRub(p.priceCents)} - + {p.description ? ( {p.description} diff --git a/client/src/shared/constants/order.ts b/client/src/shared/constants/order.ts new file mode 100644 index 0000000..d8fc61e --- /dev/null +++ b/client/src/shared/constants/order.ts @@ -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 = { + 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 +} diff --git a/server/src/lib/order-status.js b/server/src/lib/order-status.js new file mode 100644 index 0000000..2692efb --- /dev/null +++ b/server/src/lib/order-status.js @@ -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)) +} + diff --git a/server/src/routes/api.js b/server/src/routes/api.js index b8c1a66..24b828a 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,861 +1,30 @@ -import { prisma } from '../lib/prisma.js' -import crypto from 'node:crypto' -import fs from 'node:fs' -import path from 'node:path' -import { hashPassword, normalizeEmail } from '../lib/auth.js' - -function slugify(input) { - return input - .toLowerCase() - .trim() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-а-яё]/gi, '') -} - -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), - } -} +import { + mapProductForApi, + parseMaterialsInput, + safeExtFromFilename, + slugify, +} from './api/_product-helpers.js' +import { registerAdminCategoryRoutes } from './api/admin-categories.js' +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 { registerPublicCatalogRoutes } from './api/public-catalog.js' +import { registerPublicReviewRoutes } from './api/public-reviews.js' export async function registerApiRoutes(fastify) { - fastify.get('/api/categories', async () => { - return prisma.category.findMany({ orderBy: { sort: 'asc' } }) + await registerPublicCatalogRoutes(fastify, { mapProductForApi }) + await registerPublicReviewRoutes(fastify) + + await registerAdminProductRoutes(fastify, { + slugify, + safeExtFromFilename, + parseMaterialsInput, + mapProductForApi, }) - - 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) - }) - - // ---- Отзывы к товарам ---- - - 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: 'Пользователь не найден' }) - } - }, - ) + await registerAdminCategoryRoutes(fastify, { slugify }) + await registerAdminOrderRoutes(fastify) + await registerAdminReviewRoutes(fastify) + await registerAdminUserRoutes(fastify) } + diff --git a/server/src/routes/api/_product-helpers.js b/server/src/routes/api/_product-helpers.js new file mode 100644 index 0000000..83fb734 --- /dev/null +++ b/server/src/routes/api/_product-helpers.js @@ -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), + } +} + diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js new file mode 100644 index 0000000..997160e --- /dev/null +++ b/server/src/routes/api/admin-categories.js @@ -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) + }, + ) +} + diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js new file mode 100644 index 0000000..d975117 --- /dev/null +++ b/server/src/routes/api/admin-orders.js @@ -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 }) + }, + ) +} + diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js new file mode 100644 index 0000000..b42483e --- /dev/null +++ b/server/src/routes/api/admin-products.js @@ -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: 'Товар не найден' }) + } + }, + ) +} + diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js new file mode 100644 index 0000000..8b7548f --- /dev/null +++ b/server/src/routes/api/admin-reviews.js @@ -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 } + }, + ) +} + diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js new file mode 100644 index 0000000..bbdae79 --- /dev/null +++ b/server/src/routes/api/admin-users.js @@ -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: 'Пользователь не найден' }) + } + }, + ) +} + diff --git a/server/src/routes/api/public-catalog.js b/server/src/routes/api/public-catalog.js new file mode 100644 index 0000000..16ef720 --- /dev/null +++ b/server/src/routes/api/public-catalog.js @@ -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) + }) +} + diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js new file mode 100644 index 0000000..c6b3414 --- /dev/null +++ b/server/src/routes/api/public-reviews.js @@ -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: 'Вы уже оставляли отзыв на этот товар' }) + } + }, + ) +} +