diff --git a/client/src/app/routes/index.tsx b/client/src/app/routes/index.tsx index a2b1a20..5e9177e 100644 --- a/client/src/app/routes/index.tsx +++ b/client/src/app/routes/index.tsx @@ -2,13 +2,11 @@ import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AboutPage } from '@/pages/about' -// import { AdminLayoutPage } from '@/pages/admin-layout' import { AuthCallbackPage, AuthPage } from '@/pages/auth' import { CartPage } from '@/pages/cart' import { CheckoutPage } from '@/pages/checkout' import { HomePage } from '@/pages/home' import { InfoPage } from '@/pages/info' -// import { MeLayoutPage } from '@/pages/me' import { NotFoundPage } from '@/pages/not-found' import { PrivacyPolicyPage } from '@/pages/privacy-policy' import { ProductPage } from '@/pages/product' diff --git a/client/src/entities/cart/index.ts b/client/src/entities/cart/index.ts new file mode 100644 index 0000000..6870839 --- /dev/null +++ b/client/src/entities/cart/index.ts @@ -0,0 +1,3 @@ +export type { CartItem } from './model/types' +export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api' +export type { CartResponse } from './api/cart-api' diff --git a/client/src/entities/notification/index.ts b/client/src/entities/notification/index.ts new file mode 100644 index 0000000..2394f9c --- /dev/null +++ b/client/src/entities/notification/index.ts @@ -0,0 +1,7 @@ +export { + fetchUserNotificationSettings, + updateUserNotificationSettings, + fetchAdminNotificationSettings, + updateAdminNotificationSettings, +} from './api/notifications-api' +export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api' diff --git a/client/src/entities/order/index.ts b/client/src/entities/order/index.ts new file mode 100644 index 0000000..50d153d --- /dev/null +++ b/client/src/entities/order/index.ts @@ -0,0 +1,9 @@ +export { + fetchMyOrders, + createOrder, + confirmOrderReceived, + fetchMyOrder, + fetchOrderReviewEligibility, +} from './api/order-api' +export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api' +export type { OrderListResponse, OrderDetailResponse } from './api/order-api' diff --git a/client/src/entities/product/index.ts b/client/src/entities/product/index.ts new file mode 100644 index 0000000..98bfb2e --- /dev/null +++ b/client/src/entities/product/index.ts @@ -0,0 +1,2 @@ +export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api' +export type { PublicProductsResponse } from './api/product-api' diff --git a/client/src/entities/review/index.ts b/client/src/entities/review/index.ts new file mode 100644 index 0000000..48f31fd --- /dev/null +++ b/client/src/entities/review/index.ts @@ -0,0 +1,12 @@ +export { + postProductReview, + uploadReviewImage, + fetchLatestApprovedReviews, + fetchPublicProductReviews, +} from './api/reviews-api' +export type { + PublicReviewFeedItem, + PublicReviewsLatestResponse, + PublicProductReviewItem, + PublicProductReviewsResponse, +} from './api/reviews-api' diff --git a/client/src/entities/user/index.ts b/client/src/entities/user/index.ts new file mode 100644 index 0000000..cfb7e7f --- /dev/null +++ b/client/src/entities/user/index.ts @@ -0,0 +1,10 @@ +export type { AdminUser, ShippingAddress } from './model/types' +export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api' +export type { AdminUsersListResponse } from './api/user-api' +export { + fetchMyAddresses, + createMyAddress, + updateMyAddress, + deleteMyAddress, + setMyAddressDefault, +} from './api/address-api' diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 69333d8..90c5cb1 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -60,7 +60,7 @@ export function AdminLayoutPage() { { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, - { to: '/admin/notifications', label: 'Оповещения', icon: }, + { to: '/admin/notifications', label: 'Уведомления', icon: }, ], [], ) diff --git a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx index 3508376..71645b2 100644 --- a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx @@ -47,10 +47,10 @@ export function AdminNotificationsPage() { return ( - Оповещения + Уведомления - Настройка оповещений администратора. + Настройка уведомлений администратора. {error && ( diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 0c24ad1..385a927 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -57,7 +57,7 @@ export function MeLayoutPage() { { to: '/me/messages', label: 'Сообщения', icon: }, { to: '/me/settings', label: 'Настройки', icon: }, { to: '/me/addresses', label: 'Адреса доставки', icon: }, - { to: '/me/notifications', label: 'Оповещения', icon: }, + { to: '/me/notifications', label: 'Уведомления', icon: }, ], [], ) diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx deleted file mode 100644 index 107da67..0000000 --- a/client/src/pages/me/ui/MePage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import Alert from '@mui/material/Alert' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' -import Stack from '@mui/material/Stack' -import TextField from '@mui/material/TextField' -import Typography from '@mui/material/Typography' -import { useUnit } from 'effector-react' -import { useForm } from 'react-hook-form' -import { - $requestEmailChangeCodeError, - $updateProfileError, - $user, - $verifyEmailChangeError, - requestEmailChangeCodeFx, - updateProfileFx, - verifyEmailChangeFx, -} from '@/shared/model/auth' -import type { AxiosError } from 'axios' - -function getApiErrorMessage(error: unknown): string | null { - const e = error as AxiosError<{ error?: string }> - const msg = e?.response?.data?.error - return msg ? String(msg) : null -} - -export function MePage() { - const user = useUnit($user) - const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) - const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) - const pendingProfile = useUnit(updateProfileFx.pending) - const errorEmailReq = useUnit($requestEmailChangeCodeError) - const errorProfile = useUnit($updateProfileError) - const errorEmailVerify = useUnit($verifyEmailChangeError) - - const emailForm = useForm<{ newEmail: string; code: string }>({ - defaultValues: { newEmail: '', code: '' }, - mode: 'onChange', - }) - - const profileForm = useForm<{ displayName: string }>({ - defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, - mode: 'onChange', - }) - - const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) - const profileErrorMsg = getApiErrorMessage(errorProfile) - - if (!user) { - return Нужно войти. Перейдите на страницу «Вход». - } - - return ( - - - Профиль - - - Текущая почта: {user.email} - - - {emailErrorMsg && ( - - {emailErrorMsg} - - )} - {profileErrorMsg && ( - - {profileErrorMsg} - - )} - - - - - Имя / ник - - - - - - - - - - - - Смена почты - - - - - - - - - - - - - ) -} diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index 85524c3..f1b07be 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -57,7 +57,7 @@ export function NotificationsPage() { return ( - Оповещения + Уведомления Настройте, какие уведомления вы хотите получать на почту. @@ -78,7 +78,7 @@ export function NotificationsPage() { onChange={(e) => handleToggle('globalEnabled', e.target.checked)} /> } - label={Получать оповещения} + label={Получать уведомления} /> Включите, чтобы получать уведомления о заказах на почту. diff --git a/client/src/pages/terms/ui/TermsPage.tsx b/client/src/pages/terms/ui/TermsPage.tsx index e5340bf..7699ea3 100644 --- a/client/src/pages/terms/ui/TermsPage.tsx +++ b/client/src/pages/terms/ui/TermsPage.tsx @@ -1,7 +1,7 @@ import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import { STORE_EMAIL, STORE_PUBLIC_SITE_URL } from '@/shared/config' +import { STORE_EMAIL, STORE_PHONE, STORE_PUBLIC_SITE_URL } from '@/shared/config' const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') @@ -138,8 +138,8 @@ const sections = [ `ИНН: ${OP_INN}`, `ОГРН: ${OP_OGRN}`, `Адрес: ${OP_ADDR}`, - `Телефон: +7 (900) 000-00-00`, // TODO: заменить на реальный номер телефона - `Email: ${STORE_EMAIL}`, // TODO: заменить на реальный email при настройке STORE_EMAIL + `Телефон: ${STORE_PHONE}`, + `Email: ${STORE_EMAIL}`, ], }, ] diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 8a10718..01bce42 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/routes/api/admin-categories.js b/server/src/routes/api/admin-categories.js index f252cbd..e33bc91 100644 --- a/server/src/routes/api/admin-categories.js +++ b/server/src/routes/api/admin-categories.js @@ -6,116 +6,136 @@ import { import { prisma } from '../../lib/prisma.js' export async function registerAdminCategoryRoutes(fastify) { - fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async () => { - const items = await prisma.category.findMany({ - orderBy: [{ sort: 'asc' }, { name: 'asc' }], - }) - return { items } + fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const items = await prisma.category.findMany({ + orderBy: [{ sort: 'asc' }, { name: 'asc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).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 + try { + const body = request.body ?? {} + const name = String(body.name ?? '').trim() + if (!name) { + reply.code(400).send({ error: 'Укажите название категории' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` + if (isUnspecifiedCategorySlug(slug)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + 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) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать категорию' }) } - const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` - if (isUnspecifiedCategorySlug(slug)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) - return - } - 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) }) fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } + try { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } - const data = {} - if (body.name !== undefined) data.name = String(body.name ?? '').trim() - if (body.sort !== undefined) { - const s = Number(body.sort) - if (!Number.isFinite(s)) { - reply.code(400).send({ error: 'Некорректный sort' }) - return - } - data.sort = Math.round(s) - } - if (body.slug !== undefined) { - const s = String(body.slug ?? '').trim() - if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { - reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) - return - } - if (!s) { - reply.code(400).send({ error: 'Slug не может быть пустым' }) - return - } - if (s !== existing.slug) { - if (isUnspecifiedCategorySlug(s)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + const data = {} + if (body.name !== undefined) data.name = String(body.name ?? '').trim() + if (body.sort !== undefined) { + const s = Number(body.sort) + if (!Number.isFinite(s)) { + reply.code(400).send({ error: 'Некорректный sort' }) return } - const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) - if (clash) { - reply.code(409).send({ error: 'Такой slug уже занят' }) + data.sort = Math.round(s) + } + if (body.slug !== undefined) { + const s = String(body.slug ?? '').trim() + if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { + reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) return } + if (!s) { + reply.code(400).send({ error: 'Slug не может быть пустым' }) + return + } + if (s !== existing.slug) { + if (isUnspecifiedCategorySlug(s)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + } + data.slug = s } - data.slug = s - } - if (Object.keys(data).length === 0) { - return existing - } - if (data.name !== undefined && !data.name) { - reply.code(400).send({ error: 'Укажите название' }) - return - } + if (Object.keys(data).length === 0) { + return existing + } + if (data.name !== undefined && !data.name) { + reply.code(400).send({ error: 'Укажите название' }) + return + } - const updated = await prisma.category.update({ where: { id }, data }) - return updated + const updated = await prisma.category.update({ where: { id }, data }) + return updated + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить категорию' }) + } }) fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } - if (isUnspecifiedCategorySlug(existing.slug)) { - reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) - return - } + try { + const { id } = request.params + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + if (isUnspecifiedCategorySlug(existing.slug)) { + reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) + return + } - const fallback = await getOrCreateUnspecifiedCategory() - await prisma.$transaction([ - prisma.product.updateMany({ - where: { categoryId: id }, - data: { categoryId: fallback.id }, - }), - prisma.category.delete({ where: { id } }), - ]) - return reply.code(204).send() + const fallback = await getOrCreateUnspecifiedCategory() + await prisma.$transaction([ + prisma.product.updateMany({ + where: { categoryId: id }, + data: { categoryId: fallback.id }, + }), + prisma.category.delete({ where: { id } }), + ]) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить категорию' }) + } }) } diff --git a/server/src/routes/api/catalog-slider.js b/server/src/routes/api/catalog-slider.js index 4e494c6..76c5e61 100644 --- a/server/src/routes/api/catalog-slider.js +++ b/server/src/routes/api/catalog-slider.js @@ -3,89 +3,104 @@ import { prisma } from '../../lib/prisma.js' const MAX_SLIDES = 20 export async function registerCatalogSliderRoutes(fastify) { - fastify.get('/api/catalog-slider', async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/catalog-slider', async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) - fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const body = request.body ?? {} - const rawSlides = body.slides - if (!Array.isArray(rawSlides)) { - return reply.code(400).send({ error: 'Ожидается slides: массив' }) - } - if (rawSlides.length > MAX_SLIDES) { - return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) - } + try { + const body = request.body ?? {} + const rawSlides = body.slides + if (!Array.isArray(rawSlides)) { + return reply.code(400).send({ error: 'Ожидается slides: массив' }) + } + if (rawSlides.length > MAX_SLIDES) { + return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) + } - const seenGalleryIds = new Set() - const normalized = [] - for (let i = 0; i < rawSlides.length; i++) { - const row = rawSlides[i] - const galleryImageId = String(row?.galleryImageId ?? '').trim() - if (!galleryImageId) { - return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + const seenGalleryIds = new Set() + const normalized = [] + for (let i = 0; i < rawSlides.length; i++) { + const row = rawSlides[i] + const galleryImageId = String(row?.galleryImageId ?? '').trim() + if (!galleryImageId) { + return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + } + if (seenGalleryIds.has(galleryImageId)) { + return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) + } + seenGalleryIds.add(galleryImageId) + const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) + if (!img) { + return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) + } + const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) + normalized.push({ galleryImageId, caption, sortOrder: i }) } - if (seenGalleryIds.has(galleryImageId)) { - return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) - } - seenGalleryIds.add(galleryImageId) - const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) - if (!img) { - return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) - } - const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) - normalized.push({ galleryImageId, caption, sortOrder: i }) - } - await prisma.$transaction(async (tx) => { - await tx.catalogSliderSlide.deleteMany({}) - for (const n of normalized) { - await tx.catalogSliderSlide.create({ - data: { - sortOrder: n.sortOrder, - caption: n.caption, - galleryImageId: n.galleryImageId, - }, - }) - } - }) + await prisma.$transaction(async (tx) => { + await tx.catalogSliderSlide.deleteMany({}) + for (const n of normalized) { + await tx.catalogSliderSlide.create({ + data: { + sortOrder: n.sortOrder, + caption: n.caption, + galleryImageId: n.galleryImageId, + }, + }) + } + }) - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить слайдер' }) } }) } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 3e25fe5..de8773f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -123,8 +123,7 @@ export async function registerAuthRoutes(fastify) { const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() const avatarTypeRaw = request.body?.avatarType - const avatarType = - avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() + const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js index 2440907..54d3659 100644 --- a/server/src/routes/user-addresses.js +++ b/server/src/routes/user-addresses.js @@ -45,133 +45,159 @@ function validateAddressPayload(body, reply) { } export async function registerUserAddressRoutes(fastify) { - fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const items = await prisma.shippingAddress.findMany({ - where: { userId }, - orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], - }) - return { items } + fastify.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить адреса' }) + } }) fastify.post('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const validated = validateAddressPayload(request.body, reply) - if (!validated) return + try { + const userId = request.user.sub + const validated = validateAddressPayload(request.body, reply) + if (!validated) return - const isDefault = Boolean(request.body?.isDefault) - const created = await prisma.$transaction(async (tx) => { - if (isDefault) { - await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) - } - return tx.shippingAddress.create({ - data: { - userId, - ...validated, - isDefault, - }, + const isDefault = Boolean(request.body?.isDefault) + const created = await prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.create({ + data: { + userId, + ...validated, + isDefault, + }, + }) }) - }) - return reply.code(201).send({ item: created }) + return reply.code(201).send({ item: created }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать адрес' }) + } }) fastify.patch('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + try { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - const body = request.body ?? {} - const data = {} + const body = request.body ?? {} + const data = {} - if (body.label !== undefined) { - const labelRaw = body.label - const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() - if (label !== null && label.length > 40) - return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) - data.label = label && label.length ? label : null - } - - if (body.recipientName !== undefined) { - const v = String(body.recipientName || '').trim() - if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) - if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) - data.recipientName = v - } - - if (body.recipientPhone !== undefined) { - const v = normalizePhoneLite(body.recipientPhone) - if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) - if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) - data.recipientPhone = v - } - - if (body.addressLine !== undefined) { - const v = String(body.addressLine || '').trim() - if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) - if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) - data.addressLine = v - } - - if (body.comment !== undefined) { - const commentRaw = body.comment - const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() - if (comment !== null && comment.length > 200) - return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) - data.comment = comment && comment.length ? comment : null - } - - if (body.lat !== undefined) { - const lat = Number(body.lat) - if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) - data.lat = lat - } - - if (body.lng !== undefined) { - const lng = Number(body.lng) - if (!Number.isFinite(lng) || lng < -180 || lng > 180) - return reply.code(400).send({ error: 'Некорректная долгота' }) - data.lng = lng - } - - const setDefault = body.isDefault === true - const updated = await prisma.$transaction(async (tx) => { - if (setDefault) { - await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + if (body.label !== undefined) { + const labelRaw = body.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) + return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + data.label = label && label.length ? label : null } - return tx.shippingAddress.update({ - where: { id }, - data: { - ...data, - ...(setDefault ? { isDefault: true } : {}), - }, - }) - }) - return { item: updated } + if (body.recipientName !== undefined) { + const v = String(body.recipientName || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + data.recipientName = v + } + + if (body.recipientPhone !== undefined) { + const v = normalizePhoneLite(body.recipientPhone) + if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + data.recipientPhone = v + } + + if (body.addressLine !== undefined) { + const v = String(body.addressLine || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) + if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + data.addressLine = v + } + + if (body.comment !== undefined) { + const commentRaw = body.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) + return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + data.comment = comment && comment.length ? comment : null + } + + if (body.lat !== undefined) { + const lat = Number(body.lat) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) + return reply.code(400).send({ error: 'Некорректная широта' }) + data.lat = lat + } + + if (body.lng !== undefined) { + const lng = Number(body.lng) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) + return reply.code(400).send({ error: 'Некорректная долгота' }) + data.lng = lng + } + + const setDefault = body.isDefault === true + const updated = await prisma.$transaction(async (tx) => { + if (setDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.update({ + where: { id }, + data: { + ...data, + ...(setDefault ? { isDefault: true } : {}), + }, + }) + }) + + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить адрес' }) + } }) fastify.delete('/api/me/addresses/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + try { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - await prisma.shippingAddress.delete({ where: { id } }) - return reply.code(204).send() + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить адрес' }) + } }) fastify.post('/api/me/addresses/:id/default', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + try { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) - const updated = await prisma.$transaction(async (tx) => { - await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) - return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) - }) + const updated = await prisma.$transaction(async (tx) => { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) + }) - return { item: updated } + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' }) + } }) } diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index 136453b..8a5ba0e 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -1,76 +1,96 @@ import { prisma } from '../lib/prisma.js' export async function registerUserCartRoutes(fastify) { - fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const items = await prisma.cartItem.findMany({ - where: { userId }, - include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, - orderBy: { createdAt: 'asc' }, - }) - return { - items: items.map((x) => ({ - id: x.id, - qty: x.qty, - product: x.product, - })), + fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const items = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, + orderBy: { createdAt: 'asc' }, + }) + return { + items: items.map((x) => ({ + id: x.id, + qty: x.qty, + product: x.product, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить корзину' }) } }) fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const productId = String(request.body?.productId || '').trim() - const qtyRaw = request.body?.qty - const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) + try { + const userId = request.user.sub + const productId = String(request.body?.productId || '').trim() + const qtyRaw = request.body?.qty + const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) - if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) - if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) + if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) + if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) - const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) - if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.quantity - const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) - const nextQty = (existing?.qty ?? 0) + Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + const available = product.quantity + const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) + const nextQty = (existing?.qty ?? 0) + Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - const item = await prisma.cartItem.upsert({ - where: { userId_productId: { userId, productId } }, - update: { qty: nextQty }, - create: { userId, productId, qty: nextQty }, - }) - return reply.code(201).send({ item }) + const item = await prisma.cartItem.upsert({ + where: { userId_productId: { userId, productId } }, + update: { qty: nextQty }, + create: { userId, productId, qty: nextQty }, + }) + return reply.code(201).send({ item }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось добавить в корзину' }) + } }) fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const qtyRaw = request.body?.qty - const qty = Number(qtyRaw) - if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) + try { + const userId = request.user.sub + const { id } = request.params + const qtyRaw = request.body?.qty + const qty = Number(qtyRaw) + if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) - const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) - if (qty === 0) { - await prisma.cartItem.delete({ where: { id } }) - return reply.code(204).send() + if (qty === 0) { + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } + + const available = existing.product.quantity + const nextQty = Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + + const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить количество' }) } - - const available = existing.product.quantity - const nextQty = Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - - const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) - return { item: updated } }) fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) - await prisma.cartItem.delete({ where: { id } }) - return reply.code(204).send() + try { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить из корзины' }) + } }) } diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index 6812f33..cd446ad 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -44,22 +44,21 @@ export async function registerUserMessageRoutes(fastify) { }) if (orders.length === 0) return { count: 0 } + const orderIds = orders.map((o) => o.id) const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId }, }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + let count = 0 - for (const o of orders) { - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) - const n = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: 'admin', - createdAt: { gt: lastRead }, - }, - }) - count += n + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) count++ } return { count } }) @@ -86,25 +85,32 @@ export async function registerUserMessageRoutes(fastify) { }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const orderIds = orders.map((o) => o.id) + const unreadCounts = new Map() + if (orderIds.length > 0) { + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) { + unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1) + } + } + } + const items = [] for (const o of orders) { const lastMsg = o.messages[0] if (!lastMsg) continue - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) - const unreadCount = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: 'admin', - createdAt: { gt: lastRead }, - }, - }) items.push({ orderId: o.id, status: o.status, deliveryType: o.deliveryType, lastMessageAt: lastMsg.createdAt, preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text, - unreadCount, + unreadCount: unreadCounts.get(o.id) ?? 0, }) } return { items } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 8ced32d..6916bb1 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -176,35 +176,45 @@ export async function registerUserOrderRoutes(fastify) { return reply.code(201).send({ orderId: created.id }) }) - fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const orders = await prisma.order.findMany({ - where: { userId }, - include: { items: true }, - orderBy: { createdAt: 'desc' }, - }) - return { - items: orders.map((o) => ({ - id: o.id, - status: o.status, - totalCents: o.totalCents, - currency: o.currency, - createdAt: o.createdAt, - updatedAt: o.updatedAt, - itemsCount: o.items.reduce((s, i) => s + i.qty, 0), - })), + fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: { select: { qty: true } } }, + orderBy: { createdAt: 'desc' }, + }) + return { + items: orders.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказы' }) } }) fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ - where: { id, userId }, - include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, - }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - return { item: order } + try { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказ' }) + } }) fastify.get( @@ -251,19 +261,24 @@ export async function registerUserOrderRoutes(fastify) { '/api/me/orders/:id/confirm-received', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ where: { id, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + try { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' - const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' - if (!okDelivery && !okPickup) { - return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) + const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' + const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' + if (!okDelivery && !okPickup) { + return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) + } + + await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) + return { ok: true, status: 'DONE' } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось подтвердить получение' }) } - - await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) - return { ok: true, status: 'DONE' } }, ) }