feat: complete backend swagger annotations, admin pages pagination, and frontend application fixes

This commit is contained in:
پارسا آقایی 2026-07-10 16:13:52 +03:30
parent 24137beb32
commit 2851ef8a25
41 changed files with 478 additions and 128 deletions

View File

@ -1,13 +1,17 @@
import { Controller, Get, UseGuards, Param, Put, Post, Body, Delete, Query } from '@nestjs/common';
import { AdminService } from './admin.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
@ApiTags('Admin - پنل مدیریت')
@ApiBearerAuth()
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@UseGuards(JwtAuthGuard)
@Get('dashboard/stats')
@ApiOperation({ summary: 'دریافت آمار کلی داشبورد' })
async getDashboardStats() {
const stats = await this.adminService.getDashboardStats();
return {
@ -18,6 +22,11 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Get('users')
@ApiOperation({ summary: 'لیست کاربران' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام یا ایمیل' })
@ApiQuery({ name: 'role', required: false, description: 'فیلتر بر اساس نقش کاربر' })
async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@ -30,6 +39,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('users/:id/role')
@ApiOperation({ summary: 'تغییر نقش کاربر' })
async updateUserRole(@Param('id') id: string, @Body('role') role: string) {
const user = await this.adminService.updateUserRole(id, role);
return {
@ -40,6 +50,11 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Get('products')
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
@ApiQuery({ name: 'categoryId', required: false, description: 'شناسه دسته‌بندی' })
async getProducts(
@Query('page') page?: string,
@Query('limit') limit?: string,
@ -52,6 +67,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Post('products')
@ApiOperation({ summary: 'ایجاد محصول جدید' })
async createProduct(@Body() data: any) {
const product = await this.adminService.createProduct(data);
return { success: true, data: product };
@ -59,6 +75,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('products/:id')
@ApiOperation({ summary: 'ویرایش محصول' })
async updateProduct(@Param('id') id: string, @Body() data: any) {
const product = await this.adminService.updateProduct(id, data);
return {
@ -69,6 +86,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Delete('products/:id')
@ApiOperation({ summary: 'حذف محصول' })
async deleteProduct(@Param('id') id: string) {
await this.adminService.deleteProduct(id);
return {
@ -79,6 +97,11 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Get('orders')
@ApiOperation({ summary: 'لیست سفارش‌ها' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
@ApiQuery({ name: 'status', required: false, description: 'وضعیت سفارش' })
async getOrders(
@Query('page') page?: string,
@Query('limit') limit?: string,
@ -91,6 +114,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('orders/:id/status')
@ApiOperation({ summary: 'تغییر وضعیت سفارش' })
async updateOrderStatus(@Param('id') id: string, @Body('status') status: string) {
const order = await this.adminService.updateOrderStatus(id, status);
return {
@ -102,6 +126,10 @@ export class AdminController {
// --- Coupons ---
@UseGuards(JwtAuthGuard)
@Get('coupons')
@ApiOperation({ summary: 'لیست کد تخفیف‌ها' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getCoupons(@Query() query: any) {
const coupons = await this.adminService.getCoupons(query);
return { success: true, ...coupons };
@ -109,6 +137,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Post('coupons')
@ApiOperation({ summary: 'ایجاد کد تخفیف جدید' })
async createCoupon(@Body() data: any) {
const coupon = await this.adminService.createCoupon(data);
return { success: true, data: coupon };
@ -116,6 +145,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('coupons/:id')
@ApiOperation({ summary: 'ویرایش کد تخفیف' })
async updateCoupon(@Param('id') id: string, @Body() data: any) {
const coupon = await this.adminService.updateCoupon(id, data);
return { success: true, data: coupon };
@ -123,6 +153,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('coupons/:id/toggle')
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
async toggleCoupon(@Param('id') id: string, @Body('isActive') isActive: boolean) {
const coupon = await this.adminService.toggleCoupon(id, isActive);
return { success: true, data: coupon };
@ -130,6 +161,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Delete('coupons/:id')
@ApiOperation({ summary: 'حذف کد تخفیف' })
async deleteCoupon(@Param('id') id: string) {
await this.adminService.deleteCoupon(id);
return { success: true };
@ -138,6 +170,7 @@ export class AdminController {
// --- Settings ---
@UseGuards(JwtAuthGuard)
@Get('settings')
@ApiOperation({ summary: 'دریافت تنظیمات' })
async getSettings() {
const settings = await this.adminService.getSettings();
return { success: true, data: settings };
@ -145,6 +178,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('settings')
@ApiOperation({ summary: 'ذخیره تنظیمات' })
async updateSettings(@Body() data: Record<string, string>) {
const settings = await this.adminService.updateSettings(data);
return { success: true, data: settings };

View File

@ -57,7 +57,7 @@ export class AdminService {
this.prisma.user.count({ where })
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
}
async updateUserRole(id: string, role: string) {
@ -92,7 +92,7 @@ export class AdminService {
this.prisma.product.count({ where })
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
} catch (error) {
console.error("[AdminService] getProducts error:", error);
throw new HttpException(error.message || 'Error fetching products', 500);
@ -179,7 +179,7 @@ export class AdminService {
this.prisma.order.count({ where })
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) } };
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
}
async updateOrderStatus(id: string, status: string) {
@ -209,7 +209,7 @@ export class AdminService {
return {
data,
meta: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) }
meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
};
}

View File

@ -1,30 +1,40 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import { BlogsService } from './blogs.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('admin/blogs')
export class BlogsController {
constructor(private readonly blogsService: BlogsService) {}
@Get()
@ApiOperation({ summary: 'لیست مقالات' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getBlogs(@Query() query: any) {
return this.blogsService.getBlogs(query);
}
@Post()
@ApiOperation({ summary: 'ایجاد مقاله جدید' })
async createBlog(@Body() body: any, @Request() req: any) {
const data = await this.blogsService.createBlog(body, req.user.id);
return { success: true, data };
}
@Put(':id')
@ApiOperation({ summary: 'ویرایش مقاله' })
async updateBlog(@Param('id') id: string, @Body() body: any) {
const data = await this.blogsService.updateBlog(id, body);
return { success: true, data };
}
@Delete(':id')
@ApiOperation({ summary: 'حذف مقاله' })
async deleteBlog(@Param('id') id: string) {
await this.blogsService.deleteBlog(id);
return { success: true };

View File

@ -24,7 +24,7 @@ export class BlogsService {
return {
data,
meta: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) }
meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
};
}

View File

@ -1,36 +1,47 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { CategoriesService } from './categories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
@ApiTags('Admin - مدیریت دسته‌بندی‌ها')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('admin/categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'لیست دسته‌بندی‌ها (با صفحه‌بندی)' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام' })
async getCategories(@Query() query: any) {
return this.categoriesService.getCategories(query);
}
@Get('all')
@ApiOperation({ summary: 'لیست تمام دسته‌بندی‌ها (بدون صفحه‌بندی)' })
async getAllCategoriesRaw() {
const data = await this.categoriesService.getAllCategoriesRaw();
return { success: true, data };
}
@Post()
@ApiOperation({ summary: 'ایجاد دسته‌بندی جدید' })
async createCategory(@Body() body: any) {
const data = await this.categoriesService.createCategory(body);
return { success: true, data };
}
@Put(':id')
@ApiOperation({ summary: 'ویرایش دسته‌بندی' })
async updateCategory(@Param('id') id: string, @Body() body: any) {
const data = await this.categoriesService.updateCategory(id, body);
return { success: true, data };
}
@Delete(':id')
@ApiOperation({ summary: 'حذف دسته‌بندی' })
async deleteCategory(@Param('id') id: string) {
await this.categoriesService.deleteCategory(id);
return { success: true };

View File

@ -23,7 +23,7 @@ export class CategoriesService {
return {
data,
meta: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) }
meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
};
}

View File

@ -2,13 +2,17 @@ import { Controller, Get, Post, Delete, Param, UseGuards, UseInterceptors, Uploa
import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from './media.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
@ApiTags('Admin - مدیریت رسانه (تصاویر)')
@ApiBearerAuth()
@Controller('admin/media')
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
@UseGuards(JwtAuthGuard)
@Get()
@ApiOperation({ summary: 'لیست فایل‌های رسانه' })
async getAllMedia() {
const data = await this.mediaService.getAllMedia();
return { success: true, data };
@ -16,6 +20,7 @@ export class MediaController {
@UseGuards(JwtAuthGuard)
@Post('upload')
@ApiOperation({ summary: 'آپلود فایل جدید' })
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('File is missing');
@ -25,6 +30,7 @@ export class MediaController {
@UseGuards(JwtAuthGuard)
@Delete(':id')
@ApiOperation({ summary: 'حذف فایل' })
async deleteMedia(@Param('id') id: string) {
const data = await this.mediaService.deleteMedia(id);
return { success: true, data };

View File

@ -1,18 +1,26 @@
import { Controller, Get, Delete, Param, Query, UseGuards } from '@nestjs/common';
import { PetsService } from './pets.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
@ApiTags('Admin - مدیریت حیوانات خانگی')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('admin/pets')
export class PetsController {
constructor(private readonly petsService: PetsService) {}
@Get()
@ApiOperation({ summary: 'لیست حیوانات خانگی' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getPets(@Query() query: any) {
return this.petsService.getPets(query);
}
@Delete(':id')
@ApiOperation({ summary: 'حذف حیوان خانگی' })
async deletePet(@Param('id') id: string) {
await this.petsService.deletePet(id);
return { success: true };

View File

@ -24,7 +24,7 @@ export class PetsService {
return {
data,
meta: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) }
meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
};
}

View File

@ -1,13 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ReportsService } from './reports.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
@ApiTags('Admin - گزارشات')
@ApiBearerAuth()
@Controller('admin/reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@UseGuards(JwtAuthGuard)
@Get()
@ApiOperation({ summary: 'دریافت گزارشات داشبورد' })
async getReports() {
const data = await this.reportsService.getDashboardReports();
return { success: true, data };

View File

@ -1,30 +1,40 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { WikiService } from './wiki.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('admin/wiki')
export class WikiController {
constructor(private readonly wikiService: WikiService) {}
@Get()
@ApiOperation({ summary: 'لیست اصطلاحات دانشنامه' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getTerms(@Query() query: any) {
return this.wikiService.getTerms(query);
}
@Post()
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
async createTerm(@Body() body: any) {
const data = await this.wikiService.createTerm(body);
return { success: true, data };
}
@Put(':key')
@ApiOperation({ summary: 'ویرایش اصطلاح' })
async updateTerm(@Param('key') key: string, @Body() body: any) {
const data = await this.wikiService.updateTerm(key, body);
return { success: true, data };
}
@Delete(':key')
@ApiOperation({ summary: 'حذف اصطلاح' })
async deleteTerm(@Param('key') key: string) {
await this.wikiService.deleteTerm(key);
return { success: true };

View File

@ -23,7 +23,7 @@ export class WikiService {
return {
data,
meta: { total, page: Number(page), limit: Number(limit), totalPages: Math.ceil(total / Number(limit)) }
meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
};
}

View File

@ -1,10 +1,13 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ description: 'شماره موبایل', example: '09123456789' })
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@IsString()
mobile: string;
@ApiProperty({ description: 'رمز عبور', example: 'password123' })
@IsNotEmpty({ message: 'رمز عبور الزامی است' })
@MinLength(6, { message: 'رمز عبور باید حداقل ۶ کاراکتر باشد' })
password: string;

View File

@ -1,22 +1,28 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ description: 'نام', example: 'علی' })
@IsNotEmpty({ message: 'نام الزامی است' })
@IsString()
firstName: string;
@ApiProperty({ description: 'نام خانوادگی', example: 'رضایی' })
@IsNotEmpty({ message: 'نام خانوادگی الزامی است' })
@IsString()
lastName: string;
@ApiPropertyOptional({ description: 'ایمیل (اختیاری)', example: 'ali@example.com' })
@IsOptional()
@IsEmail({}, { message: 'ایمیل نامعتبر است' })
email?: string;
@ApiProperty({ description: 'شماره موبایل', example: '09123456789' })
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@IsString()
mobile: string;
@ApiProperty({ description: 'رمز عبور', example: 'password123' })
@IsNotEmpty({ message: 'رمز عبور الزامی است' })
@MinLength(6, { message: 'رمز عبور باید حداقل ۶ کاراکتر باشد' })
password: string;

View File

@ -1,6 +1,7 @@
import { Controller, Get, Param, HttpStatus } from '@nestjs/common';
import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common';
import { BlogsService } from './blogs.service';
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse, ApiQuery } from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Blogs - مجله سلامت')
@Controller('blogs')
@ -13,9 +14,9 @@ export class BlogsController {
@Get()
@ApiOperation({ summary: 'دریافت لیست مقالات مجله سلامت' })
@ApiOkResponse({ description: 'لیست مقالات منتشر شده' })
findAll() {
return this.blogsService.findAll();
@ApiOkResponse({ description: 'لیست مقالات منتشر شده (صفحه‌بندی شده)' })
findAll(@Query() query: PaginationDto) {
return this.blogsService.findAll(query);
}
@Get(':slug')

View File

@ -1,20 +1,48 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
@Injectable()
export class BlogsService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.blog.findMany({
where: { isPublished: true },
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { firstName: true, lastName: true }
async findAll(filters: PaginationDto) {
const { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
const whereClause: any = { isPublished: true };
if (search) {
whereClause.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
];
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.blog.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
author: {
select: { firstName: true, lastName: true }
}
}
}),
this.prisma.blog.count({ where: whereClause })
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
}
});
};
}
async findOneBySlug(slug: string) {

View File

@ -0,0 +1,39 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsInt, Min, IsString, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export class PaginationDto {
@ApiPropertyOptional({ description: 'شماره صفحه (شروع از ۱)', minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ description: 'تعداد آیتم‌ها در هر صفحه', minimum: 1, default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
limit?: number = 10;
@ApiPropertyOptional({ description: 'فیلد برای مرتب‌سازی', default: 'createdAt' })
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@ApiPropertyOptional({ description: 'جهت مرتب‌سازی (asc/desc)', enum: SortOrder, default: SortOrder.DESC })
@IsOptional()
@IsEnum(SortOrder)
sortOrder?: SortOrder = SortOrder.DESC;
@ApiPropertyOptional({ description: 'عبارت جستجو' })
@IsOptional()
@IsString()
search?: string;
}

View File

@ -9,10 +9,27 @@ export class HttpExceptionFilter implements ExceptionFilter {
const status = exception.getStatus();
const exceptionResponse: any = exception.getResponse();
let message = typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور');
let code = typeof exceptionResponse === 'object' && exceptionResponse.error ? exceptionResponse.error : (status === 400 ? 'BAD_REQUEST' : 'ERROR');
// Convert array of class-validator errors to a generic Farsi message if it's a 400
if (Array.isArray(message) && status === 400) {
message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.';
} else if (message === 'Unauthorized' || status === 401) {
message = 'شما دسترسی لازم برای این عملیات را ندارید. لطفاً وارد شوید.';
code = 'UNAUTHORIZED';
} else if (message === 'Forbidden' || status === 403) {
message = 'دسترسی غیرمجاز.';
code = 'FORBIDDEN';
} else if (message === 'Not Found' || status === 404) {
message = 'مورد درخواستی یافت نشد.';
code = 'NOT_FOUND';
}
response.status(status).json({
success: false,
message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور'),
code: exceptionResponse.error || (status === 400 ? 'BAD_REQUEST' : 'ERROR'),
message,
code,
details: typeof exceptionResponse === 'object' ? exceptionResponse : {}
});
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class ApiErrorResponse {
@ApiProperty({ description: 'موفقیت‌آمیز بودن درخواست', example: false })
success: boolean;
@ApiProperty({ description: 'پیام خطا', example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.' })
message: string;
@ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' })
code: string;
@ApiProperty({ description: 'جزئیات خطا (در صورت وجود)', required: false, example: {} })
details?: any;
}

View File

@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
export class PaginationMeta {
@ApiProperty({ description: 'تعداد کل آیتم‌ها', example: 100 })
total: number;
@ApiProperty({ description: 'شماره صفحه فعلی', example: 1 })
page: number;
@ApiProperty({ description: 'تعداد کل صفحات', example: 10 })
lastPage: number;
@ApiProperty({ description: 'تعداد آیتم‌ها در هر صفحه', example: 10 })
limit: number;
}
export class PaginatedResponse<T> {
@ApiProperty({ description: 'آرایه آیتم‌ها' })
data: T[];
@ApiProperty({ description: 'اطلاعات صفحه‌بندی' })
meta: PaginationMeta;
}
export function createPaginatedSchema(dtoModel: any) {
return {
schema: {
allOf: [
{
properties: {
data: {
type: 'array',
items: { $ref: `#/components/schemas/${dtoModel.name}` },
},
meta: {
$ref: '#/components/schemas/PaginationMeta',
},
},
},
],
},
};
}

View File

@ -1,8 +1,9 @@
import { Controller, Get, Post, Body, Param, UseGuards, Req, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Orders - مدیریت سفارش‌ها')
@ApiBearerAuth()
@ -58,22 +59,30 @@ export class OrdersController {
@Get()
@ApiOperation({ summary: 'لیست سفارش‌های کاربر فعلی' })
@ApiOkResponse({
description: 'آرایه‌ای از سفارش‌های ثبت شده کاربر',
description: 'آرایه‌ای از سفارش‌های ثبت شده کاربر (صفحه‌بندی شده)',
schema: {
example: [
{
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
totalAmount: '3500000.00',
charityDonation: '10000.00',
status: 'processing',
createdAt: '2026-05-26T18:10:00.000Z'
example: {
data: [
{
id: 'e1d2c3b4-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
totalAmount: '3500000.00',
charityDonation: '10000.00',
status: 'processing',
createdAt: '2026-05-26T18:10:00.000Z'
}
],
meta: {
total: 1,
page: 1,
lastPage: 1,
limit: 10
}
]
}
}
})
findAll(@Req() req: any) {
return this.ordersService.findAllByUser(req.user.id);
findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.ordersService.findAllByUser(req.user.id, query);
}
@Get(':id')

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
import { CreateOrderDto } from './dto/create-order.dto';
@Injectable()
@ -43,14 +44,32 @@ export class OrdersService {
});
}
async findAllByUser(userId: string) {
return this.prisma.order.findMany({
where: { userId },
include: {
orderItems: { include: { product: true } },
},
orderBy: { createdAt: 'desc' },
});
async findAllByUser(userId: string, filters: PaginationDto) {
const { page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.order.findMany({
where: { userId },
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
orderItems: { include: { product: true } },
},
}),
this.prisma.order.count({ where: { userId } })
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
}
};
}
async findOne(id: string, userId: string) {

View File

@ -1,9 +1,10 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
import { PetsService } from './pets.service';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Pets - مدیریت حیوانات خانگی')
@ApiBearerAuth()
@ -61,26 +62,34 @@ export class PetsController {
@Get()
@ApiOperation({ summary: 'لیست حیوانات خانگی کاربر فعلی' })
@ApiOkResponse({
description: 'آرایه‌ای از حیوانات خانگی ثبت شده کاربر',
description: 'آرایه‌ای از حیوانات خانگی ثبت شده کاربر (صفحه‌بندی شده)',
schema: {
example: [
{
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
name: 'بادی',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 3,
weight: '25.50',
activityLevel: 'متوسط',
imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z'
example: {
data: [
{
id: 'c7b8d9e0-1234-5678-abcd-ef1234567890',
userId: 'd8c4e428-cd2f-4c55-bfa3-dfa312bb6480',
name: 'بادی',
type: 'سگ',
breed: 'ژرمن شپرد',
age: 3,
weight: '25.50',
activityLevel: 'متوسط',
imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z'
}
],
meta: {
total: 1,
page: 1,
lastPage: 1,
limit: 10
}
]
}
}
})
findAll(@Req() req: any) {
return this.petsService.findAllByUser(req.user.id);
findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.petsService.findAllByUser(req.user.id, query);
}
@Get(':id')

View File

@ -1,5 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
@ -27,11 +28,38 @@ export class PetsService {
});
}
async findAllByUser(userId: string) {
return this.prisma.pet.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
async findAllByUser(userId: string, filters: PaginationDto) {
const { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
const whereClause: any = { userId };
if (search) {
whereClause.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ breed: { contains: search, mode: 'insensitive' } },
];
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.pet.count({ where: whereClause })
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
}
};
}
async findOne(id: string, userId: string) {

View File

@ -1,19 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsEnum } from 'class-validator';
import { PaginationDto } from '../../common/dto/pagination.dto';
export class GetProductsDto {
@ApiPropertyOptional({ description: 'Filter by category slug' })
export class GetProductsDto extends PaginationDto {
@ApiPropertyOptional({ description: 'فیلتر بر اساس اسلاگ دسته‌بندی' })
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({ description: 'Filter by pet type', enum: ['سگ', 'گربه', 'all'] })
@ApiPropertyOptional({ description: 'فیلتر بر اساس نوع حیوان', enum: ['سگ', 'گربه', 'all'] })
@IsOptional()
@IsEnum(['سگ', 'گربه', 'all'])
petType?: string;
@ApiPropertyOptional({ description: 'Search query string' })
@IsOptional()
@IsString()
query?: string;
}

View File

@ -25,28 +25,36 @@ export class ProductsController {
@ApiOkResponse({
description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)',
schema: {
example: [
{
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [],
symptoms: []
example: {
data: [
{
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [],
symptoms: []
}
],
meta: {
total: 1,
page: 1,
lastPage: 1,
limit: 10
}
]
}
}
})
findAll(@Query() query: GetProductsDto) {

View File

@ -7,7 +7,7 @@ export class ProductsService {
constructor(private prisma: PrismaService) {}
async findAll(filters: GetProductsDto) {
const { category, petType, query } = filters;
const { category, petType, search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
const whereClause: any = {};
@ -19,22 +19,38 @@ export class ProductsService {
whereClause.suitableFor = { in: [petType, 'هر دو'] };
}
if (query) {
if (search) {
whereClause.OR = [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const products = await this.prisma.product.findMany({
where: whereClause,
include: {
ingredients: true,
symptoms: true,
},
});
const skip = (page - 1) * limit;
return products;
const [data, total] = await Promise.all([
this.prisma.product.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: {
ingredients: true,
symptoms: true,
},
}),
this.prisma.product.count({ where: whereClause }),
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
},
};
}
async findOne(idOrSlug: string) {

View File

@ -1,6 +1,7 @@
import { Controller, Get, Param, HttpStatus } from '@nestjs/common';
import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common';
import { WikiService } from './wiki.service';
import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Wiki - دانشنامه ترکیبات')
@Controller('wiki')
@ -13,9 +14,9 @@ export class WikiController {
@Get()
@ApiOperation({ summary: 'دریافت تمام کلمات دانشنامه' })
@ApiOkResponse({ description: 'لیست تمامی ترکیبات علمی' })
findAll() {
return this.wikiService.findAll();
@ApiOkResponse({ description: 'لیست کلمات به همراه توضیحات (صفحه‌بندی شده)' })
findAll(@Query() query: PaginationDto) {
return this.wikiService.findAll(query);
}
@Get(':key')

View File

@ -1,14 +1,43 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
@Injectable()
export class WikiService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.scientificTerm.findMany({
orderBy: { term: 'asc' }
});
async findAll(filters: PaginationDto) {
const { search, page = 1, limit = 10, sortBy = 'term', sortOrder = 'asc' } = filters;
const whereClause: any = {};
if (search) {
whereClause.OR = [
{ term: { contains: search, mode: 'insensitive' } },
{ definition: { contains: search, mode: 'insensitive' } },
];
}
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
this.prisma.scientificTerm.findMany({
where: whereClause,
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
}),
this.prisma.scientificTerm.count({ where: whereClause })
]);
return {
data,
meta: {
total,
page,
lastPage: Math.ceil(total / limit),
limit,
}
};
}
async findOneByKey(key: string) {

View File

@ -34,7 +34,7 @@ export default function Blogs() {
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
if (res.data?.data) {
setBlogs(res.data.data);
setTotalPages(res.data.meta?.totalPages || 1);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);

View File

@ -33,7 +33,7 @@ export default function Categories() {
const res = await api.get('/admin/categories', { params: { page, limit, search } });
if (res.data?.data) {
setCategories(res.data.data);
setTotalPages(res.data.meta?.totalPages || 1);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);

View File

@ -25,7 +25,7 @@ export default function Coupons() {
const res = await api.get('/admin/coupons', { params: { page, limit, search } });
if (res.data?.data) {
setCoupons(res.data.data);
setTotalPages(res.data.meta?.totalPages || 1);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);

View File

@ -105,7 +105,7 @@ export default function Dashboard() {
paddingAngle={5}
dataKey="value"
>
{pieData.map((entry, index) => (
{pieData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>

View File

@ -35,7 +35,7 @@ export default function Orders() {
const response = await api.get(`/admin/orders?${params.toString()}`);
if (response.data?.success) {
setOrders(response.data.data);
setTotalPages(response.data.meta?.totalPages || 1);
setTotalPages(response.data.meta?.lastPage || 1);
}
} catch (err) {
console.error('Failed to fetch orders', err);

View File

@ -18,7 +18,7 @@ export default function Pets() {
const res = await api.get('/admin/pets', { params: { page, limit, search } });
if (res.data?.data) {
setPets(res.data.data);
setTotalPages(res.data.meta?.totalPages || 1);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);

View File

@ -52,7 +52,7 @@ export default function Products() {
const prodRes = await api.get('/admin/products', { params: { page, limit, search, categoryId: categoryFilter } });
if (prodRes.data?.data) {
setProducts(prodRes.data.data);
setTotalPages(prodRes.data.meta?.totalPages || 1);
setTotalPages(prodRes.data.meta?.lastPage || 1);
}
// Fetch categories separately so it doesn't break products

View File

@ -31,7 +31,7 @@ export default function Users() {
const response = await api.get(`/admin/users?${params.toString()}`);
if (response.data?.success) {
setUsers(response.data.data);
setTotalPages(response.data.meta?.totalPages || 1);
setTotalPages(response.data.meta?.lastPage || 1);
}
} catch (err) {
console.error('Failed to fetch users', err);

View File

@ -31,7 +31,7 @@ export default function Wiki() {
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
if (res.data?.data) {
setTerms(res.data.data);
setTotalPages(res.data.meta?.totalPages || 1);
setTotalPages(res.data.meta?.lastPage || 1);
}
} catch (err) {
console.error(err);

View File

@ -55,7 +55,7 @@ export class OrderService {
public async getUserOrders(): Promise<Order[]> {
try {
const response = await api.get('/orders');
return response.data;
return response.data.data;
} catch (error: any) {
const message = error.response?.data?.message || 'خطا در دریافت لیست سفارش‌ها';
throw new Error(Array.isArray(message) ? message[0] : message);

View File

@ -75,7 +75,7 @@ export class ProductService {
try {
const response = await api.get(`/products?${params.toString()}`);
return response.data.map((item: any) => this.mapBackendToFrontend(item));
return response.data.data.map((item: any) => this.mapBackendToFrontend(item));
} catch (error) {
console.error("[ProductService] Failed to fetch products:", error);
return [];
@ -105,7 +105,7 @@ export class ProductService {
public async getFeaturedProducts(): Promise<Product[]> {
try {
const response = await api.get('/products');
return response.data.slice(0, 4).map((item: any) => this.mapBackendToFrontend(item));
return response.data.data.slice(0, 4).map((item: any) => this.mapBackendToFrontend(item));
} catch (error) {
console.error("[ProductService] Failed to fetch featured products:", error);
return [];

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 4000",
"dev": "next dev -p 4001",
"build": "next build",
"start": "next start",
"lint": "eslint"