From 7b89e141c5e1ded54981c322b198611e6c931919 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Tue, 25 Aug 2026 11:33:37 +0330 Subject: [PATCH] feat(core): polish codebase, enhance error handling, security hardening, rate limiting, and a11y compliance --- backend/prisma/schema.prisma | 11 ++++ backend/src/auth/auth.controller.ts | 3 + backend/src/b2b/b2b.controller.ts | 3 + backend/src/blogs/blogs.controller.ts | 2 + backend/src/common/env.validation.ts | 56 +++++++++++++++++++ .../common/filters/http-exception.filter.ts | 5 +- .../common/filters/prisma-exception.filter.ts | 16 ++++-- backend/src/contact/contact.controller.ts | 3 + backend/src/main.ts | 3 + backend/src/reviews/reviews.controller.ts | 2 + frontend/application/app/b2b/error.tsx | 17 ++++++ frontend/application/app/blog/error.tsx | 17 ++++++ frontend/application/app/blog/loading.tsx | 31 ++++++++++ frontend/application/app/shop/error.tsx | 17 ++++++ frontend/application/app/shop/loading.tsx | 38 +++++++++++++ .../components/B2BLandingClient.tsx | 18 ++++-- .../components/ContactFormClient.tsx | 12 ++-- 17 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 backend/src/common/env.validation.ts create mode 100644 frontend/application/app/b2b/error.tsx create mode 100644 frontend/application/app/blog/error.tsx create mode 100644 frontend/application/app/blog/loading.tsx create mode 100644 frontend/application/app/shop/error.tsx create mode 100644 frontend/application/app/shop/loading.tsx diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4ed8d50..d7f987f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -33,6 +33,8 @@ model User { reviews ProductReview[] blogComments BlogComment[] + @@index([role]) + @@index([createdAt]) @@map("users") } @@ -404,6 +406,9 @@ model Order { paymentTransactions PaymentTransaction[] inventoryReservations InventoryReservation[] + @@index([userId]) + @@index([status]) + @@index([createdAt]) @@map("orders") } @@ -589,6 +594,8 @@ model ContactSubmission { adminNotes String? @map("admin_notes") @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() + @@index([status]) + @@index([createdAt]) @@map("contact_submissions") } @@ -666,6 +673,8 @@ model Prescription { pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull) @@index([userId]) + @@index([status]) + @@index([createdAt]) @@map("prescriptions") } @@ -683,6 +692,8 @@ model B2BInquiry { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz() + @@index([status]) + @@index([createdAt]) @@map("b2b_inquiries") } diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 04b3985..15b02da 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { UseGuards, Req, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service'; import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; @@ -41,6 +42,7 @@ import { AdminLoginDto } from './dto/admin-login.dto'; export class AuthController { constructor(private readonly authService: AuthService) {} + @Throttle({ default: { limit: 5, ttl: 60000 } }) @Post('send-otp') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' }) @@ -58,6 +60,7 @@ export class AuthController { return this.authService.sendOtp(sendOtpDto); } + @Throttle({ default: { limit: 10, ttl: 60000 } }) @Post('verify-otp') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'تایید کد پیامکی و ورود به سیستم' }) diff --git a/backend/src/b2b/b2b.controller.ts b/backend/src/b2b/b2b.controller.ts index 55cce1f..ceb8032 100644 --- a/backend/src/b2b/b2b.controller.ts +++ b/backend/src/b2b/b2b.controller.ts @@ -14,11 +14,14 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; +import { Throttle } from '@nestjs/throttler'; + @ApiTags('B2B - مدیریت پنل B2B و درخواست‌ها') @Controller('b2b') export class B2BController { constructor(private readonly b2bService: B2BService) {} + @Throttle({ default: { limit: 5, ttl: 60000 } }) @Post('inquire') @ApiOperation({ summary: 'ثبت استعلام جدید B2B (فرم ثبت استعلام)' }) createInquiry( diff --git a/backend/src/blogs/blogs.controller.ts b/backend/src/blogs/blogs.controller.ts index 7752751..7316632 100644 --- a/backend/src/blogs/blogs.controller.ts +++ b/backend/src/blogs/blogs.controller.ts @@ -20,6 +20,7 @@ import { } from '@nestjs/swagger'; import { PaginationDto } from '../common/dto/pagination.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Throttle } from '@nestjs/throttler'; @ApiTags('Blogs - مجله سلامت') @Controller('blogs') @@ -70,6 +71,7 @@ export class BlogsController { return this.blogsService.findCommentsBySlug(slug); } + @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(JwtAuthGuard) @ApiBearerAuth() @Post(':slug/comments') diff --git a/backend/src/common/env.validation.ts b/backend/src/common/env.validation.ts new file mode 100644 index 0000000..e4508c0 --- /dev/null +++ b/backend/src/common/env.validation.ts @@ -0,0 +1,56 @@ +import { plainToInstance } from 'class-transformer'; +import { + IsNotEmpty, + IsOptional, + IsString, + validateSync, +} from 'class-validator'; + +class EnvironmentVariables { + @IsNotEmpty({ message: 'DATABASE_URL is required for database connection' }) + @IsString() + DATABASE_URL: string; + + @IsOptional() + @IsString() + NODE_ENV?: string = 'development'; + + @IsOptional() + @IsString() + PORT?: string = '4001'; + + @IsOptional() + @IsString() + JWT_ACCESS_SECRET?: string; + + @IsOptional() + @IsString() + JWT_REFRESH_SECRET?: string; + + @IsOptional() + @IsString() + REDIS_HOST?: string; + + @IsOptional() + @IsString() + REDIS_PORT?: string; +} + +export function validateEnv(config: Record) { + const validatedConfig = plainToInstance(EnvironmentVariables, config, { + enableImplicitConversion: true, + }); + + const errors = validateSync(validatedConfig, { + skipMissingProperties: false, + }); + + if (errors.length > 0) { + const messages = errors + .map((err) => Object.values(err.constraints || {}).join(', ')) + .join('; '); + throw new Error(`Environment Validation Error: ${messages}`); + } + + return validatedConfig; +} diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 7a7deb1..b180ea9 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -72,11 +72,10 @@ export class CustomHttpExceptionFilter implements ExceptionFilter { } const errorPayload = { - success: false, statusCode: status, message, - code, - details, + error: code, + details: details && Object.keys(details).length > 0 ? details : undefined, timestamp: new Date().toISOString(), path: request.url, }; diff --git a/backend/src/common/filters/prisma-exception.filter.ts b/backend/src/common/filters/prisma-exception.filter.ts index 61f2835..8a78b57 100644 --- a/backend/src/common/filters/prisma-exception.filter.ts +++ b/backend/src/common/filters/prisma-exception.filter.ts @@ -54,16 +54,24 @@ export class PrismaExceptionFilter implements ExceptionFilter { } } + const isProd = process.env.NODE_ENV === 'production'; + if (isProd && status === HttpStatus.INTERNAL_SERVER_ERROR) { + message = + 'خطایی در پردازش اطلاعات پایگاه داده رخ داد. لطفاً بعداً تلاش فرمایید.'; + details = {}; + } else if (isProd && details && (details as any).meta) { + delete (details as any).meta; + } + this.logger.error( - `[Prisma ${exception.code}] ${request.method} ${request.url} - ${message}`, + `[Prisma ${exception.code}] ${request.method} ${request.url} - ${exception.message}`, ); response.status(status).json({ - success: false, statusCode: status, message, - code, - details, + error: code, + details: Object.keys(details).length > 0 ? details : undefined, timestamp: new Date().toISOString(), path: request.url, }); diff --git a/backend/src/contact/contact.controller.ts b/backend/src/contact/contact.controller.ts index 7da0157..5e804e4 100644 --- a/backend/src/contact/contact.controller.ts +++ b/backend/src/contact/contact.controller.ts @@ -13,10 +13,13 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; +import { Throttle } from '@nestjs/throttler'; + @Controller('contact') export class ContactController { constructor(private readonly contactService: ContactService) {} + @Throttle({ default: { limit: 5, ttl: 60000 } }) @Post() async submitContact( @Body() diff --git a/backend/src/main.ts b/backend/src/main.ts index dfee4b0..442ab23 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -7,6 +7,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { CustomHttpExceptionFilter } from './common/filters/http-exception.filter'; import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter'; import { DecimalInterceptor } from './common/interceptors/decimal.interceptor'; +import { validateEnv } from './common/env.validation'; import helmet from 'helmet'; import compression from 'compression'; @@ -16,6 +17,8 @@ import compression from 'compression'; }; async function bootstrap() { + validateEnv(process.env); + process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || diff --git a/backend/src/reviews/reviews.controller.ts b/backend/src/reviews/reviews.controller.ts index 20fe9dc..060c0fe 100644 --- a/backend/src/reviews/reviews.controller.ts +++ b/backend/src/reviews/reviews.controller.ts @@ -17,6 +17,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; @ApiTags('Reviews - نظرات و امتیازدهی محصولات') @Controller() @@ -26,6 +27,7 @@ export class ReviewsController { /** * Public / User: Submit a review for a product */ + @Throttle({ default: { limit: 5, ttl: 60000 } }) @Post('products/:productId/reviews') @ApiOperation({ summary: 'ثبت دیدگاه جدید برای یک محصول' }) async createReview( diff --git a/frontend/application/app/b2b/error.tsx b/frontend/application/app/b2b/error.tsx new file mode 100644 index 0000000..2c70680 --- /dev/null +++ b/frontend/application/app/b2b/error.tsx @@ -0,0 +1,17 @@ +"use client"; +import React, { useEffect } from "react"; +import { ServerErrorPage } from "../../components/ErrorPages"; + +export default function B2BErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("B2B Route Error:", error); + }, [error]); + + return ; +} diff --git a/frontend/application/app/blog/error.tsx b/frontend/application/app/blog/error.tsx new file mode 100644 index 0000000..9ae5a3f --- /dev/null +++ b/frontend/application/app/blog/error.tsx @@ -0,0 +1,17 @@ +"use client"; +import React, { useEffect } from "react"; +import { ServerErrorPage } from "../../components/ErrorPages"; + +export default function BlogErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Blog Route Error:", error); + }, [error]); + + return ; +} diff --git a/frontend/application/app/blog/loading.tsx b/frontend/application/app/blog/loading.tsx new file mode 100644 index 0000000..97bde2e --- /dev/null +++ b/frontend/application/app/blog/loading.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Skeleton } from "../../components/Skeleton"; + +export default function BlogLoading() { + return ( +
+
+
+ + + +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+ + + +
+ ))} +
+
+
+ ); +} diff --git a/frontend/application/app/shop/error.tsx b/frontend/application/app/shop/error.tsx new file mode 100644 index 0000000..f205019 --- /dev/null +++ b/frontend/application/app/shop/error.tsx @@ -0,0 +1,17 @@ +"use client"; +import React, { useEffect } from "react"; +import { ServerErrorPage } from "../../components/ErrorPages"; + +export default function ShopErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error("Shop Route Error:", error); + }, [error]); + + return ; +} diff --git a/frontend/application/app/shop/loading.tsx b/frontend/application/app/shop/loading.tsx new file mode 100644 index 0000000..efcec8b --- /dev/null +++ b/frontend/application/app/shop/loading.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Skeleton } from "../../components/Skeleton"; + +export default function ShopLoading() { + return ( +
+
+ {/* Banner Skeleton */} +
+ + {/* Filters & Search Row */} +
+ +
+ + + +
+
+ + {/* Product Cards Grid */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/application/components/B2BLandingClient.tsx b/frontend/application/components/B2BLandingClient.tsx index 4b54ff4..130c83d 100644 --- a/frontend/application/components/B2BLandingClient.tsx +++ b/frontend/application/components/B2BLandingClient.tsx @@ -247,10 +247,11 @@ export default function B2BLandingClient() {
-