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,