diff --git a/actions/get-total-revenue.ts b/actions/get-total-revenue.ts index c8e59e4..2181783 100644 --- a/actions/get-total-revenue.ts +++ b/actions/get-total-revenue.ts @@ -1,25 +1,55 @@ +import { cache } from 'react'; +import { z } from 'zod'; + import prismadb from '@/lib/prismadb'; -export const getTotalRevenue = async (storeId: string) => { - const paidOrders = await prismadb.order.findMany({ - where: { - storeId, - isPaid: true, - }, - include: { - orderItems: { - include: { - product: true, +const storeIdSchema = z.string().uuid('Неверный формат идентификатора магазина'); + +interface PrismaOrder { + orderItems: { + product: { + price: number; + }; + }[]; +} + +// Кэшированная функция получения выручки +export const getTotalRevenue = cache(async (storeId: string) => { + try { + const validationResult = storeIdSchema.safeParse(storeId); + + if (!validationResult.success) { + throw new Error('Неверный идентификатор магазина'); + } + + const paidOrders = await prismadb.order.findMany({ + where: { + storeId: validationResult.data, + isPaid: true, + }, + select: { + orderItems: { + select: { + product: { + select: { + price: true, + }, + }, + }, }, }, - }, - }); + }); - return paidOrders.reduce((total: any, order: any) => { - const orderTotal = order.orderItems.reduce( - (orderSum: any, item: any) => orderSum + item.product.price.toNumber(), - 0, - ); - return total + orderTotal; - }, 0); -}; + return paidOrders.reduce((total: number, order: PrismaOrder) => { + const orderTotal = order.orderItems.reduce( + (orderSum: number, item) => + orderSum + item.product.price, + 0, + ); + return total + orderTotal; + }, 0); + } catch (error) { + console.error('[GET_TOTAL_REVENUE]', error); + return 0; + } +}); diff --git a/app/(root)/(routes)/page.tsx b/app/(root)/(routes)/page.tsx index 9d425ea..f8f800d 100644 --- a/app/(root)/(routes)/page.tsx +++ b/app/(root)/(routes)/page.tsx @@ -1,19 +1,35 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { useStoreModal } from '@/hooks/use-store-modal'; +// Компонент настройки магазина const SetupPage = () => { - const onOpen = useStoreModal((state) => state.onOpen); - const isOpen = useStoreModal((state) => state.isOpen); + + // Используем селекторы из хука с мемоизацией + const { onOpen, isOpen } = useStoreModal( + useCallback( + (state) => ({ + onOpen: state.onOpen, + isOpen: state.isOpen, + }), + [], + ), + ); useEffect(() => { - if (!isOpen) { - onOpen(); + try { + if (!isOpen) { + onOpen(); + } + } catch (error) { + console.error('[SETUP_PAGE]', error); } }, [isOpen, onOpen]); - return null; + // Возвращаем пустой фрагмент вместо null для лучшей производительности + return <>; }; + export default SetupPage; diff --git a/app/(root)/layout.tsx b/app/(root)/layout.tsx index 5355bd6..a8af0c3 100644 --- a/app/(root)/layout.tsx +++ b/app/(root)/layout.tsx @@ -1,24 +1,46 @@ import { auth } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; +import { cache } from 'react'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +// Кэширование запроса на получение магазина +const getStore = cache(async (userId: string) => { + return prismadb.store.findFirst({ + where: { userId }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); +}); + +// Схема валидации для userId +const userIdSchema = z.string().min(1, 'ID пользователя обязателен'); + export default async function SetupLayout({ children }: { children: React.ReactNode }) { - const { userId }: { userId: string | null } = await auth(); + try { + const { userId } = await auth(); - if (!userId) { - redirect('/sign-in'); - } + // Валидация userId + const validationResult = userIdSchema.safeParse(userId); - const store = await prismadb.store.findFirst({ - where: { - userId, - }, - }); + if (!validationResult.success) { + redirect('/sign-in'); + } - if (store) { - redirect(`/${store.id}`); - } + // Используем кэшированную функцию для получения магазина + const store = await getStore(validationResult.data); - return <>{children}; + if (store) { + redirect(`/${store.id}`); + } + + return
{children}
; + } catch (error) { + console.error('[SETUP_LAYOUT]', error); + redirect('/error'); + } } diff --git a/app/api/[storeId]/billboards/[billboardId]/route.ts b/app/api/[storeId]/billboards/[billboardId]/route.ts index 9f6a4ce..8f831bb 100644 --- a/app/api/[storeId]/billboards/[billboardId]/route.ts +++ b/app/api/[storeId]/billboards/[billboardId]/route.ts @@ -1,27 +1,148 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { cache } from 'react'; import prismadb from '@/lib/prismadb'; - -export async function GET(request: Request, props: { params: Promise<{ billboardId: string }> }) { - const params = await props.params; +import { errorResponses } from '@/lib/error-responses'; + +// Кэширование запросов на получение билбордов +const getBillboards = cache(async (storeId: string) => { + return prismadb.billboard.findMany({ + where: { storeId }, + select: { + id: true, + label: true, + imageUrl: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); +}); + +// Расширенная схема валидации для создания билборда +const billboardSchema = z.object({ + label: z.string().min(1, 'Укажите метку').max(100, 'Метка слишком длинная').trim(), + imageUrl: z + .string() + .url('Укажите корректный URL изображения') + .max(500, 'URL слишком длинный') + .refine((url) => url.startsWith('https://'), 'Разрешены только HTTPS URL'), +}); + +export async function POST(request: Request, props: { params: Promise<{ storeId: string }> }) { try { - if (!params.billboardId) { - return new NextResponse('Необходим идентификатор билборда.', { - status: 400, - }); + const params = await props.params; + const { userId } = await auth(); + + if (!userId) { + return errorResponses.unauthorized(); } - const billboard = await prismadb.billboard.findUnique({ - where: { - id: params.billboardId, + if (!params.storeId) { + return errorResponses.badRequest('Необходим идентификатор магазина'); + } + + const body = await request.json(); + const validationResult = billboardSchema.safeParse(body); + + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); + } + + // Проверка на лимит билбордов для магазина + const billboardCount = await prismadb.billboard.count({ + where: { storeId: params.storeId }, + }); + + if (billboardCount >= 50) { + return errorResponses.badRequest('Достигнут лимит билбордов для магазина'); + } + + // Используем транзакцию для проверки прав и создания билборда + const billboard = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); + + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } + + // Проверка на дубликаты + const existingBillboard = await tx.billboard.findFirst({ + where: { + storeId: params.storeId, + label: validationResult.data.label, + }, + select: { id: true }, + }); + + if (existingBillboard) { + throw new Error('Билборд с такой меткой уже существует'); + } + + return tx.billboard.create({ + data: { + ...validationResult.data, + storeId: params.storeId, + }, + select: { + id: true, + label: true, + imageUrl: true, + createdAt: true, + }, + }); + }); + + return NextResponse.json(billboard, { + status: 201, + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', }, }); + } catch (error) { + console.error('[BILLBOARDS_POST]', error); + if (error instanceof Error) { + switch (error.message) { + case 'Не авторизованный доступ': + return errorResponses.forbidden(); + case 'Билборд с такой меткой уже существует': + return errorResponses.conflict(error.message); + default: + return errorResponses.serverError(error); + } + } + return errorResponses.serverError(error); + } +} - return NextResponse.json(billboard); +export async function GET(request: Request, props: { params: Promise<{ storeId: string }> }) { + try { + const params = await props.params; + + if (!params.storeId) { + return errorResponses.badRequest('Необходим идентификатор магазина'); + } + + // Используем кэшированную функцию для получения билбордов + const billboards = await getBillboards(params.storeId); + + return NextResponse.json(billboards, { + headers: { + 'Cache-Control': 'public, max-age=60, stale-while-revalidate=30', + }, + }); } catch (error) { - console.log('[BILLBOARD_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[BILLBOARDS_GET]', error); + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/billboards/route.ts b/app/api/[storeId]/billboards/route.ts index 286c5b7..467cab0 100644 --- a/app/api/[storeId]/billboards/route.ts +++ b/app/api/[storeId]/billboards/route.ts @@ -1,81 +1,100 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для создания билборда +const billboardSchema = z.object({ + label: z.string().min(1, 'Укажите метку'), + imageUrl: z.string().url('Укажите корректный URL изображения'), +}); export async function POST(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); - - const body = await request.json(); - - const { label, imageUrl } = body; + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } - if (!label) { - return new NextResponse('Укажите метку', { status: 400 }); + if (!params.storeId) { + return errorResponses.badRequest('Необходим идентификатор магазина'); } - if (!imageUrl) { - return new NextResponse('Необходимо URL изображения', { status: 400 }); - } + const body = await request.json(); + const validationResult = billboardSchema.safeParse(body); - if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, - }); + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + // Используем транзакцию для проверки прав и создания билборда + const billboard = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - const billboard = await prismadb.billboard.create({ - data: { - label, - imageUrl, - storeId: params.storeId, - }, + return tx.billboard.create({ + data: { + ...validationResult.data, + storeId: params.storeId, + }, + select: { + id: true, + label: true, + imageUrl: true, + createdAt: true, + }, + }); }); - return NextResponse.json(billboard); + return NextResponse.json(billboard, { status: 201 }); } catch (error) { - console.log('[BILLBOARDS_POST]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[BILLBOARDS_POST]', error); + if (error instanceof Error && error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + return errorResponses.serverError(error); } } export async function GET(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { + const params = await props.params; + if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор магазина'); } const billboards = await prismadb.billboard.findMany({ where: { storeId: params.storeId, }, + select: { + id: true, + label: true, + imageUrl: true, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, }); return NextResponse.json(billboards); } catch (error) { - console.log('[BILLBOARDS_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[BILLBOARDS_GET]', error); + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/categories/[categoryId]/route.ts b/app/api/[storeId]/categories/[categoryId]/route.ts index 93ff5e0..6c42030 100644 --- a/app/api/[storeId]/categories/[categoryId]/route.ts +++ b/app/api/[storeId]/categories/[categoryId]/route.ts @@ -1,30 +1,49 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для обновления категории +const updateCategorySchema = z.object({ + name: z.string().min(2, 'Название должно содержать минимум 2 символа'), + billboardId: z.string().uuid('Неверный формат идентификатора билборда'), +}); export async function GET(request: Request, props: { params: Promise<{ categoryId: string }> }) { - const params = await props.params; try { + const params = await props.params; + if (!params.categoryId) { - return new NextResponse('Необходим идентификатор категории.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор категории'); } const category = await prismadb.category.findUnique({ - where: { - id: params.categoryId, - }, - include: { - billboard: true, + where: { id: params.categoryId }, + select: { + id: true, + name: true, + billboard: { + select: { + id: true, + label: true, + imageUrl: true, + }, + }, + createdAt: true, + updatedAt: true, }, }); + if (!category) { + return errorResponses.notFound('Категория не найдена'); + } + return NextResponse.json(category); } catch (error) { - console.log('[CATEGORY_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[CATEGORY_GET]', error); + return errorResponses.serverError(error); } } @@ -32,43 +51,62 @@ export async function DELETE( request: Request, props: { params: Promise<{ categoryId: string; storeId: string }> }, ) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } if (!params.categoryId) { - return new NextResponse('Необходим идентификатор категории.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор категории'); } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + const category = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - const category = await prismadb.category.delete({ - where: { - id: params.categoryId, - }, + // Проверяем наличие связанных продуктов + const productsCount = await tx.product.count({ + where: { categoryId: params.categoryId }, + }); + + if (productsCount > 0) { + throw new Error('Нельзя удалить категорию с привязанными товарами'); + } + + return tx.category.delete({ + where: { id: params.categoryId }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); }); return NextResponse.json(category); } catch (error) { - console.log('[CATEGORY_DELETE]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[CATEGORY_DELETE]', error); + if (error instanceof Error) { + if (error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + if (error.message === 'Нельзя удалить категорию с привязанными товарами') { + return errorResponses.badRequest(error.message); + } + } + return errorResponses.serverError(error); } } @@ -76,60 +114,72 @@ export async function PATCH( request: Request, props: { params: Promise<{ categoryId: string; storeId: string }> }, ) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); - - const body = await request.json(); - - const { name, billboardId } = body; + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } - if (!billboardId) { - return new NextResponse('Необходим идентификатор билборда.', { - status: 400, - }); - } + const body = await request.json(); + const validationResult = updateCategorySchema.safeParse(body); - if (!name) { - return new NextResponse('Укажите название.', { status: 400 }); + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); } - if (!params.categoryId) { - return new NextResponse('Необходим идентификатор категории.', { - status: 400, + const category = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, }); - } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + // Проверяем существование билборда + const billboard = await tx.billboard.findUnique({ + where: { id: validationResult.data.billboardId }, + select: { id: true }, + }); - const category = await prismadb.category.update({ - where: { - id: params.categoryId, - }, - data: { - name, - billboardId, - }, + if (!billboard) { + throw new Error('Билборд не найден'); + } + + return tx.category.update({ + where: { id: params.categoryId }, + data: validationResult.data, + select: { + id: true, + name: true, + billboard: { + select: { + id: true, + label: true, + }, + }, + updatedAt: true, + }, + }); }); return NextResponse.json(category); } catch (error) { - console.log('[CATEGORY_PATCH]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[CATEGORY_PATCH]', error); + if (error instanceof Error) { + if (error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + if (error.message === 'Билборд не найден') { + return errorResponses.notFound(error.message); + } + } + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/categories/route.ts b/app/api/[storeId]/categories/route.ts index 1176731..9ee5e83 100644 --- a/app/api/[storeId]/categories/route.ts +++ b/app/api/[storeId]/categories/route.ts @@ -1,83 +1,121 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для создания категории +const categorySchema = z.object({ + name: z.string().min(2, 'Название должно содержать минимум 2 символа'), + billboardId: z.string().uuid('Неверный формат идентификатора билборда'), +}); export async function POST(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); - - const body = await request.json(); - - const { name, billboardId } = body; + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } - if (!name) { - return new NextResponse('Укажите название.', { status: 400 }); + if (!params.storeId) { + return errorResponses.badRequest('Необходим идентификатор магазина'); } - if (!billboardId) { - return new NextResponse('Необходим идентификатор билборда.', { - status: 400, - }); + const body = await request.json(); + const validationResult = categorySchema.safeParse(body); + + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); } - if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, + // Используем транзакцию для проверки прав и создания категории + const category = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, }); - } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + // Проверяем существование билборда + const billboard = await tx.billboard.findUnique({ + where: { id: validationResult.data.billboardId }, + select: { id: true }, + }); - const category = await prismadb.category.create({ - data: { - name, - billboardId, - storeId: params.storeId, - }, + if (!billboard) { + throw new Error('Билборд не найден'); + } + + return tx.category.create({ + data: { + name: validationResult.data.name, + billboardId: validationResult.data.billboardId, + storeId: params.storeId, + }, + select: { + id: true, + name: true, + billboardId: true, + createdAt: true, + }, + }); }); - return NextResponse.json(category); + return NextResponse.json(category, { status: 201 }); } catch (error) { - console.log('[CATEGORIES_POST]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[CATEGORIES_POST]', error); + if (error instanceof Error) { + if (error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + if (error.message === 'Билборд не найден') { + return errorResponses.notFound('Указанный билборд не найден'); + } + } + return errorResponses.serverError(error); } } export async function GET(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { + const params = await props.params; + if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор магазина'); } const categories = await prismadb.category.findMany({ where: { storeId: params.storeId, }, + select: { + id: true, + name: true, + billboard: { + select: { + id: true, + label: true, + }, + }, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, }); return NextResponse.json(categories); } catch (error) { - console.log('[CATEGORIES_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[CATEGORIES_GET]', error); + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/checkout/route.ts b/app/api/[storeId]/checkout/route.ts index 0bb8e11..33d0b83 100644 --- a/app/api/[storeId]/checkout/route.ts +++ b/app/api/[storeId]/checkout/route.ts @@ -1,8 +1,15 @@ import { NextResponse } from 'next/server'; import type Stripe from 'stripe'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; import { stripe } from '@/lib/stripe'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для запроса +const checkoutSchema = z.object({ + productIds: z.array(z.string().uuid()).min(1, 'Необходимо выбрать хотя бы один товар'), +}); const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -15,72 +22,92 @@ export async function OPTIONS() { } export async function POST(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; - const { productIds } = await request.json(); + try { + const params = await props.params; + const body = await request.json(); - if (!productIds || productIds.length === 0) { - return new NextResponse('Необходимы идентификаторы продуктов.', { - status: 400, - }); - } + const validationResult = checkoutSchema.safeParse(body); - const products = await prismadb.product.findMany({ - where: { - id: { - in: productIds, - }, - }, - }); + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); + } + + const { productIds } = validationResult.data; + + const { products, order } = await prismadb.$transaction(async (tx) => { + const products = await tx.product.findMany({ + where: { + id: { in: productIds }, + isArchived: false, // Проверяем, что товар не в архиве + }, + select: { + id: true, + name: true, + price: true, + storeId: true, + }, + }); + + if (products.length !== productIds.length) { + throw new Error('Некоторые товары не найдены или недоступны'); + } + + // Проверяем, что все товары из одного магазина + if (!products.every((product) => product.storeId === params.storeId)) { + throw new Error('Товары должны быть из одного магазина'); + } + + const order = await tx.order.create({ + data: { + storeId: params.storeId, + isPaid: false, + orderItems: { + create: productIds.map((productId) => ({ + product: { + connect: { id: productId }, + }, + })), + }, + }, + select: { + id: true, + }, + }); - const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = []; + return { products, order }; + }); - for (const product of products) { - lineItems.push({ + const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = products.map((product) => ({ quantity: 1, price_data: { currency: 'KZT', product_data: { name: product.name, }, - unit_amount: product.price.toNumber() * 100, + unit_amount: Math.round(product.price * 100), }, - }); - } + })); - const order = await prismadb.order.create({ - data: { - storeId: params.storeId, - isPaid: false, - orderItems: { - create: productIds.map((productId: string) => ({ - product: { - connect: { - id: productId, - }, - }, - })), + const session = await stripe.checkout.sessions.create({ + line_items: lineItems, + mode: 'payment', + billing_address_collection: 'required', + phone_number_collection: { + enabled: true, + }, + success_url: `${process.env.FRONTEND_STORE_URL}/cart?success=1`, + cancel_url: `${process.env.FRONTEND_STORE_URL}/cart?canceled=1`, + metadata: { + orderId: order.id, }, - }, - }); - - const session = await stripe.checkout.sessions.create({ - line_items: lineItems, - mode: 'payment', - billing_address_collection: 'required', - phone_number_collection: { - enabled: true, - }, - success_url: `${process.env.FRONTEND_STORE_URL}/cart?success=1`, - cancel_url: `${process.env.FRONTEND_STORE_URL}/cart?canceled=1`, - metadata: { - orderId: order.id, - }, - }); - - return NextResponse.json( - { url: session.url }, - { - headers: corsHeaders, - }, - ); + }); + + return NextResponse.json({ url: session.url }, { headers: corsHeaders }); + } catch (error) { + console.error('[CHECKOUT_POST]', error); + if (error instanceof Error) { + return errorResponses.badRequest(error.message); + } + return errorResponses.serverError(error); + } } diff --git a/app/api/[storeId]/colors/[colorId]/route.ts b/app/api/[storeId]/colors/[colorId]/route.ts index d042a7a..64b9025 100644 --- a/app/api/[storeId]/colors/[colorId]/route.ts +++ b/app/api/[storeId]/colors/[colorId]/route.ts @@ -1,27 +1,47 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для обновления цвета +const updateColorSchema = z.object({ + name: z.string().min(2, 'Название должно содержать минимум 2 символа'), + value: z + .string() + .min(4, 'Неверный формат цвета') + .max(9, 'Неверный формат цвета') + .regex(/^#/, { message: 'Цвет должен начинаться с #' }), +}); export async function GET(request: Request, props: { params: Promise<{ colorId: string }> }) { - const params = await props.params; try { + const params = await props.params; + if (!params.colorId) { - return new NextResponse('Необходим идентификатор цвета.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор цвета'); } const color = await prismadb.color.findUnique({ - where: { - id: params.colorId, + where: { id: params.colorId }, + select: { + id: true, + name: true, + value: true, + createdAt: true, + updatedAt: true, }, }); + if (!color) { + return errorResponses.notFound('Цвет не найден'); + } + return NextResponse.json(color); } catch (error) { - console.log('[COLOR_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[COLOR_GET]', error); + return errorResponses.serverError(error); } } @@ -29,43 +49,61 @@ export async function DELETE( request: Request, props: { params: Promise<{ colorId: string; storeId: string }> }, ) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } if (!params.colorId) { - return new NextResponse('Необходим идентификатор цвета.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор цвета'); } + const color = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + // Проверяем использование цвета в товарах + const productsUsingColor = await tx.product.count({ + where: { colorId: params.colorId }, + }); - const color = await prismadb.color.delete({ - where: { - id: params.colorId, - }, + if (productsUsingColor > 0) { + throw new Error('Цвет используется в товарах'); + } + + return tx.color.delete({ + where: { id: params.colorId }, + select: { + id: true, + name: true, + value: true, + }, + }); }); return NextResponse.json(color); } catch (error) { - console.log('[COLOR_DELETE]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[COLOR_DELETE]', error); + if (error instanceof Error) { + if (error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + if (error.message === 'Цвет используется в товарах') { + return errorResponses.conflict('Нельзя удалить цвет, который используется в товарах'); + } + } + return errorResponses.serverError(error); } } @@ -73,58 +111,53 @@ export async function PATCH( request: Request, props: { params: Promise<{ colorId: string; storeId: string }> }, ) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); - - const body = await request.json(); - - const { name, value } = body; + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); - } - - if (!name) { - return new NextResponse('Укажите название.', { status: 400 }); - } - - if (!value) { - return new NextResponse('Укажите значение.', { status: 400 }); + return errorResponses.unauthorized(); } if (!params.colorId) { - return new NextResponse('Необходим идентификатор цвета.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор цвета'); } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + const body = await request.json(); + const validationResult = updateColorSchema.safeParse(body); - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); } - const color = await prismadb.color.update({ - where: { - id: params.colorId, - }, - data: { - name, - value, - }, + const color = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); + + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } + + return tx.color.update({ + where: { id: params.colorId }, + data: validationResult.data, + select: { + id: true, + name: true, + value: true, + updatedAt: true, + }, + }); }); return NextResponse.json(color); } catch (error) { - console.log('[COLOR_PATCH]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[COLOR_PATCH]', error); + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/colors/route.ts b/app/api/[storeId]/colors/route.ts index 1460147..f7e20d0 100644 --- a/app/api/[storeId]/colors/route.ts +++ b/app/api/[storeId]/colors/route.ts @@ -1,81 +1,105 @@ import { auth } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; + +// Схема валидации для создания цвета +const colorSchema = z.object({ + name: z.string().min(2, 'Название должно содержать минимум 2 символа'), + value: z + .string() + .min(4, 'Неверный формат цвета') + .max(9, 'Неверный формат цвета') + .regex(/^#/, { message: 'Цвет должен начинаться с #' }), +}); export async function POST(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { - const { userId }: { userId: string | null } = await auth(); - - const body = await request.json(); - - const { name, value } = body; + const params = await props.params; + const { userId } = await auth(); if (!userId) { - return new NextResponse('Пользователь не аутентифицирован', { - status: 403, - }); + return errorResponses.unauthorized(); } - if (!name) { - return new NextResponse('Укажите название.', { status: 400 }); + if (!params.storeId) { + return errorResponses.badRequest('Необходим идентификатор магазина'); } - if (!value) { - return new NextResponse('Укажите значение.', { status: 400 }); - } + const body = await request.json(); + const validationResult = colorSchema.safeParse(body); - if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, - }); + if (!validationResult.success) { + return errorResponses.validationError(validationResult.error.message); } - const storeByUserId = await prismadb.store.findFirst({ - where: { - id: params.storeId, - userId, - }, - }); + // Используем транзакцию для проверки прав и создания цвета + const color = await prismadb.$transaction(async (tx) => { + const storeByUserId = await tx.store.findFirst({ + where: { + id: params.storeId, + userId, + }, + select: { id: true }, + }); - if (!storeByUserId) { - return new NextResponse('Не авторизованный доступ', { status: 405 }); - } + if (!storeByUserId) { + throw new Error('Не авторизованный доступ'); + } - const color = await prismadb.color.create({ - data: { - name, - value, - storeId: params.storeId, - }, + return tx.color.create({ + data: { + name: validationResult.data.name, + value: validationResult.data.value, + storeId: params.storeId, + }, + select: { + id: true, + name: true, + value: true, + createdAt: true, + }, + }); }); - return NextResponse.json(color); + return NextResponse.json(color, { status: 201 }); } catch (error) { - console.log('[COLORS_POST]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[COLORS_POST]', error); + if (error instanceof Error && error.message === 'Не авторизованный доступ') { + return errorResponses.forbidden(); + } + return errorResponses.serverError(error); } } export async function GET(request: Request, props: { params: Promise<{ storeId: string }> }) { - const params = await props.params; try { + const params = await props.params; + if (!params.storeId) { - return new NextResponse('Необходим идентификатор магазина.', { - status: 400, - }); + return errorResponses.badRequest('Необходим идентификатор магазина'); } const colors = await prismadb.color.findMany({ where: { storeId: params.storeId, }, + select: { + id: true, + name: true, + value: true, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, }); return NextResponse.json(colors); } catch (error) { - console.log('[COLORS_GET]', error); - return new NextResponse('Ошибка сервера', { status: 500 }); + console.error('[COLORS_GET]', error); + return errorResponses.serverError(error); } } diff --git a/app/api/[storeId]/products/[productId]/route.ts b/app/api/[storeId]/products/[productId]/route.ts index 59df3cb..bf7d525 100644 --- a/app/api/[storeId]/products/[productId]/route.ts +++ b/app/api/[storeId]/products/[productId]/route.ts @@ -63,7 +63,6 @@ export async function DELETE( return errorResponses.unauthorized(); } - // Используем транзакцию для обеспечения целостности данных const product = await prismadb.$transaction(async (tx) => { const storeByUserId = await tx.store.findFirst({ where: { id: params.storeId, userId }, diff --git a/app/api/[storeId]/sizes/[sizeId]/route.ts b/app/api/[storeId]/sizes/[sizeId]/route.ts index 7c0ba1b..6e9e5fc 100644 --- a/app/api/[storeId]/sizes/[sizeId]/route.ts +++ b/app/api/[storeId]/sizes/[sizeId]/route.ts @@ -17,12 +17,20 @@ export async function GET(request: Request, props: { params: Promise<{ sizeId: s const params = await props.params; if (!params.sizeId) { - return new NextResponse('Необходим идентификатор размера.', { status: 400 }); + return new NextResponse('Необходим идентификатор размера.', { + status: 400, + }); } const size = await prismadb.size.findUnique({ where: { id: params.sizeId }, - select: { id: true, name: true, value: true, createdAt: true, updatedAt: true }, + select: { + id: true, + name: true, + value: true, + createdAt: true, + updatedAt: true, + }, }); if (!size) { @@ -50,10 +58,11 @@ export async function DELETE( } if (!params.sizeId) { - return new NextResponse('Необходим идентификатор размера.', { status: 400 }); + return new NextResponse('Необходим идентификатор размера.', { + status: 400, + }); } - // Используем транзакцию для обеспечения целостности данных const size = await prismadb.$transaction(async (tx) => { const storeByUserId = await tx.store.findFirst({ where: { @@ -96,7 +105,9 @@ export async function PATCH( } if (!params.sizeId) { - return new NextResponse('Необходим идентификатор размера.', { status: 400 }); + return new NextResponse('Необходим идентификатор размера.', { + status: 400, + }); } // Валидация входных данных diff --git a/app/api/stores/route.ts b/app/api/stores/route.ts index b3be671..d8f0bba 100644 --- a/app/api/stores/route.ts +++ b/app/api/stores/route.ts @@ -3,51 +3,86 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import prismadb from '@/lib/prismadb'; +import { errorResponses } from '@/lib/error-responses'; +// схема валидации для создания магазина const createStoreSchema = z.object({ - name: z.string().min(2, 'Имя должно содержать минимум 2 символа'), + name: z + .string() + .min(2, 'Имя должно содержать минимум 2 символа') + .max(50, 'Имя слишком длинное') + .trim() + .regex(/^[\p{L}\s-]+$/u, 'Имя может содержать только буквы, пробелы и дефисы'), }); export async function POST(request: Request) { try { - // Get user authentication status const { userId } = await auth(); if (!userId) { - return new NextResponse('Unauthorized', { status: 401 }); + return errorResponses.unauthorized(); } - // Parse and validate request body const body = await request.json(); const validationResult = createStoreSchema.safeParse(body); if (!validationResult.success) { - return new NextResponse(validationResult.error.message, { status: 400 }); + return errorResponses.validationError(validationResult.error.message); } - // Create store with validated data - const store = await prismadb.store.create({ - data: { - name: validationResult.data.name, - userId, - }, - select: { - id: true, - name: true, - createdAt: true, - }, + // Проверка на лимит магазинов + const storesCount = await prismadb.store.count({ + where: { userId }, }); - return NextResponse.json(store, { status: 201 }); - } catch (error) { - // Log error for monitoring but don't expose details - console.error('[STORES_POST]', error); + if (storesCount >= 7) { + return errorResponses.forbidden('Достигнут лимит магазинов (максимум 3)'); + } + + // Используем транзакцию для проверки дубликатов и создания магазина + const store = await prismadb.$transaction(async (tx) => { + // Проверка на дубликаты + const existingStore = await tx.store.findFirst({ + where: { + userId, + name: validationResult.data.name, + }, + select: { id: true }, + }); - return new NextResponse('Internal Server Error', { - status: 500, + if (existingStore) { + throw new Error('Магазин с таким именем уже существует'); + } + + return tx.store.create({ + data: { + name: validationResult.data.name, + userId, + }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); + }); + + return NextResponse.json(store, { + status: 201, headers: { - 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }); + } catch (error) { + console.error('[STORES_POST]', error); + if (error instanceof Error) { + switch (error.message) { + case 'Магазин с таким именем уже существует': + return errorResponses.conflict(error.message); + default: + return errorResponses.serverError(error); + } + } + return errorResponses.serverError(error); } } diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts index a0385d6..1a3cd46 100644 --- a/app/api/webhook/route.ts +++ b/app/api/webhook/route.ts @@ -39,7 +39,9 @@ export async function POST(request: Request) { // Validate session and metadata if (!session?.metadata?.orderId) { - return new NextResponse('Missing order ID in session metadata', { status: 400 }); + return new NextResponse('Missing order ID in session metadata', { + status: 400, + }); } const orderId = session.metadata.orderId; diff --git a/next.config.mjs b/next.config.mjs index 25458e7..e12ee0b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -10,7 +10,7 @@ const nextConfig = { hostname: 'res.cloudinary.com', }, ], - minimumCacheTTL: 1500000 + minimumCacheTTL: 1500000, }, typescript: { ignoreBuildErrors: true,